MCP для искусственных агентов: стандартизация интеграции инструментов в LangGraph
В этой статье объясняется, что именно стандартизирует MCP в системах искусственного интеллекта с агентным подходом, путем сравнения специальных интеграций инструментов с теми, что основаны на MCP в рамках оркестратора LangGraph.
Введение и краткий обзор
К концу части 2 система достигла по-настоящему скоординированного состояния: набор специализированных агентов, каждый из которых отвечал за узкую область работы, выполнял свои задачи в рамках общего состояния под управлением оркестратора, определявшего, что следует запустить далее. Однако во всех примерах предполагалось, что каждый агент уже имеет доступ ко всему необходимому в этом общем состоянии и может читать его по мере необходимости.
Такое предположение редко справедливо в производственных условиях. Агенту обычно требуется что-то, что находится вне графа: строка из базы данных, ответ от внешнего API, фрагмент текста из базы знаний или какой-либо другой ресурс, находящийся за пределами собственных границ системы. Каждый раз, когда агенту необходимо получить доступ к таким ресурсам, ему нужен собственный путь для этого, и если каждый из этих путей создается вручную, приходится повторять одну и ту же работу по интеграции, но с небольшими изменениями, для каждого агента и каждого внешнего ресурса.
Именно это повторение является проблемой, которую рассматривается в этой статье, и оно объясняет, почему MCP стало такой часто обсуждаемой темой в течение прошлого года. Прежде чем решить, стоит ли его внедрять, полезно точно определить, какую проблему он решает, а также разобраться, что подразумевалось под подключением инструмента к агенту до появления MCP.
Проблема, которую решает MCP
До появления MCP для предоставления агенту доступа к внешним ресурсам требовалось вручную создавать индивидуальные интеграции, адаптированные под конкретный ресурс, с учетом того, что казалось наиболее подходящим в данный момент. К одному ресурсу можно было получить доступ через REST API, к другому — через клиент базы данных, а к третьему — через SDK с собственными правилами аутентификации и обработки ошибок. Все эти различия приходилось напрямую включать в код самого агента.
Это хороший компромисс, когда один агент взаимодействует с одним инструментом. Однако такой подход перестаёт быть эффективным, как только система растёт в любом из направлений. Если добавить второго агента, который нуждается в том же ресурсе, придётся либо скопировать интеграцию, либо кто-то в конечном итоге вынесёт её в общий модуль, обычно только после того, как дублирование уже возникло. Если же добавить второй инструмент, код агента будет вынужден одновременно учитывать два совершенно разных паттерна интеграции.
Эти интеграции редко похожи друг на друга, потому что ничто не обязывает их к этому. Одна оболочка может автоматически повторять неудачные запросы; другая — вообще не будет их повторять. Одна может отображать ошибки в виде выброшенных исключений; другая — скрывает их в поле статуса, которое вызывающий код должен помнить проверять. Не существует общего подхода к тому, что на самом деле означает «подключение агента к инструменту» — каждая интеграция отвечает на этот вопрос по-своему.
Именно этот пробел и предназначен к устранению MCP. Он не вводит новые возможности, отсутствовавшие у агентов ранее; он стандартизирует механизм доступа к уже существующим возможностям, так что подключение нового агента к существующему инструменту или нового инструмента к существующему агенту больше не означает необходимости создания с нуля ещё одной специально разработанной интеграции. Оценить, соответствует ли он этому обещанию на практике, гораздо проще, как только вы увидите, как на самом деле выглядит импровизированный подход в коде — именно к этому мы перейдём далее.
До MCP: импровизированный способ подключения инструмента
Рассмотрим довольно обычную интеграцию: агент, который должен запрашивать данные у внешней системы, объединённый кодом-связующим, обеспечивающим работу соединения.
import requests
class LookupToolClient:
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url
self.api_key = api_key
def lookup(self, query: str) -> dict:
response = requests.get(
f"{self.base_url}/search",
params={"q": query},
headers={"Authorization": f"Bearer {self.api_key}"},
)
if response.status_code != 200:
return {"error": f"lookup failed: {response.status_code}"}
return response.json()
def agent_node(state: GraphState) -> dict:
client = LookupToolClient(base_url="https://internal-tool.example.com", api_key="...")
result = client.lookup(state["extracted_fields"]["query"])
return {"tool_result": result}
Само по себе в этом нет ничего плохого. Это компактный HTTP-клиент, простая обработка ошибок и функция, которая вызывает его изнутри Node. Проблемы начинаются, когда появляется ещё один инструмент — на этот раз не ещё один REST API, а клиент базы данных с совершенно иной структурой:
import psycopg2
class RecordsClient:
def __init__(self, connection_string: str):
self.conn = psycopg2.connect(connection_string)
def fetch_record(self, record_id: str) -> dict:
with self.conn.cursor() as cur:
cur.execute("SELECT * FROM records WHERE id = %s", (record_id,))
row = cur.fetchone()
if row is None:
raise ValueError(f"no record found for {record_id}")
return dict(zip([desc[0] for desc in cur.description], row))
У этих двух клиентов нет общего интерфейса, единой конвенции нумерации, даже единого способа сигнализации об ошибках: один возвращает словарь ошибок, а другой просто бросает исключение. Любому агенту, который должен использовать оба клиента, приходится изучать их особенности по отдельности и учитывать каждую из них в своем режиме. Если к этому добавить все дополнительные инструменты, которые со временем требуется системе — собственные клиенты, собственные схемы аутентификации, собственные способы обработки ошибок — то из нескольких небольших интеграций получается настоящая нагрузка по техническому обслуживанию, при этом отсутствует какая-либо общая структура, объединяющая их.
Вот основной принцип, к которому стоит придерживаться: речь не идет о плохо написанной интеграции, а просто о типичной, такой, какая обычно получается при интеграции различных инструментов, когда нет ничего, что обеспечивало бы единый формат для них.
Что на самом деле стандартизирует MCP
Имея в виду этот специальный пример, становится гораздо проще точно описать, что делает MCP, без необходимости обращаться к более общим и расплывчатым утверждениям, часто делаемым на его счёт.
По сути, MCP определяет общий протокол для предоставления инструментов агенту, независимо от того, как внутри работает инструмент или на каком языке/фреймворке он был создан. Вместо того чтобы каждый инструмент сопровождался собственным клиентом с уникальными правилами, он предоставляется через сервер MCP, который объявляет его возможности в предсказуемом стандартном формате: имя, описание, схема входных данных и схема выходных данных. Любой агент, понимающий этот протокол, может обнаружить такой инструмент и вызвать его так же, как любой другой инструмент, независимо от того, является ли основой REST-конечная точка, база данных или что-то совершенно иное.
Эта стандартизация охватывает ровно три области, и важно четко указать, какие именно, поскольку возникает соблазн предположить, что объем применения MCP шире, чем на самом деле.
- Обнаружение: Агент может запрашивать у сервера MCP список инструментов, которые тот предоставляет, и получать структурированный ответ вместо того, чтобы полагаться на информацию, закодированную где-то в коде или задокументированную отдельно от самой реализации.
- Вызов: Каждый вызов инструмента следует одному и тому же шаблону, независимо от его типа — запрос с определенной структурой и ответ с определенной структурой, вместо того чтобы каждый клиент определял собственную сигнатуру метода и тип возвращаемого значения.
MCP не устраняет необходимости написания собственной логики инструмента, а также не гарантирует правильного его поведения только потому, что он интегрирован в протокол. Он стандартизирует контракт между агентом и инструментом, но не качество или надежность того, что находится за этим контрактом — на этот момент статья сразу же возвращается позже, после того как приведенные сравнения прояснили эту разницу.
Структура сервера MCP
Учитывая только что описанную стандартизацию, стоит рассмотреть, что на самом деле её реализует. Структурно сервер MCP представляет собой не что иное, как набор объявленных инструментов, каждый из которых имеет собственную схему, упакованных в слой протокола, позволяющий агенту находить и вызывать их единообразным способом.
Для определения инструмента на сервере MCP минимум необходимо указать три элемента: имя, которое использует агент для его обозначения, схему, описывающую ожидаемый ввод, и функцию, которая выполняется при фактическом вызове инструмента.
from mcp.server import Server
from mcp.types import Tool
server = Server("lookup-tools")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="lookup",
description="Search for a record by query string",
inputSchema={
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> dict:
if name == "lookup":
return perform_lookup(arguments["query"])
raise ValueError(f"unknown tool: {name}")
При сравнении с ранее обсуждаемыми специальными клиентами здесь выделяются два важных аспекта. Во-первых, схема входных данных объявляется явно с самого начала, а не определяется на основе параметров функции — это позволяет как агенту, так и человеку-рецензенту точно видеть, что требуется от инструмента, без необходимости изучения его реализации. Во-вторых, серверу достаточно предоставить всего два точки входа — list_tools и call_tool — независимо от того, сколько инструментов он содержит и насколько различно они работают внутри. То, общается ли инструмент lookup с REST API, запрашивает данные из базы данных или выполняет что-то совершенно другое, остается полностью скрытым за этими двумя функциями.
С точки зрения агента подключение к этому серверу выглядит одинаково, независимо от того, какие инструменты он предоставляет:
from mcp.client import ClientSession
async def call_lookup_tool(query: str) -> dict:
async with ClientSession(server_params) as session:
result = await session.call_tool("lookup", {"query": query})
return result
Сравните это с двумя временными клиентами ранее: один был построен на библиотеке requests, другой — на psycopg2; у каждого из них была собственная структура и правила использования. Здесь код агента остается неизменным независимо от того, как внутренне работает инструмент — он вызывает метод session.call_tool с именем и набором аргументов, и каждый раз получает результат в той же структуре. Именно такая единообразность является настоящей ценностью архитектуры сервера, а не сама логика инструмента, которую в любом случае кто-то должен написать.
Перепроектирование той же интеграции с помощью MCP
Лучший способ увидеть практическую разницу — это взять точно такую же интеграцию, созданную ранее, а именно инструмент поиска и клиент для работы с записями, и пересоздать её с использованием MCP. Функциональность остаётся прежней, основные системы тоже не меняются, меняется только интерфейс.
Инструмент поиска, изначально представлявший собой отдельный клиент на основе библиотеки requests, теперь превращается в объявление инструмента, зарегистрированное на сервере MCP:
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="lookup",
description="Search for a record by query string",
inputSchema={
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
),
Tool(
name="fetch_record",
description="Fetch a record by ID",
inputSchema={
"type": "object",
"properties": {"record_id": {"type": "string"}},
"required": ["record_id"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> dict:
if name == "lookup":
return perform_lookup(arguments["query"])
elif name == "fetch_record":
return fetch_record_from_db(arguments["record_id"])
raise ValueError(f"unknown tool: {name}")
Внутренняя логика не была изменена: функция perform_lookup по-прежнему обращается к тому же внешнему API, а функция fetch_record_from_db по-прежнему выполняет тот же запрос к базе данных. Разница заключается в том, что эти два инструмента, хотя и работают с совершенно разными системами, теперь объявляются рядом друг с другом, имеют идентичную структуру схемы и доступны через те же два точки входа.
Наиболее заметные изменения происходят в узле, отвечающем за их вызов, поскольку ему больше не требуется учитывать requests или psycopg2:
async def agent_node(state: GraphState) -> dict:
async with ClientSession(server_params) as session:
result = await session.call_tool(
"lookup", {"query": state["extracted_fields"]["query"]}
)
return {"tool_result": result}
Это контрастирует с более ранней версией, в которой импортировалась определённая библиотека HTTP, парсился конкретный формат ошибок, и требовалась совершенно отдельная логика для вызова fetch_record вместо lookup. В этой версии вызов второго инструмента сводится к замене строки и словаря аргументов, без необходимости создания отдельного клиента с собственными особенностями.
Ничего не изменилось в том, что на самом деле делают эти инструменты. Изменилось лишь то, что агент, использующий их, больше совсем не обязан понимать их реализацию — ему достаточно знать только их название и объявленную схему. Это та стандартизация, о которой шла речь ранее в серии статей; теперь её можно применять непосредственно в коде, а не полагаться на слова.
Сравнение: Ad Hoc против MCP
Когда оба подхода полностью реализованы, можно перестать размышлять о теоретических преимуществах и сразу посмотреть, в чём именно заключаются различия между версией Ad Hoc и версией MCP.
- Усилия, необходимые для каждого нового инструмента: При спорадическом подходе каждый дополнительный инструмент требовал наличия собственного клиента, собственной логики аутентификации, собственного формата ошибок и своего набора правил, которые необходимо было изучить перед тем, как можно было безопасно им пользоваться. В рамках MCP добавление инструмента означает внесение ещё одной записи в
list_toolsи ещё одного ветвления внутриcall_tool, причём каждое из них следует тому же шаблону, что и предыдущий инструмент. - Что должен понимать код, вызывающий инструмент: Узел агента в спорадическом подходе должен был импортировать определённую библиотеку клиента и обрабатывать специфический формат ошибок этой библиотеки. Узел агента MCP не импортирует ничего, что было бы специфично для конкретного инструмента; он просто вызывает
session.call_toolс именем и словарём аргументов, причём поведение этого вызова остаётся одинаковым независимо от того, является ли используемый инструмент REST-конечной точкой, базой данных или чем-либо ещё.
LookupToolClient, и каждый агент, использующий его, автоматически примет эти изменения. MCP не устраняет эту нагрузку по техническому обслуживанию, но объединяет её: теперь поведение каждого инструмента регулируется через две одинаковые функции — list_tools и call_tool, вместо того чтобы оно было распределено среди множества различных клиентских реализаций, соответствующих каждому инструменту.Ничто из этого не упрощает собственную логику инструмента. Функции perform_lookup и fetch_record_from_db всё равно необходимо написать и обеспечить их корректную работу. Изменяется лишь всё, что связано с этой логикой: способ её обнаружения, способ вызова, способ отображения ошибок, а также то, какую часть этой нагрузки должен нести собственный код агента по сравнению с автоматическим обработанием в рамках единого общего протокола.
Интеграция инструмента MCP в систему LangGraph (продолжение из части 2)
До сих пор каждый пример касался одного агента, вызывающего отдельный инструмент изолированно. Стоит закрыть этот пробел, вновь включив инструмент на основе MCP в многоагентную структуру, созданную в части 2, поскольку именно там сходятся основные идеи всех трёх статей этой серии.
Вспомним граф из Части 2: шаг ввода данных, интерпретация LLM, движок правил, представленный в Части 1, и условное направление потока либо к узлу уведомлений, либо на проверку человеком. Представьте, что теперь движок правил перед принятием решения должен проверить информацию в какой-то внешней системе — такая зависимость раньше требовала создания специального клиента, подобного тому, что был разработан ранее в этой статье и подключен непосредственно к данному узлу.
Когда сервер MCP предоставляет тот же механизм поиска в виде инструмента, обязанности узла практически не меняются. Он по-прежнему считывает данные из состояния графа и записывает свое решение обратно в это состояние, просто для доступа к внешней системе он использует метод session.call_tool, вместо того чтобы обращаться через специальный клиент:
async def rules_engine_node(state: GraphState) -> dict:
async with ClientSession(server_params) as session:
lookup_result = await session.call_tool(
"lookup", {"query": state["extracted_fields"]["category"]}
)
decision = evaluate_rules(state["extracted_fields"], lookup_result)
return {"decision": decision["decision"], "decision_reason": decision["reason"]}
Положение узла в общем потоке не меняется под влиянием всего этого. Он по-прежнему находится в том же месте — ниже llm_interpretation и выше условного ответвления, которое уже существовало в Части 2; он по-прежнему ограничен диапазоном разрешений, определённым в обсуждении правил той статьи, и по-прежнему регистрируется под тем же идентификатором трейса, описанным в разделе об отслеживаемости. Единственное реальное изменение касается способа взаимодействия узла с внешним миром: вместо использования клиента, созданного специально для этого узла, теперь он отправляет запросы в инструмент, который любой другой узел, будь то в текущей схеме или в будущей, может вызвать по тому же пути.
Именно здесь сходятся три основных элемента этой серии. Агент, который принимает решения только в тех случаях, когда это прямо ему разрешено, работает внутри графа, координирующего его действия с другими агентами, и общается с внешними системами через стандартизированный интерфейс, используемый всеми агентами. Ни двигатель правил, ни граф, ни MCP не нуждаются в детальном понимании функций остальных двух компонентов. Им достаточно соблюдать те же принципы, на которых основана вся эта серия: четко определенные обязанности, явно оговоренные контракты и отсутствие места для догадок.
Затраты и задержки: сколько на самом деле стоит оркестрация
Каждый слой, добавляемый в ходе этой серии, придает определённую структуру, и ни одна из этих структур не появляется бесплатно. Вместо того чтобы скрывать эти затраты, полезно проследить, что на самом деле происходит с временем отклика, когда запрос проходит через все ранее описанные компоненты.
Рассмотрим отдельный запрос, проходящий по графу, описанному в части 2, теперь дополненному механизмом поиска на основе MCP, рассмотренным выше. Путь обработки запроса можно разделить примерно так: модуль приема данных выполняет легкую форматировку без внешних вызовов, поэтому он занимает не более нескольких миллисекунд. Шаг интерпретации с помощью большой языковой модели включает вызов модели для извлечения структурированных полей из исходного запроса, и это обычно является самой затратной частью всего процесса, добавляя несколько сотен миллисекунд во времени обработки в зависимости от выбранной модели и длины запроса. Вызов инструмента поиска с помощью механизма MCP в движке правил добавляет к времени, необходимому самому инструменту для ответа, еще один сетевой обмен данными, что представляет собой значимую стоимость, но в целом меньшую, чем затраты на обработку с помощью большой языковой модели. Оценка самих правил, поскольку это просто детерминистическая логика, практически не требует ресурсов. Шаги маршрутизации и отправки заключительного уведомления добавляют к этому лишь незначительную дополнительную нагрузку.
Если сложить все эти показатели, становится ясно: запрос, требующий всего одной обработки в модели и одного вызова инструмента, имеет общее время выполнения, которое почти полностью определяется именно этими двумя операциями, причем собственная координация в рамках графа практически не оказывает влияния. Узлы, рёбра и общее состояние, описанные в части 2, обеспечивают структуру без создания значительной задержки: чтение и запись объекта общего состояния сопряжены с незначительными затратами времени. На самом деле время тратится на обращение к модели или внешней системе.
Это меняет подход к рассмотрению настройки производительности. Добавление большего количества узлов, дополнительных ветвей маршрутизации или проверок безопасности в граф едва влияет на скорость, поскольку это всего лишь вызовы функций и поиск в словаре. На скорость системы действительно влияют каждый вызов большой языковой модели и каждый вызов внешнего инструмента, находящиеся на критическом пути запроса. Система, построенная из трех агентов, которые по очереди вызывают модель, будет работать заметно медленнее, чем система, вызывающая модель всего один раз, независимо от того, насколько эффективной является оркестрация вокруг неё.
Практический вывод прост: когда задержка имеет значение для рабочего процесса, не стоит сразу анализировать форму графа. Сначала подсчитайте количество вызовов модели и запросов к внешним инструментам, через которые должен пройти обычный запрос до завершения, и определите, могут ли некоторые из них выполняться одновременно вместо последовательно или полностью исключаться для запросов, которым они не нужны.
Чего не решает MCP
Стоит быть таким же откровенным относительно ограничений MCP, как и относительно его преимуществ, рассмотренных ранее, поскольку большинство материалов на эту тему сосредотачиваются в основном на преимуществах и редко затрагивают ограничения. Те же три аспекта, обсуждавшиеся ранее — поиск, вызов и обработка ошибок, — заслуживают повторного рассмотрения с противоположной точки зрения.
- Стандартизация процессов поиска и вызова инструментов не решает проблемы плохо спроектированного инструмента: стандарт MCP стандартизирует способ нахождения и вызова инструмента, но не то, что происходит внутри него. Инструмент с медленной, нестабильной или плохо структурированной реализацией останется таким же медленным, нестабильным и плохо структурированным даже при использовании через сервер MCP. Протокол лишь перемещает проблему несоответствий из кода вызова в саму реализацию инструмента, но не делает эти несоответствия исчезающими.
Всё это не противоречит необходимости внедрения MCP. Это напоминание о том, что выбор протокола не заменяет инженерную работу, которая всё равно должна быть выполнена на его основе. Изменения, которые приносит MCP, реальны, как показано в предыдущих разделах, но они более узки по сравнению с концепцией «агенты, подключающиеся к инструментам» в целом; поэтому важно точно определить границы этой концепции перед принятием решения о целесообразности её внедрения, чему и посвящён следующий раздел.
Когда стоит внедрять MCP
Учитывая вышеизложенные компромиссы, здесь нет универсального ответа — ни однозначного «всегда внедряйте его», ни «никогда не беспокойтесь». Вместо этого важно проверить несколько конкретных условий перед тем, как применять его в любой системе.
- Более одного агента будет нуждаться в одних и тех же инструментах. Почти вся выгода, описанная ранее, обусловлена возможностью повторного использования: второй агент может подключиться к уже созданному серверу, вместо того чтобы дублировать клиент или создавать его заново. Когда существует только один агент и один инструмент, такой выигрыш ещё не существует — нет ничего, что можно было бы поделиться, — и ранее рассмотренный импровизированный подход остаётся более простым решением для этого конкретного случая.
- Ожидается увеличение количества инструментов. Стандартизированный интерфейс становится всё ценнее по мере увеличения количества инструментов, работающих через него. При наличии двух-трёх инструментов запуск отдельного сервера может оказаться нецелесообразным из-за дополнительных затрат. Когда же речь идёт о десяти или двадцати инструментах, каждый из которых в противном случае потребовал бы собственного специального клиента, нагрузка по техническому обслуживанию импровизированного подхода начинает представлять серьёзную проблему.
С другой стороны, это плохое решение для ситуации, когда один агент взаимодействует с одним стабильным, хорошо понятным инструментом, который не собирается меняться. В таком случае создание специального клиента происходит быстрее, остается на одну единицу меньше деталей, требующих обслуживания, а координация, которую предоставляет MCP, не находит второго пользователя, способного ею воспользоваться.
Практический тест, который здесь следует применить, похож на тот, что использовался ранее для самого движка правил: решает ли добавление такой сложности проблему, с которой действительно сталкивается данная система на текущем этапе, или же она принимается лишь потому, что это модный способ решения проблемы, которой у системы пока нет.
Заключение: Завершение серии
В течение трех статей постепенно формировалась одна система, при этом каждый слой строился на предыдущем. В первой части был создан единственный агент, достаточно надежный для принятия реальных решений, благодаря строгому разделению процессов интерпретации и принятия решений. Во второй части этому агенту была предоставлена поддержка в виде группы специализированных агентов, объединенных в граф, обменивающийся между собой состоянием; всё это контролировалось с помощью четких разрешений и обеспечивалась возможность отслеживания процесса от начала до конца, чтобы каждый запрос можно было проследить с момента его поступления до момента выполнения. В этой статье была устранена оставшаяся проблема — как эти агенты могут действовать за пределами графа — путем стандартизации доступа через MCP в тех ситуациях, где это действительно целесообразно, при этом четко указывалось, где это не имеет смысла.
None of these three pieces is especially complex on its own. What actually makes the resulting system dependable is one habit, repeated at every layer without exception: responsibilities stay narrow, contracts stay explicit, and nothing is left to guesswork or allowed to happen quietly in the background. That habit is the real subject of this series, more than any particular tool—LangGraph and MCP just happened to be the frameworks used to put it into practice, but the underlying principles hold regardless of which tools you reach for.