Привет, Хабр!
Последний год мы были участниками бума разработки AI-агентов, в процессе которого мы сталкивались с определёнными трудностями:
Проблемы с интеграциями
Галлюцинации
Переполнение контекста
Зацикливание
Но самая большая проблема, с которой я столкнулся, — архитектура и масштабирование.
Писать монолитных агентов удобно, но с ростом количества проектов и инструментов возникают определенные трудности:
Копирование функционала из одного агента в другого
Сложность тестирования
Необходимость менять большое количество кода для добавления нового инструмента
Поэтому нам жизненно необходимо создавать правильную архитектуру агентских систем, которую можно будет легко масштабировать и тестировать.
В этой статье я расскажу, как создать MCP-сервер с помощью библиотеки fastmcp и как подключиться к нему с использованием LangChain, а также рассмотрим примеры работы нового React-агента.
На помощь к нам приходит Anthropic со своим протоколом для взаимодействия агентов с инструментами.
Model Context Protocol (MCP) - открытый стандарт, представленный компанией Anthropic в ноябре 2024 года. До его появления индустрия находилась в состоянии фрагментации: каждый разработчик AI-агента или IDE писал свои собственные коннекторы к базам данных, GitHub, Slack или Google Drive. Это порождало проблему M×NM×N, где MM — количество AI-приложений (Claude Desktop, Cursor, LangChain-агенты), а NN — количество внешних сервисов.
Основная цель MCP — создать универсальный интерфейс для подключения AI-моделей к внешним данным и инструментам. Создатели сравнивают его с портом USB-C: вместо того чтобы искать уникальный кабель для каждого устройства, вы используете один стандартный разъем. Один раз написав MCP-сервер (например, для доступа к внутренней базе знаний компании), вы можете подключить его к любому MCP-клиенту — будь то локальное приложение Claude Desktop или агент на LangChain.
Архитектура взаимодействия:
Протокол работает по классической клиент-серверной схеме
MCP Host (Клиент): Приложение, в котором находится LLM (Наш агент или какая то другая программа). Оно инициирует соединение.
MCP Server: Легковесное приложение, которое предоставляет доступ к трем примитивам:
Resources (данные для чтения),
Tools (функции для выполнения),
Prompts (шаблоны запросов).
Транспорт: Общение происходит либо через локальные потоки ввода-вывода (stdio), либо через HTTP для удаленных подключений.
Cам протокол агностичен к языку программирования. Вы можете написать сервер на Python (с помощью fastmcp), Rust или Node.js, а клиент будет взаимодействовать с ним одинаково.
Про fastmcp:
FastMCP - высокоуровневая Python-библиотека для быстрой разработки MCP-серверов и клиентов. Она оборачивает спецификацию Model Context Protocol (MCP) и даёт удобный API для объявления инструментов, ресурсов, шаблонных промптов
uv pip install fastmcp
Основные компоненты
Библиотека разграничивает элементы для создания агентов на 3 группы (в дальнейшем я подробнее рассмотрю каждый из них):
Tools — инструменты, которые предоставляет сервер и которые может самостоятельно вызывать языковая модель.
Создать инструмент в fastmcp так же просто, как и в LangChain. Достаточно добавить декоратор:
@mcp.tool def add(a: int, b: int) -> int: """Adds two integer numbers together.""" return a + b
Resources & Templates — это механизм FastMCP для предоставления языковой модели или клиентскому приложению данных в режиме «только для чтения». Главное отличие от инструментов — они не выполняют никаких действий и не вызываются непосредственно моделью (не передаются в список инструментов агента).
Все элементы в fastmcp можно создать с помощью декоратора:
@mcp.resource("data://config") def get_config() -> dict: """Provides application configuration as JSON.""" return { "theme": "dark", "version": "1.2.0", "features": ["tools", "resources"], }
Prompts - создает параметризованные шаблоны сообщений, которые помогают LLM генерировать структурированные ответы.
Наиболее частый случай использования — загрузка готовых промптов для разных моделей в IDE и добавление специфичного контекста. Это освобождает вас от задачи самостоятельного написания запроса. Но помимо этого их можно использовать для быстрого и удалённого изменения поведения агента. Пример создания:
@mcp.prompt def ask_about_topic(topic: str) -> str: """Generates a user message asking for an explanation of a topic.""" return f"Can you please explain the concept of '{topic}'?"
Для того чтобы создать сервер достаточно написать:
from fastmcp import FastMCP mcp = FastMCP()
Теперь у нас есть экземпляр, который готов работать. Но при реальной разработке я советую использовать дополнительные параметры:
mcp = FastMCP( name="MathMCPServer", instructions=''' Сервер для математических операций ''', version='1.0', website_url="@ViacheslavVoo", on_duplicate_tools="error", ''' on_duplicate_tools - если вы каким то образом добавите несколько одинаковых интрументов, то при запуске сервера ваш код упадет с ошибкой ''' tools=[test_func, test_func], ''' tools = [func1, func2] - позволяет не навешивать декоратор @mcp.tool на каждую функцию. Особенно полезно когда функции находятся в других модулях. Важно чтобы функция отвечала требованиям инструмента ''' include_tags = ["public"], exclude_tags = ["deprecated"] ''' include_tags - показывает компоненты у которых есть хотя бы один совпадающий тег exclude_tags - скрывает компоненты с любым совпадающим тегом Приоритет: теги исключения всегда имеют приоритет над тегами включения ''' mask_error_details=True #скрывает ошибки от языковой модели # пример использования в блоке тестирования агента )
Транспортные протоколы
Вы можете поднять mcp с использованием нескольких протоколов (для локального и удаленного развертывания):
STDIO (стандартный ввод/вывод) — это транспортный протокол по умолчанию для серверов FastMCP. Если вы вызываете run() без аргументов, ваш сервер использует транспортный протокол STDIO. Этот протокол обеспечивает связь через стандартные потоки ввода и вывода, что делает его идеальным для инструментов командной строки и приложений, таких как Claude Desktop.
Сервер считывает сообщения MCP из стандартного потока ввода и записывает ответы в стандартный поток вывода, поэтому серверы STDIO не работают постоянно — они запускаются по требованию клиента.
STDIO подходит для:
Локальная разработка и тестирование
Интеграция с Desktop инструментами
Командной строки
Однопользовательские приложения
HTTP
HTTP-транспорт превращает ваш MCP-сервер в веб-сервис, доступный по URL-адресу. Этот транспорт использует потоковый HTTP-протокол, который позволяет клиентам подключаться по сети. В отличие от STDIO, где каждому клиенту выделяется отдельный процесс, HTTP-сервер может одновременно обслуживать несколько клиентов.
HTTP-протокол обеспечивает двустороннюю связь между клиентом и сервером и поддерживает все операции MCP, включая потоковую передачу ответов. Поэтому он рекомендуется для сетевых развертываний.
HTTP-транспорт обеспечивает:
Доступность сети
Несколько одновременных подключений
Интеграция с веб-инфраструктурой
Возможность удаленного развертывания
Примеры запуска:
mcp.run()
mcp.run(transport="http", host="0.0.0.0", port=8000)
Помимо этих способов fastmcp поддерживает SSE, но этот вариант считается устаревшим.
Как я уже говорил, инструмент в fastmcp — это такой же инструмент, как в LangChain. Основные требования к инструментам, которые улучшат работу агента:
Функция должна иметь doc string (обязательное требование)
Название функции и наименования аргументов должны соответствовать назначению
Аргументы должны иметь аннотации типов
Инструменты проще всего создать с использованием декоратора @tool
@mcp.tool def add(item) -> str: """Some desc""" return "entry has been added to the list"
В этом случае декоратор возьмёт всю информацию из описания функции. Если вы хотите использовать другое описание и/или добавить информацию, вы можете использовать дополнительные параметры:
name="add_in_list" - имя функции, которое будет использоваться вместо указанного
description="adds an entry to the list" - новое описание doc string
tags={"list", "add", "public"} - метки, с помощью которых можно фильтровать инструменты
meta={"version": "1.2", "author": "product-team"}
exclude_args - позволяет скрыть аргументы инструмента от языковой модели (это могут id, ключи и другая информация, которая не должна попадать в LLM)
Здесь я хочу подробнее остановиться на создании и изменение инструментов
Создание инструмента из функции, к которой у нас нет доступа напрямую. Например, если вы хотите использовать встроенную функцию или какой-то код из legacy
import secrets from fastmcp import Tool # Функция из стандартной библиотеки Python для генерации безопасных токенов token_tool = Tool.from_function( secrets.token_hex, name="generate_secure_token", description=""" Generates a cryptographically strong random hex string. Use this when the user needs a secure API key, password, or unique identifier. Default length is 32 bytes if not specified. """ )
Добавление описания к аргументам функции.
Модель будет лучше ориентироваться в инструментах, если будет знать не только описание функции, но и описание аргументов. Когда у нас есть возможность, то мы можем сделать это с помощью Field или Annotated:
@mcp.tool def find_user_field( user_id: Annotated[str, ''' "The unique identifier for the user, " "usually in the format 'usr-xxxxxxxx'."'''] ): """Finds a user by their ID.""" ...
Но такая возможность есть не всегда, поэтому fastmpc позволяет оборачивать функции в инструменты и добавлять к ним описание:
@mcp.tool def find_user(user_id: str): """Finds a user by their ID.""" ... new_tool = Tool.from_tool( find_user, transform_args={ "user_id": ArgTransform( description=( "The unique identifier for the user, " "usually in the format 'usr-xxxxxxxx'." ) ) } )
Если посмотреть схему, то мы увидим практически одинаковое описание:
name='find_user' title=None description='Finds a user by their ID.' icons=None tags=set() meta=None enabled=True parameters={'type': 'object', 'properties': {'user_id': {'type': 'string', 'description': "The unique identifier for the user, usually in the format 'usr-xxxxxxxx'."}}, 'required': ['user_id']} name='find_user' title=None description='Finds a user by their ID.' icons=None tags=set() meta=None enabled=True parameters={'properties': {'user_id': {'description': ' "The unique identifier for the user, "\n "usually in the format \'usr-xxxxxxxx\'."', 'type': 'string'}}, 'required': ['user_id'], 'type': 'object'}
Помимо добавления описания можно изменять названия аргументов, устанавливать default value и скрывать часть аргументов:
@mcp.tool def search(q: str): """Searches for items in the database.""" return "database.search(q)" new_tool = Tool.from_tool( search, transform_args={ "q": ArgTransform(name="search_query", default=10) } )
Скрытие аргументов:
def send_email(to: str, subject: str, body: str, api_key: str, timestamp): """Sends an email.""" ... new_tool = Tool.from_tool( send_email, name="send_notification", transform_args={ "api_key": ArgTransform( hide=True, default=os.environ.get("EMAIL_API_KEY"), ), 'timestamp': ArgTransform( hide=True, default_factory=lambda: datetime.now(), ) } )
Когда мы разобрались с созданием сервера и инструментов, пора подключить к ним клиента. Для разработки я использую LangChain, поэтому примеры будут с использованием этой библиотеки.
Экземпляр класса MultiServerMCPClient
#make_mcp_client from langchain_mcp_adapters.client import MultiServerMCPClient #Используем возможности langchain async def make_client(server_config: dict) -> MultiServerMCPClient: multi_client = MultiServerMCPClient(server_config) return multi_client ''' Примечание: у fastmcp также есть класс Client, который принимает url сервера, но я учитываю, что сервер и клиент находятся в разном окружении, а установка fastmcp в окружение клиента ради одного класса излишне '''
В качестве необходимо передать config, который представляет собой словарь вида:
MCP_URL = "http://127.0.0.1:8081/mcp" MCP_CONFIG = { "my_tools": { "transport": "streamable_http", "url": MCP_URL, } }
Получение инструментов от сервера
#load_tools from agent_mcp.mcp_connection.make_mcp_client import make_client async def load_tools(server_config: dict) -> list: """Получает инструменты от MCP-сервиса. Если сервис недоступен — бросает ConnectionError.""" client = await make_client(server_config) try: tools = await client.get_tools() except Exception as e: raise ConnectionError(f"Не удалось получить инструменты от сервиса: {e}") for tool in tools: tool.handle_tool_error = True #позже покажу зачем нам эта строка _langchain_tools = tools return _langchain_tools
Таким образом в main файле агента достаточно написать:
#main MCP_TOOLS_URL = "http://127.0.0.1:8080/mcp" MCP_TOOL_CONFIG = { "graphics-tools": { "transport": "streamable_http", "url": MCP_TOOLS_URL, } } _langchain_tools: Optional[list] = None async def load_agent_tools() -> list: """Получает инструменты от MCP-сервиса. Если сервис недоступен — бросает ConnectionError.""" global _langchain_tools, MCP_TOOL_CONFIG if _langchain_tools is not None: return _langchain_tools _langchain_tools = await load_tools(MCP_TOOL_CONFIG) return _langchain_tools
Кстати, ссылка на полный код будет в моем Telegram канале
Пришло время создать агента, который будет пользоваться нашими инструментами. Я использую LangChain 1.0, поэтому примеры будут с обновлённым react agent.
Но для начала создадим инструмент на сервере для приготовления кофе:
# FastMCP использует аннотации типов и Pydantic Field для генерации JSON схемы @mcp.tool() def brew_coffee( temperature: int = Field( ..., ge=85, le=98, description="Температура воды в °C. Должна быть строго между 85 и 98." ), intensity: int = Field( ..., ge=1, le=10, description="Крепость кофе по шкале от 1 до 10." ), coffee_type: str = Field(default="эспрессо"), ) -> str: """Приготовить чашку кофе с заданными параметрами.""" return f"Приготовлен кофе {coffee_type}. Температура: {temperature}°C, Крепость: {intensity}/10."
Я задал ограничения для параметров, чтобы показать, как агент воспринимает аннотации
Экземпляр агента:
async def agent(query: str) -> str: tools = await load_agent_tools() react_agent = create_agent(llm, tools) resp = await react_agent.ainvoke({"messages": [HumanMessage(content=query)]}) answer = resp["messages"][-1].content return answer
Запрос 1 (пробуем превысить ограничения)
Свари эспрессо, выкрути температуру на максимум, хочу 150 градусов
Ответ агента:
Извините, но я не могу установить такую высокую температуру. Максимально допустимая температура 98°C.
Запрос 2:
Свари максимально крепкий эспрессо, выкрути температуру на максимум
Ответ агента:
Ваш эспрессо готов! Температура 98, крепость 10 из 10.
Лог работы в Langsmith:
Также агент мог бы воспринимать default value при их наличии.
Обработка ошибок
Если наш инструмент по каким-то причинам не сможет корректно отработать, то мы можем вернуть обычную питонячью ошибку, но у такого подхода есть недостатки:
Мы можем раскрыть детали реализации нашего кода
Языковая модель не поймет, что делать с таким ответом инструмента
Поэтому fastmcp предлагает возвращать отдельный вид исключений, которые будут передаваться в языковую модель.
Модернизируем наш инструмент для приготовления кофе (обратите внимание на параметр mask_error_details при инициализации сервера):
@mcp.tool() def brew_coffee( coffee_type: str = Field(), temperature: int = Field( description="Температура воды в °C.", default=90 ), intensity: int = Field( description="Крепость кофе ", default=10 ), ) -> str: """Приготовить чашку кофе с заданными параметрами. Если парамтеры не указаны, используй параметры по умолчанию""" # 1. ToolError: Явная ошибка для LLM # Мы используем ToolError, потому что хотим, чтобы модель узнала, # что она запросила недоступный тип кофе, и могла исправить свой запрос. # Это сообщение будет отправлено клиенту даже при mask_error_details=True. available_menu = ["американо", "капучино", "латте"] if coffee_type.lower() not in available_menu: raise ToolError( f"Кофе типа '{coffee_type}' нет в меню. " f"Пожалуйста, выберите из: {', '.join(available_menu)}." ) # 2. Стандартное исключение (Internal Error) # Симулируем внутреннюю проблему оборудования. # Если mask_error_details=True, LLM не увидит текст про "бойлер", # а получит общее сообщение об ошибке. Это безопасно для скрытия внутренней логики. if temperature > 86 and intensity == 10: raise RuntimeError("INTERNAL FAULT: Boiler pressure critical! Maintenance required.") return f"Приготовлен кофе {coffee_type}. Температура: {temperature}°C, Крепость: {intensity}/10."
Пробуем сломать наш инструмент:
Запрос:
Свари эспрессо крепости 10
Ответ агента:
К сожалению, я не могу приготовить эспрессо. В нашем меню есть американо, капучино и латте
Лог в langsmith:
Запрос 2:
Свари американо температуры 90 крепости 10
Ответ агента:
Извините, у меня не получилось приготовить американо. Пожалуйста, попробуйте еще раз.
Лог в langsmith:
Передача скрытых аргументов
Бывают ситуации, когда инструменту необходимо знать какой-нибудь id или ключ, который не должна видеть языковая модель. Создадим такой простой инструмент на сервере:
@mcp.tool( exclude_args=["user_id"] ) def add_item_to_card(item_name: str, quantity: int, user_id: str = None) -> str: '''Добавить товар в корзину пользователя''' return f"Товар {item_name} добавлен в корзину. user_id: {user_id}"
и добавим к агенту функцию для перехвата вызова инструментов:
@wrap_tool_call async def safe_inject_params(request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: tool_name = request.tool_call['name'] original_args = request.tool_call['args'] new_args = original_args.copy() if tool_name == "add_item_to_card": # Добавляем скрытый параметр new_args['user_id'] = "123" # Подменяем аргументы в запросе request.tool_call['args'] = new_args try: result = await handler(request) print(f"Tool completed successfully") return result except Exception as e: print(f"Tool failed: {e}") raise
react_agent = create_agent(llm, tools, middleware=[safe_inject_params])
Такую возможность нам предоставляет новая версия LangChain. Возможно, я как-нибудь напишу статью с разбором последнего обновления).
Запрос 1:
Добавь в корзину 2 упаковки зернового кофе
Ответ агента:
Добавлено 2 упаковки зернового кофе в корзину.
Лог в langsmith:
Как я уже говорил, ресурсы не передаются в список инструментов агента. Мы запрашиваем их перед запуском агента. Также можно использовать их в качестве динамического контекста — в этом случае необходимо создать свой инструмент, например, get_resource, который будет запрашивать информацию.
Создание ресурса:
@mcp.resource("config://vibe") def get_vibe(): return "Сегодня ты должен отвечать как суровый системный администратор из 90-х." @mcp.resource("functions://with-hidden-id") def get_func_names_with_id() -> list: """ Возвращает список функций, которые содержат скрытый параметр 'id'.""" return [...]
При их создании также можно указать дополнительные параметры:
@mcp.resource( uri="data://app-status", name="ApplicationStatus", description="Provides the current status of the application.", mime_type="application/json", tags={"status"}, meta={"version": "2.1", "owner": "Viacheslav"} )
В langchain ресурсы можно загрузить с помощью get_resources:
client = MultiServerMCPClient({...}) # Load all resources from a server res = await client.get_resources("server_name") res = await client.get_resources("server_name", uris=["file:///path/to/file.txt"])
Пример использования:
async def complex_analysis_pipeline(query: str): """Анализа с предзагрузкой данных""" # 1. Параллельная загрузка нескольких ресурсов resource_uris = [ "metrics://system/health", "logs://errors/recent", "config://current-settings" ] # Асинхронная загрузка всех ресурсов resources = await asyncio.gather(*[ client.get_resources(uri) for uri in resource_uris ]) # 2. Объединение контекста combined_context = "\n\n".join([ f"## {uri}\n{data}" for uri, data in zip(resource_uris, resources) ]) # 3. Создание промпта с богатым контекстом prompt = ChatPromptTemplate.from_messages([ ("system", """Ты эксперт по анализу систем. У тебя есть следующие данные: {context} Анализируй проблему шаг за шагом."""), ("human", "{question}") ]) chain = prompt | ChatOpenAI(temperature=0) return await chain.ainvoke({ "context": combined_context, "question": query })
Prompts
Я нашёл для себя вариант использования prompt в виде быстрой замены системных промптов у агента. Это можно было бы делать с помощью обычного обращения к базе, но я предпочитаю использовать минимально возможный стек технологий. Здесь я приведу примеры максимально упрощённого использования, чтобы вы сами нашли для себя способы применения.
@mcp.prompt def generate_code_request(language: str, task_description: str) -> dict: """Запрос на генерацию кода""" content = f"Write a {language} function that performs: {task_description}" return { "role": "user", "content": content }
#Сервер @mcp.prompt def analyze_data( numbers: list[int], metadata: dict[str, str], threshold: float ) -> str: """Анализ числовых данных""" return f"Analyze numbers: {numbers} with metadata: {metadata}. Threshold: {threshold}" #Клиент # Создаем инструмент из FastMCP промпта def data_analysis_tool(inputs: dict) -> str: prompt_text = analyze_data( inputs["numbers"], inputs["metadata"], inputs["threshold"] ) return llm([HumanMessage(content=prompt_text)]).content
Относительно недавно мы столкнулись с ИИ. В прошедшем году мы пробовали, экспериментировали, сталкивались с определёнными трудностями, но главное — приобретённый опыт. Этот опыт показывает, что хорошо продуманная и легко масштабируемая архитектура — огромный вклад в создание действительно полезного агента, которым можно будет пользоваться.
Связка fastmcp и LangChain — отличное сочетание, которая решает проблемы:
Масштабирование: Разделение сервера (MCP) и клиента (агент) позволяет обновлять инструменты независимо, запускать сервера на разных нодах.
Тестирование: MCP-сервер можно тестировать изолированно, мокать, что упрощает CI/CD.
Если хотите обсудить архитектуру AI-агентов или поделиться своим опытом — добро пожаловать в мой Telegram
Источник


Финансы
Поделиться
Поделиться этой статьей
Скопировать ссылкуX (Twitter)LinkedInFacebookEmail
Торговля ИИ не умерла: Взгляд изнутри на Wal
