Всё стремительнее на глазах формируется новый виток в развитии инструментов для работы с искусственным интеллектом: если ещё недавно внимание разработчиков было приковано к no-code/low-code платформам вроде n8n и Make, то сегодня в центр внимания выходят ИИ-агенты, MCP-серверы и собственные тулзы, с помощью которых нейросети не просто генерируют текст, но и учатся действовать. Это не просто тренд — это новая парадигма от «что мне сделать?» к «вот как я это сделаю сам».

Вместе с этим появляется множество вопросов:

Что такое MCP? Зачем вообще нужны тулзы? Как ИИ может использовать код, написанный мной? И почему всё больше разработчиков создают собственные MCP-серверы, вместо того чтобы довольствоваться готовыми решениями?

Эта статья — путеводитель по новой реальности. Без лишней теории, с большим количеством практики:

  • Мы поговорим о том, что из себя представляют MCP-серверы и как они взаимодействуют с нейросетями

  • Разберёмся, как создавать собственные инструменты (тулзы) и подключать их к ИИ

  • И, главное, на простых примерах покажу, как научить нейросеть работать с вашим кодом: будь то калькулятор, AI-интерфейс к API, или даже полноценный агент для автоматизации действий

К концу статьи вы сможете не просто понимать, что такое MCP, а писать собственные серверы и подключать их к ИИ, готовые к использованию в реальных проектах.

Рекомендую также заглянуть в мою предыдущую статью «Как научить нейросеть работать руками: создание полноценного ИИ-агента с MCP и LangGraph за час», — она отлично дополнит сегодняшний материал.

Поехали.

Отличие MCP и инструментов (тулзы, tools)

Начнём с самого частого вопроса у тех, кто только начинает разбираться в теме: что такое MCP, что такое инструменты (тулзы), и в чём между ними разница. Давайте разберёмся.

MCP vs Tools: метафора для понимания

Путаница возникает не случайно — эти понятия действительно близки. Чтобы проще понять, представьте, что:

  • MCP-сервер — это как библиотека или фреймворк на любом языке программирования.

  • Инструмент (tool) — это отдельная функция, выполняющая конкретную задачу.

Таким образом, инструмент — это кирпич, а MCP — это здание, собранное из этих кирпичей и обёрнутое в удобный интерфейс, с которым может взаимодействовать ИИ-агент.

Зачем всё это вообще нужно

Всё внимание к теме MCP объясняется очень просто:

теперь вы можете написать абсолютно любой код, будь то:

  • простой скрипт на Python

  • REST API-эндпоинт

  • локальная функция

…и дать нейросети возможность самостоятельно вызывать его — как будто она понимает, что делает.

Простой пример — как это работает

Допустим, у вас есть обычная функция, которая принимает два аргумента: city и days. Вы вручную вызываете её как:

get_weather(city="Москва", days=4)

Она возвращает погоду на 4 дня — всё просто.

Теперь представьте: Вы задаёте нейросети вопрос:

«Дружище, подскажи, какая там погода будет в Краснодаре в ближайшие четыре дня?»

ИИ-агент сам:

  1. Извлекает из запроса нужные переменные (city = "Краснодар", days = 4)

  2. Вызывает вашу функцию

  3. Получает результат

  4. И сам же формирует осмысленный ответ для пользователя — будто всё это сделал человек вручную.

Причём, даже если нейросеть работает локально, без доступа к интернету, она всё равно справляется — потому что задача не в поиске данных, а в использовании доступных инструментов.

Когда инструментов становится много

А теперь представьте, что у вас не одна такая функция, а целый набор:

  • create_file(), delete_file(), read_file(), list_files() и десятки других

Все они работают вокруг общей логики — например, с файлами.

В какой-то момент вы или другой разработчик можете объединить эти функции в единый набор с общей структурой, описанием и интерфейсом. Вот это уже и будет MCP-сервер — полноценная коллекция инструментов, с которой может работать нейроагент.

Так и родилось понятие MCP:

это не просто набор случайных тулзов, а логически объединённая система инструментов, с которой ИИ может взаимодействовать как с фреймворком.

Как нейросеть понимает, как работать с инструментами?

Это, пожалуй, один из самых важных вопросов во всей теме: как ИИ вообще осознаёт, что и когда нужно вызывать? Как он «узнаёт», что у нас есть функция, которая может выполнить нужное действие?

Старый формат общения: чат и текст

До недавнего времени взаимодействие с ИИ выглядело просто:
— Вы писали в чат нейросети свой запрос
— Она генерировала текст в ответ

Может быть, вы даже прикладывали файлы и просили что-то сделать с ними, но на этом всё. ИИ был «в голове», но без рук.

Новый подход: инструменты и действия

Теперь всё меняется. У нас появилась возможность давать нейросети инструменты — буквально, расширять её возможности через функции. Мы можем:

  • написать свои собственные функции

  • объединить их в MCP-сервер

  • взять чужой код или готовый набор инструментов

  • и… подключить всё это к ИИ

Сегодня я покажу, как это делается. А стало возможным всё это благодаря MCP-протоколу (Model Context Protocol) — разработке компании Anthropic, которая задала единый стандарт описания инструментов для использования нейросетями.

То есть компания Anthropic придумала некое общепринятое описание правил создания кода для нейросетей. К нему можно отнести, например, специальный формат аннотаций и документации в каждой функции-инструменте.

Магия в описании: как ИИ «видит» ваши функции

Представьте, что вы написали функцию для работы с погодой:

def get_weather(city: str) -> dict:
    """
    Получает текущую погоду для указанного города.
    
    Args:
        city (str): Название города на русском или английском языке
        
    Returns:
        dict: Словарь с данными о погоде (температура, влажность, описание)
    """
    # ваш код здесь

Когда вы подключаете эту функцию к ИИ, например, через LangGraph, нейросеть получает не только сам код, но и полное описание: что делает функция, какие параметры принимает, что возвращает.

Как работает «мозг» ИИ-агента

Процесс принятия решений выглядит примерно так:

  1. Пользователь пишет: «Какая сейчас погода в Москве?»

  2. ИИ анализирует: «Нужна информация о погоде в конкретном городе»

  3. ИИ сканирует доступные инструменты: «У меня есть функция get_weather, которая принимает название города»

  4. ИИ принимает решение: «Это именно то, что нужно!»

  5. ИИ вызывает функцию: get_weather("Москва")

  6. ИИ получает результат и формулирует ответ пользователю

LangGraph как умный координатор

LangGraph делает этот процесс ещё более элегантным. Он работает как граф состояний, где каждый узел может:

  • Анализировать текущую ситуацию

  • Выбирать нужный инструмент

  • Передавать управление следующему узлу

Благодаря этому ИИ может выполнять сложные многошаговые задачи: сначала получить погоду, потом на основе неё предложить одежду, а затем найти ближайший магазин.

В рамках сегодняшней статьи мы не будем глубоко погружаться в тему графов, так как это заслуживает, серии публикаций и, если я увижу ваш отклик на статью, которую вы сейчас читаете — с меня серия публикаций по LangGraph в рамках которой я разложу тему цепочек (графов), от А до Я, а сегодня ограничимся только инструментами и MCP.

Главный секрет успеха

80% успеха любого MCP-сервера — это качественные описания инструментов. Чем подробнее и точнее вы опишете, что делает ваша функция, тем лучше ИИ поймёт, когда её использовать.

Плохое описание: «Делает расчёты»

Хорошее описание: «Вычисляет сложные проценты по вкладу с учётом капитализации за указанный период»

Именно поэтому далее в статье мы уделим особое внимание правильному оформлению функций и их документации.

Подготовка к практике

Уверен, вы уже хотите поскорее приступить к коду — и правильно! Но прежде чем мы начнём, есть пара важных моментов.

Рекомендуется к прочтению

Для более глубокого понимания очень желательно ознакомиться с моей предыдущей статьёй:

«Как научить нейросеть работать руками: создание полноценного ИИ-агента с MCP и LangGraph за час»

Также рекомендую заглянуть в мой Telegram-канал «Лёгкий путь в Python». Именно там я уже опубликовал:

  • Исходный код из этой и прошлой статьи

  • Эксклюзивные материалы, которых нет на Хабре

  • Полные практические примеры MCP-серверов, скриптов и тулзов

Что потребуется

Для полноценной работы нам понадобится API-токен одного из LLM-провайдеров. Подойдут:

  • DeepSeek (я буду использовать его в примерах)

  • Claude (Anthropic)

  • OpenAI (ChatGPT)

  • или локальные решения вроде Ollama

Если вы читали прошлую статью — вы уже знаете, как подключать любой из этих вариантов к LangGraph.

Подготовка среды

Сегодня всё будем писать на Python, так что первым делом — создаём виртуальное окружение и устанавливаем зависимости.

python -m venv venv
source venv/bin/activate  # или venv\Scripts\activate на Windows

Создаём .env файл и помещаем туда ваши токены. Пример:

OPENAI_API_KEY=sk-proj-123
DEEPSEEK_API_KEY=sk-12345
ANTROPIC_API_KEY=sk-12345
OPENROUTER_API_KEY=sk-or-v1-2123123

Выберите подходящего вам провайдера — LangGraph поддерживает их все.

Устанавливаем зависимости

Создайте файл requirements.txt и добавьте в него зависимости. Полный список (актуальный на момент написания):

fastmcp==2.10.6
langchain==0.3.26
langchain-deepseek==0.1.3
langchain-mcp-adapters==0.1.9
langchain-ollama==0.3.5
langchain-openai==0.3.28
langgraph==0.5.3
mcp==1.12.0
ollama==0.5.1
openai==1.97.0
pydantic-settings==2.10.1
python-dotenv==1.1.1
uvicorn==0.35.0
faker==37.4.2

Запускаем установку:

pip install -r requirements.txt

Новое

Из нового здесь:

  • fastmcp — мощная библиотека для быстрой сборки и публикации MCP-серверов.

  • faker — удобная библиотека для генерации тестовых (фейковых) данных. Сегодня она нам пригодится при создании демонстрационных инструментов.

План действий

Вот что мы сегодня сделаем шаг за шагом:

  1. Научимся писать свои инструменты (тулзы) и подключать их напрямую к ИИ-агенту

  2. Разберёмся, как подключать готовые MCP-серверы и использовать их инструменты в своём проекте

  3. Создадим свой собственный MCP-сервер

  4. Задеплоим его в облако с помощью Amvera Cloud — Это быстро, удобно, бюджетно, и вы получите HTTPS-домен, готовый для интеграции с LangGraph и любыми LLM-агентами. К тому же, Amvera предоставляет не только хостинг приложений, но и облачную инфраструктуру с собственным инференсом LLM без иностранной карты и встроенное проксирование до Claude, Gemini, Grok, GPT — всё в одном месте для ваших ИИ-проектов.

Готовы? Тогда переходим к практике сразу после небольшого, но очень важного, теоритического отступления.

Два подхода к работе с инструментами в LangGraph / LangChain

Когда вы начинаете подключать свои инструменты (tools) к нейросети через LangGraph или LangChain, у вас есть два основных пути: биндить инструменты вручную или использовать готовый ReAct‑агент. Оба имеют свои плюсы и минусы — разберём их.

1. bind_tools — биндинг инструментов напрямую к модели

  • Вы определяете функции с декоратором @tool, снабжаете их описанием (doc‑string), затем передаёте список инструментов модели через .bind_tools().

  • Модель знает о каждом инструменте и может сгенерировать запрос — вызов той или иной функции — если это необходимо.

  • Пример сценария: чат-бот, где нужен единичный вызов инструмента (например, калькулятор или API запрос). После этого модель возвращает обычный ответ.

  • Ограничение: модель может вызвать только один инструмент за сессию или игнорировать биндинг, если недостаточно уверен обязан ли вызывать. Подходит, если вы хотите тонко контролировать, когда и какой инструмент используется.

Преимущества:

  • Низкая задержка, менее затратный способ.

  • Гибкость: вы самостоятельно решаете, когда и как обрабатывать tool_call.

Что важно:

  • Обязательно качественные описания инструментов — иначе модель может их не заметить.

2. create_react_agent — готовый ReAct‑агент из LangGraph / LangChain

  • LangGraph предоставляет create_react_agent, который сам управляет циклом ReAct (Reasoning‑Acting‑Loop): модель может вызвать инструмент, получить результат, проанализировать его и продолжить до финального ответа.

  • Этот подход называют «реактивным агентом», он автоматически вызывает нужные инструменты до тех пор, пока не сформируется окончательный ответ.

  • В коде вы просто передаёте провайдера модели и список инструментов, например:

agent = create_react_agent("model_name", tools)
response = await agent.ainvoke({...})
  • Подходит для сложных задач, где агенту нужно взаимодействовать с несколькими инструментами, несколько шагов подряд.

Преимущества:

  • Удобство и автоматизация tool‑calling: вам не нужно контролировать вложенность вызовов.

  • Подходит для сценариев с несколькими инструментами за запрос.

Что важно:

  • Меньшая гибкость: агент сам решает, какие инструменты и когда вызывать.

  • Иногда может не распознать нужный tool, если описание не точное, или модель не поддерживает tool-calling нативно.

Пример создания простых инструментов с биндом

Для разогрева начнем с простого практического примера — напишем несколько функций, инициируем LLM и научим нашего нейро-товарища использовать эти инструменты.

Подготовка: импорты и настройка

Начнем с импортов:

from typing import Annotated, Sequence, TypedDict
from dotenv import load_dotenv
from langchain_core.messages import (
    BaseMessage,
    SystemMessage,
    HumanMessage,
    AIMessage,
)
from langchain_deepseek import ChatDeepSeek
from langchain_core.tools import tool
from langgraph.graph.message import add_messages
from langgraph.graph import StateGraph, END, START
from langgraph.prebuilt import ToolNode
import os
import asyncio

Основную «магию» нам позволит оформить импорт tool из langchain_core и использование специального сервисного узла ToolNode. Просто чтобы вы оставались в контексте — узел это логическое звено или точка, через которую проходит логика графа. Сам граф — это как дорожная карта. Обязательно подробнее это обсудим.

Сразу вызываем:

load_dotenv()

Это нужно, чтобы использовать переменные из файла .env.

Описание состояния агента

Сразу опишем состояние, в котором будем хранить наши сообщения:

class AgentState(TypedDict):
    """Состояние агента, содержащее последовательность сообщений."""
    messages: Annotated[Sequence[BaseMessage], add_messages]

Берите на вооружение. Несмотря на то что подход простой — он позволяет удобно сохранять контекст общения с нейросетью, ну а состояния — это основная движущая сила графов.

Создание функций-инструментов

Теперь опишем 2 простые асинхронные функции-инструмента:

async def add(a: int, b: int) -> int:
    """Складывает два целых числа и возвращает результат."""
    await asyncio.sleep(0.1)
    return a + b

  
async def list_files() -> list:
    """Возвращает список файлов в текущей папке."""
    await asyncio.sleep(0.1)
    return os.listdir(".")

Мягко говоря, зачем тут асинхронность, спросите вы, и я вам отвечу. Сейчас идет большая мода на асинхронность в Python и, несмотря на то что тут у нас нет в ней необходимости — этим простым примером я решил показать вам, что LangGraph прекрасно справляется с асинхронной логикой.

Вы видите 2 простейшие функции. Одна принимает на вход 2 числа и складывает, вторая выводит список файлов.

Для того чтобы эти функции могли использовать нейросети, мы уже проделали часть работы, а именно внутри функции дали описание того, что они делают. Это описание мы даем именно для нейросетей, поэтому, во-первых, не забудьте его добавить, а во-вторых, сделайте чтобы это описание было понятным!

Превращение функций в инструменты

Теперь нам нужно на каждую функцию повесить специальный декоратор:

@tool
async def add(a: int, b: int) -> int:
    # ...

    
@tool
async def list_files() -> list:
    # ...

Этим простым действием мы подготовили наши функции к интеграции.

Теперь создадим простую переменную (список), в который поместим наши инструменты:

tools = [add, list_files]

Аргументы передавать не нужно — нейросеть сама разберется!

Инициализация модели и привязка инструментов

Теперь выполним инициализацию модели (подробно говорили об этом в прошлой статье) и забиндим к ней наши инструменты:

llm = ChatDeepSeek(model="deepseek-chat").bind_tools(tools)

Создание узла агента

Теперь напишем функцию, которая будет вызывать нашу модель с заготовленным промптом:

async def model_call(state: AgentState) -> AgentState:
    system_prompt = SystemMessage(
        content="Ты моя система. Ответь на мой вопрос исходя из доступных для тебя инструментов"
    )
    messages = [system_prompt] + list(state["messages"])
    response = await llm.ainvoke(messages)
    return {"messages": [response]}

Тут уже начинается работа с состоянием. Если вы имеете опыт в создании телеграм-ботов на Aiogram 3 (кстати, у меня на Хабре штук 10 статей, в которых я рассказал о процессе создания ботов), то вы могли сталкиваться с таким понятием как FSM (машина состояний). Тут все работает похожим образом. У нас есть некое состояние, в котором мы храним все сообщения (сообщения от ИИ, системные сообщения, сообщения от человека и сообщения от инструментов), и при каждом вызове нейронки мы обновляем это состояние, пробрасывая все сообщения в контекст.

Условная логика: продолжать или завершать

Теперь опишем функцию с простым условием:

async def should_continue(state: AgentState) -> str:
    """Проверяет, нужно ли продолжить выполнение или закончить."""
    messages = state["messages"]
    last_message = messages[-1]

    # Если последнее сообщение от AI и содержит вызовы инструментов - продолжаем
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        return "continue"

    # Иначе заканчиваем
    return "end"

Тут вот какая логика. Мы хотим сделать, чтобы нейронка не просто вызвала наши функции (скажем по правде, мы это и сами прекрасно сделаем), нет, мы хотим, чтобы она после вызова сделала что-то с этой информацией.

Например, у нас есть функция, которая на вход принимает PDF-документ и извлекает из него текст. Мы хотим не просто получить извлеченный текст, а чтобы нейросеть эту информацию использовала далее или, как минимум, дала по ней summary. В реализации подобной логики поможет эта функция.

Сборка и запуск графа

Теперь остается это дело запустить. Сейчас мы опишем главную функцию. Я дам ее полный код, а после прокомментирую:

async def main():
    # Создание графа
    graph = StateGraph(AgentState)
    graph.add_node("our_agent", model_call)
    tool_node = ToolNode(tools=tools)
    graph.add_node("tools", tool_node)

    # Настройка потока
    graph.add_edge(START, "our_agent")
    graph.add_conditional_edges(
        "our_agent", should_continue, {"continue": "tools", "end": END}
    )
    graph.add_edge("tools", "our_agent")

    # Компиляция и запуск
    app = graph.compile()
    result = await app.ainvoke(
        {
            "messages": [
                HumanMessage(
                    content="Посчитай общее количество файлов в этой директории и прибавь к этому значению 10"
                )
            ]
        }
    )

    # Показываем результат
    print("=== Полная история сообщений ===")
    for i, msg in enumerate(result["messages"]):
        print(f"{i+1}. {type(msg).__name__}: {getattr(msg, 'content', None)}")
        if hasattr(msg, "tool_calls") and msg.tool_calls:
            print(f"   Tool calls: {msg.tool_calls}")

    # Финальный ответ
    for msg in reversed(result["messages"]):
        if isinstance(msg, AIMessage) and not getattr(msg, "tool_calls", None):
            print(f"\n=== Финальный ответ ===")
            print(msg.content)
            break
    else:
        print("\n=== Финальный ответ не найден ===")

Разбор концепции графов

Тут мы уже сталкиваемся с графом. Постараюсь коротко прокомментировать. Все в LangGraph держится на 4 основных «китах»:

  • Сам граф или некая дорожная карта

  • Узел (нода) или некие точки на этой карте

  • Ребра — связки между нодами

  • Состояния (некие чекпоинты в рамках «дорожной карты»)

Создание графа

Процесс начинается с создания графа:

graph = StateGraph(AgentState)

Добавление узлов

Затем мы привязываем к нему все существующие узлы (ноды):

graph.add_node("our_agent", model_call)
tool_node = ToolNode(tools=tools)
graph.add_node("tools", tool_node)

Ноды всегда принимают имя и некую функцию (в некоторых случаях достаточно использовать безымянные функции). Функции могут быть как наши, так и сервисные, как в примере с ToolNode.

Связывание узлов

Далее нам необходимо узлы между собой связать. Связывать можно как обычными ребрами, так и условными.

Пример обычного ребра:

graph.add_edge(START, "our_agent")

Тут мы связали 2 узла: системный узел (START, который ранее импортировали) и наш узел. Для связки в таких узлах используется имя узлов.

Пример условного ребра:

graph.add_conditional_edges(
    "our_agent", should_continue, {"continue": "tools", "end": END}
)

Он принимает имя узла, от которого должно пойти ребро. Далее, вторым параметром, принимает название условной функции (она всегда строки возвращает), и далее мы описываем простое условие:

если условная функция вернула «continue», то мы вызываем узел tools, иначе мы вызываем узел END, тем самым завершая работу графа.

Понимаю, что сейчас может быть не все понятно, но когда-то, если это будет вам нужно, я более детально и подробно разложу концепт графов в формате мини-курса на Хабре.

Замыкание цикла

Если мы вызвали узел инструментов, то с него мы выполняем переход обратно на нашего агента, а тот уже, когда увидит, что инструменты не вызывались, просто завершит работу — END.

graph.add_edge("tools", "our_agent")

Компиляция и запуск

Далее нам нужно скомпилировать граф:

app = graph.compile()

И остается только запустить:

result = await app.ainvoke({
    "messages": [
        HumanMessage(
            content="Посчитай общее количество файлов в этой директории и прибавь к этому значению 10"
        )
    ]
})

Далее я просто в подробном виде отобразил ответ нашего агента.

Биндим собственные инструменты и инструменты чужого MCP-сервера

Тут нужно понимать, что для того чтобы появилась техническая возможность у ваших ИИ-агентов использовать инструменты из MCP-серверов — вам нужно каким-то образом подключиться к ним. Для этого на данный момент существует 2 основных вида транспорта:

  • stdio: когда вы физически запускаете на своей локальной машине или VPS-сервере MCP, извлекаете набор тулзов и передаете их ИИ-агенту (через bind или через react_agent)

  • streamable_http: та же логика, но с удаленным подключением по HTTP-протоколу

Если вы разобрались с биндом обычных тулзов, то и вопросов бинда тулзов от MCP-сервера у вас тоже возникнуть не должно. Все сводится к следующему:

  1. Объединяем все наши кастомные тулзы в 1 список (если они есть)

  2. Объединяем тулзы MCP-сервера (серверов) в другой список

  3. Объединяем эти 2 списка в 1 список и биндим к агенту

Давайте теперь проверим это на практике.

Создание кастомного инструмента

Чтобы было интереснее — напишем тулзу, которая будет принимать пол (male | female) и будет возвращать мужское или женское имя с фамилией:

@tool
async def get_random_user_name(gender: str) -> str:
    """
    Возвращает случайное мужское или женское имя в зависимости от условия:
    male - мужчина, female - женщина
    """
    faker = Faker("ru_RU")
    gender = gender.lower()
    if gender == "male":
        return f"{faker.first_name_male()} {faker.last_name_male()}"
    return f"{faker.first_name_female()} {faker.last_name_female()}"

Подключение MCP-адаптера

Теперь давайте импортируем специальный адаптер, который позволит извлечь инструменты из подключенных MCP-серверов:

from langchain_mcp_adapters.client import MultiServerMCPClient

Теперь объединим в список все наши существующие тулзы:

custom_tools = [get_random_user_name]

Функция для получения всех инструментов

Теперь давайте напишем функцию, которая будет извлекать инструменты из подключенных MCP-серверов:

async def get_all_tools():
    """Получение всех инструментов: ваших + MCP"""
    # Настройка MCP клиента
    mcp_client = MultiServerMCPClient(
        {
            "filesystem": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
                "transport": "stdio",
            },
            "context7": {
                "transport": "streamable_http",
                "url": "https://mcp.context7.com/mcp",
            },
        }
    )

    # Получаем MCP инструменты
    mcp_tools = await mcp_client.get_tools()

    # Объединяем ваши инструменты с MCP инструментами
    return custom_tools + mcp_tools

Разбор подключенных серверов

Давайте разбираться.

Благодаря MultiServerMCPClient мы смогли подключиться к 2-м MCP-серверам:

  • context7 по streamable_http — очень полезный MCP-сервер, который возвращает актуальную информацию по самым ходовым библиотекам и фреймворкам. При разработке — незаменимая вещь!

  • filesystem по stdio — хороший MCP-сервер, инструменты которого позволяют взаимодействовать с файловой системой: создавать, изменять файлы, выводить список и так далее.

Важные моменты установки

Важный момент по поводу локальных MCP (транспорт stdio). Для того чтобы они работали, часто требуется локальная установка. В случае с server-filesystem MCP установка будет иметь следующий вид:

npm install -g @modelcontextprotocol/server-filesystem

Также, в зависимости от команды, возможно, вам необходимо будет установить дополнительный софт. Например, Python с библиотекой uv, Node.js последней версии, npm и так далее.

Результат объединения

На выходе функция get_all_tools просто вернет список всех доступных тулзов — как кастомных, так и родом из подключенных MCP.

Следующий шаг

Далее, в случае с прямым биндом, отличий от предыдущего примера, где мы биндили только кастомные тулзы, особо не будет, так что останавливаться на этом не будем.

Кому будет интересно — в моем бесплатном телеграм-канале «Легкий путь в Python» уже лежит полный исходный код с примерами и с MCP-сервером.

Переходим к «черному ящику» — react_agent.

Тулзы с «Черным ящиком» React Agent LangGraph

Теперь посмотрим, как работает React Agent и каким образом он принимает тулзы для работы. Думаю, что вы будете удивлены, когда узнаете, что кода с React Agent для прикрепления тулзов будет даже меньше, чем в примере с биндом.

Что такое React Agent?

React Agent — это предварительно настроенный агент из LangGraph, который реализует паттерн ReAct (Reasoning + Acting). Это означает, что агент:

  1. Размышляет (Reasoning) — анализирует задачу и планирует действия

  2. Действует (Acting) — выполняет нужные инструменты

  3. Наблюдает — получает результаты и корректирует план

  4. Повторяет цикл до получения финального ответа

В отличие от ручной сборки графа, React Agent автоматически управляет всей логикой принятия решений. Вам не нужно думать о состояниях, узлах и ребрах — это уже реализовано внутри.

Простая инициализация

Первый этап, где мы объединяем в один список тулзы (кастомные и от MCP-агентов), отличаться не будет, но самое главное отличие будет далее и заключаться оно будет в инициализации агента. В данном примере нам не пригодятся графы.

1. Получаем список всех инструментов:

all_tools = await get_all_tools()

2. Инициируем агента:

from langgraph.prebuilt import create_react_agent


agent = create_react_agent(
    model=ChatDeepSeek(model="deepseek-chat"),
    tools=all_tools,
    prompt="Ты дружелюбный ассистент, который может генерировать фейковых пользователей, \
выполнять вычисления и делиться интересными фактами.",
)

При инициализации мы передаем:

  1. Модель (обратите внимание, без явного бинда — просто инициализация модели)

  2. Передаем список наших инструментов в параметре tools

  3. Пишем пользовательский промпт, который определяет поведение агента

Магия ReAct Agent

Вся магия заключается в том, что create_react_agent под капотом создает сложный граф с:

  • Узлом для вызова модели

  • Узлом для выполнения инструментов

  • Условной логикой для принятия решений

  • Управлением состоянием и сообщениями

Но от вас это скрыто — вы получаете готового к работе агента одной строкой!

Продвинутый вызов с логированием

Для примера я использовал вызов через astream. Такой подход нужен для более удобного логирования ответов нейросети и инструментов. Вот полный код:

async def run_query(agent, query: str):
    """Выполняет один запрос к агенту с читаемым выводом"""
    print(f"? Запрос: {query}")
    
    step_counter = 0
    processed_messages = set()  # Для избежания дублирования
    
    async for event in agent.astream(
        {"messages": [{"role": "user", "content": query}]},
        stream_mode="values",
    ):
        if "messages" in event and event["messages"]:
            messages = event["messages"]
            
            # Обрабатываем только новые сообщения
            for msg in messages:
                msg_id = getattr(msg, 'id', str(id(msg)))
                if msg_id in processed_messages:
                    continue
                processed_messages.add(msg_id)
                
                # Получаем тип сообщения
                msg_type = getattr(msg, 'type', 'unknown')
                content = getattr(msg, 'content', '')
                
                # 1. Сообщения от пользователя
                if msg_type == 'human':
                    print(f"? Пользователь: {content}")
                    print("-" * 40)
                
                # 2. Сообщения от ИИ
                elif msg_type == 'ai':
                    # Проверяем наличие вызовов инструментов
                    tool_calls = getattr(msg, 'tool_calls', [])
                    
                    if tool_calls:
                        step_counter += 1
                        print(f"? Шаг {step_counter}: Агент использует инструменты")
                        
                        # Размышления агента (если есть)
                        if content and content.strip():
                            print(f"? Размышления: {content}")
                        
                        # Детали каждого вызова инструмента
                        for i, tool_call in enumerate(tool_calls, 1):
                            # Парсим tool_call в зависимости от формата
                            if isinstance(tool_call, dict):
                                tool_name = tool_call.get('name', 'unknown')
                                tool_args = tool_call.get('args', {})
                                tool_id = tool_call.get('id', 'unknown')
                            else:
                                # Если это объект с атрибутами
                                tool_name = getattr(tool_call, 'name', 'unknown')
                                tool_args = getattr(tool_call, 'args', {})
                                tool_id = getattr(tool_call, 'id', 'unknown')
                            
                            print(f"? Инструмент {i}: {tool_name}")
                            print(f"   ? Параметры: {tool_args}")
                            print(f"   ? ID: {tool_id}")
                        print("-" * 40)
                    
                    # Финальный ответ (без tool_calls)
                    elif content and content.strip():
                        print(f"? Финальный ответ:")
                        print(f"? {content}")
                        print("-" * 40)
                
                # 3. Результаты выполнения инструментов
                elif msg_type == 'tool':
                    tool_name = getattr(msg, 'name', 'unknown')
                    tool_call_id = getattr(msg, 'tool_call_id', 'unknown')
                    print(f"? Результат инструмента: {tool_name}")
                    print(f"   ? Call ID: {tool_call_id}")
                    
                    # Форматируем результат
                    if content:
                        # Пытаемся распарсить JSON для красивого вывода
                        try:
                            import json
                            if content.strip().startswith(('{', '[')):
                                parsed = json.loads(content)
                                formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
                                print(f"   ? Результат:")
                                for line in formatted.split('\n'):
                                    print(f"     {line}")
                            else:
                                print(f"   ? Результат: {content}")
                        except:
                            print(f"   ? Результат: {content}")
                    print("-" * 40)
                
                # 4. Другие типы сообщений (для отладки)
                else:
                    if content:
                        print(f"❓ Неизвестный тип ({msg_type}): {content[:100]}...")
                        print("-" * 40)
    
    print("=" * 80)
    print("✅ Запрос обработан")
    print()

Простой вызов без логирования

Основная «длина» кода выше обусловлена детальным логированием результата. В целом, для простого вызова было бы достаточно всего одной строки:

# Простейший вызов
result = await agent.ainvoke({"messages": [{"role": "user", "content": "Твой запрос"}]})
print(result["messages"][-1].content)

Как видите, с React Agent мы получили мощного агента буквально в несколько строк кода!

FastMCP: быстрый старт

Думаю, что к этому моменту вы поняли, что никакой особой сложности или «магии» за MCP-серверами не стоит. Это просто набор разрозненных функций, объединенных между собой какой-то общей задачей.

Следовательно — настало время разобраться с тем, как писать собственные MCP-серверы!

Так как материала уже получилось много — сейчас я проведу короткий экспресс-курс «молодого бойца» в знакомстве с FastMCP. Когда-то, возможно, вернемся и более детально распакуем этого зверя.

Что такое FastMCP?

FastMCP — это высокоуровневый Python-фреймворк, который делает создание MCP-серверов максимально простым. Он разработан так, чтобы быть быстрым и Pythonic — в большинстве случаев достаточно просто декорировать функцию.

Главное, что нужно понять — FastMCP 1.0 оказался настолько успешным, что был интегрирован в официальный MCP Python SDK. А FastMCP 2.0 — это активно развиваемая версия с расширенным функционалом.

Транспорты и возможности

Главное, что нужно понять, так это то, что на FastMCP вы можете создавать MCP-серверы, которые будут работать:

  • Локально по stdio (сегодня рассматривать не будем)

  • По streamable_http (в FastMCP просто transport="http")

Технически все будет сводиться к тому, чтобы объединить несколько инструментов в одно целое.

Три способа описания функционала

Сами инструменты можно описывать 3-мя основными способами:

1. Tools (инструменты)

Инструменты позволяют LLM выполнять действия, вызывая ваши Python-функции (синхронные или асинхронные). Идеально подходят для вычислений, API-вызовов или побочных эффектов (как POST/PUT).

Примерно такая же логика и синтаксис, как в LangGraph:

from fastmcp import FastMCP


mcp = FastMCP("Мой сервер")


@mcp.tool
def add(a: int, b: int) -> int:
    """Складывает два числа"""
    return a + b

  
@mcp.tool
async def fetch_weather(city: str) -> str:
    """Получает погоду для города"""
    # Здесь может быть вызов API
    return f"В городе {city} сегодня солнечно"

2. Resources (ресурсы)

Ресурсы предоставляют источники данных только для чтения (как GET-запросы). Они позволяют LLM получать информацию из ваших данных.

@mcp.resource("user://profile/{user_id}")
def get_user_profile(user_id: str) -> str:
    """Получает профиль пользователя по ID"""
    return f"Профиль пользователя {user_id}: активный, премиум-подписка"

  
@mcp.resource("docs://readme")
def get_readme() -> str:
    """Возвращает README проекта"""
    with open("README.md", "r") as f:
        return f.read()

3. Prompts (промпты)

Промпты определяют шаблоны взаимодействия для LLM (переиспользуемые шаблоны для взаимодействий с LLM).

@mcp.prompt
def debug_code(error_message: str) -> str:
    """Помогает отладить код по сообщению об ошибке"""
    return f"""
    Анализируй эту ошибку и предложи решение:
    
    Ошибка: {error_message}
    
    Дай пошаговые инструкции для исправления.
    """

  
@mcp.prompt  
def review_code(code: str) -> list:
    """Создает промпт для ревью кода"""
    return [
        {"role": "user", "content": f"Проверь этот код:\n\n{code}"},
        {"role": "assistant", "content": "Я помогу проверить код. Что конкретно тебя беспокоит?"}
    ]

Простой пример: собираем всё вместе

Давайте создадим небольшой MCP-сервер, который демонстрирует все три подхода:

from fastmcp import FastMCP
import json
import datetime

# Создаем сервер
mcp = FastMCP(
    name="Demo Assistant",
    instructions="Ассистент для демонстрации возможностей MCP"
)


# === ИНСТРУМЕНТЫ ===
@mcp.tool
def calculate_age(birth_year: int) -> int:
    """Вычисляет возраст по году рождения"""
    current_year = datetime.datetime.now().year
    return current_year - birth_year

  
@mcp.tool
async def generate_password(length: int = 12) -> str:
    """Генерирует случайный пароль"""
    import random, string
    chars = string.ascii_letters + string.digits + "!@#$%"
    return ''.join(random.choice(chars) for _ in range(length))

  
# === РЕСУРСЫ ===
@mcp.resource("system://status")
def system_status() -> str:
    """Возвращает статус системы"""
    return json.dumps({
        "status": "online",
        "timestamp": datetime.datetime.now().isoformat(),
        "version": "1.0.0"
    })

    
@mcp.resource("help://{topic}")
def get_help(topic: str) -> str:
    """Возвращает справку по теме"""
    help_docs = {
        "password": "Используйте generate_password для создания безопасных паролей",
        "age": "Используйте calculate_age для вычисления возраста",
        "status": "Проверьте system://status для мониторинга системы"
    }
    return help_docs.get(topic, f"Справка по теме '{topic}' не найдена")

  
# === ПРОМПТЫ ===
@mcp.prompt
def security_check(action: str) -> str:
    """Создает промпт для проверки безопасности действия"""
    return f"""
    Ты специалист по информационной безопасности. 
    Проанализируй это действие на предмет безопасности: {action}
    
    Оцени:
    1. Потенциальные риски
    2. Рекомендации по безопасности  
    3. Альтернативные подходы
    """

  
@mcp.prompt
def explain_result(tool_name: str, result: str) -> str:
    """Объясняет результат работы инструмента"""
    return f"""
    Объясни пользователю простыми словами результат работы инструмента '{tool_name}':
    
    Результат: {result}
    
    Сделай объяснение понятным и полезным.
    """

  
# Запуск сервера
if __name__ == "__main__":
    mcp.run(transport="http", port=8000)

Тестирование FastMCP-сервера

Для тестирования вашего MCP-сервера у вас есть несколько вариантов, от простых до продвинутых.

1. MCP Inspector (быстрое тестирование)

FastMCP поставляется с встроенным инструментом отладки — MCP Inspector, который предоставляет удобный веб-интерфейс:

# Запуск инспектора
fastmcp dev demo_server.py

Откроется браузер с интерфейсом, где вы сможете:

  • Во вкладке Tools тестировать инструменты с реальными параметрами

  • Во вкладке Resources проверять ресурсы

  • Во вкладке Prompts генерировать промпты

2. Программный клиент (для серьезного тестирования)

Для более серьезного тестирования стоит написать программный клиент. Вот пример полноценного тест-клиента для нашего Demo Assistant:

import asyncio
import json
from fastmcp import Client
from dotenv import load_dotenv

load_dotenv()


def safe_parse_json(text):
    """Безопасно парсит JSON или возвращает исходный текст"""
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return text


async def test_demo_server():
    """Полноценное тестирование Demo Assistant MCP-сервера."""

    print("? Подключаемся к Demo Assistant серверу...")
    client = Client("http://127.0.0.1:8000/mcp/")

    async with client:
        try:
            # Проверяем соединение
            print("✅ Сервер запущен!\n")

            # Получаем возможности сервера
            tools = await client.list_tools()
            resources = await client.list_resources()
            prompts = await client.list_prompts()

            # Отображаем что доступно
            print(f"? Доступно инструментов: {len(tools)}")
            for tool in tools:
                print(f"   • {tool.name}: {tool.description}")

            print(f"\n? Доступно ресурсов: {len(resources)}")
            for resource in resources:
                print(f"   • {resource.uri}")

            print(f"\n? Доступно промптов: {len(prompts)}")
            for prompt in prompts:
                print(f"   • {prompt.name}: {prompt.description}")

            print("\n? ТЕСТИРУЕМ ФУНКЦИОНАЛ:")
            print("-" * 50)

            # === ТЕСТИРУЕМ ИНСТРУМЕНТЫ ===

            # 1. Тест расчета возраста
            print("1️⃣ Тестируем calculate_age:")
            result = await client.call_tool("calculate_age", {"birth_year": 1990})
            age_data = safe_parse_json(result.content[0].text)
            print(f"   Возраст человека 1990 г.р.: {age_data} лет")

            # 2. Тест генерации пароля
            print("\n2️⃣ Тестируем generate_password:")
            result = await client.call_tool("generate_password", {"length": 16})
            password_data = safe_parse_json(result.content[0].text)
            print(f"   Сгенерированный пароль (16 символов): {password_data}")

            # === ТЕСТИРУЕМ РЕСУРСЫ ===

            # 3. Тест системного статуса
            print("\n3️⃣ Читаем system://status:")
            resource = await client.read_resource("system://status")
            status_content = resource[0].text
            status_data = safe_parse_json(status_content)
            print(f"   Статус системы: {status_data['status']}")
            print(f"   Время: {status_data['timestamp']}")
            print(f"   Версия: {status_data['version']}")

            # 4. Тест динамического ресурса помощи
            print("\n4️⃣ Читаем help://password:")
            resource = await client.read_resource("help://password")
            help_content = resource[0].text
            print(f"   Справка: {help_content}")

            # === ТЕСТИРУЕМ ПРОМПТЫ ===

            # 5. Тест промпта безопасности
            print("\n5️⃣ Генерируем security_check промпт:")
            prompt = await client.get_prompt("security_check", {
                "action": "открыть порт 3000 на сервере"
            })
            security_prompt = prompt.messages[0].content.text
            print(f"   Промпт создан (длина: {len(security_prompt)} символов)")
            print(f"   Начало: {security_prompt[:100]}...")

            # 6. Тест промпта объяснения
            print("\n6️⃣ Генерируем explain_result промпт:")
            prompt = await client.get_prompt("explain_result", {
                "tool_name": "generate_password",
                "result": "Tj9$mK2pL8qX"
            })
            explain_prompt = prompt.messages[0].content.text
            print(f"   Промпт создан (длина: {len(explain_prompt)} символов)")
            print(f"   Начало: {explain_prompt[:100]}...")

            print("\n? ВСЕ ТЕСТЫ ПРОШЛИ УСПЕШНО!")
            print("? Статистика:")
            print(f"   ✅ Инструментов протестировано: 2/{len(tools)}")
            print(f"   ✅ Ресурсов протестировано: 2/{len(resources)}")
            print(f"   ✅ Промптов протестировано: 2/{len(prompts)}")

        except Exception as e:
            print(f"❌ Ошибка при тестировании: {e}")
            import traceback
            traceback.print_exc()


if __name__ == "__main__":
    asyncio.run(test_demo_server())

3. Как запускать тесты

Для запуска тестов в одном окне запускам FastMCP приложение, а в другом окне — файл с клиентом для тестирования.

Приступим к созданию собственного MCP сервера!

Практика: создаем полноценный математический MCP-сервер

Думаю, что к этому моменту теории достаточно — пора переходить к практике! В этом разделе мы создадим полноценный математический MCP-сервер, который продемонстрирует все возможности FastMCP: инструменты, ресурсы и промпты.

В целом, вам не обязательно повторять мой код в данном блоке. Если у вас есть мысли или собственные идеи по созданию своего MCP-сервера — воплощайте! Если особых идей нет, то предлагаю воплотить вместе со мной математический MCP-сервер, который обработает все три типа компонентов: инструменты, ресурсы и промпты.

Структура проекта

Предлагаю создать отдельный проект под MCP-сервер. Логика та же: поднимаем виртуальное окружение, устанавливаем зависимости (fastmcp==2.10.6) и прочие, которые будет требовать ваш проект.

Подготовим структуру проекта:

math_mcp_server/
├── server.py              # Главный файл сервера
├── routes/                 # Модули с логикой
│   ├── __init__.py
│   ├── basic_math.py      # Базовые математические операции
│   ├── geometry.py        # Геометрические вычисления
│   ├── statistics.py      # Статистика и анализ данных
│   ├── resources.py       # Математические ресурсы
│   └── prompts.py         # Генераторы промптов
├── requirements.txt
└── test_client.py         # Клиент для тестирования

Почему такая структура? Мы разбиваем функционал на логические модули, чтобы код был читаемым и легко расширяемым. Каждый модуль отвечает за свою область математики.

Базовые математические операции

Начнем с модуля базовых операций. Я приведу полный код этого модуля, чтобы вы увидели логику выстраивания кода:

# routes/basic_math.py
import math
from datetime import datetime
from fastmcp import FastMCP

def setup_basic_math_routes(server: FastMCP):
    """Настройка базовых математических операций."""

    @server.tool
    def calculate_basic(expression: str) -> dict:
        """Вычислить базовое математическое выражение."""
        try:
            # Безопасное вычисление только математических выражений
            allowed_names = {
                k: v for k, v in math.__dict__.items()
                if not k.startswith("__")
            }
            allowed_names.update({"abs": abs, "round": round, "pow": pow})

            result = eval(expression, {"__builtins__": {}}, allowed_names)
            return {
                "expression": expression,
                "result": result,
                "type": type(result).__name__,
                "calculated_at": datetime.now().isoformat()
            }
        except Exception as e:
            return {
                "expression": expression,
                "error": str(e),
                "calculated_at": datetime.now().isoformat()
            }

    @server.tool
    def solve_quadratic(a: float, b: float, c: float) -> dict:
        """Решить квадратное уравнение ax² + bx + c = 0."""
        discriminant = b**2 - 4*a*c

        if discriminant > 0:
            x1 = (-b + math.sqrt(discriminant)) / (2*a)
            x2 = (-b - math.sqrt(discriminant)) / (2*a)
            return {
                "equation": f"{a}x² + {b}x + {c} = 0",
                "discriminant": discriminant,
                "roots": [x1, x2],
                "type": "two_real_roots"
            }
        elif discriminant == 0:
            x = -b / (2*a)
            return {
                "equation": f"{a}x² + {b}x + {c} = 0",
                "discriminant": discriminant,
                "roots": [x],
                "type": "one_real_root"
            }
        else:
            real_part = -b / (2*a)
            imaginary_part = math.sqrt(abs(discriminant)) / (2*a)
            return {
                "equation": f"{a}x² + {b}x + {c} = 0",
                "discriminant": discriminant,
                "roots": [
                    f"{real_part} + {imaginary_part}i",
                    f"{real_part} - {imaginary_part}i"
                ],
                "type": "complex_roots"
            }

    @server.tool
    def factorial(n: int) -> dict:
        """Вычислить факториал числа."""
        if n < 0:
            return {"error": "Факториал не определен для отрицательных чисел"}

        result = math.factorial(n)
        return {
            "number": n,
            "factorial": result,
            "formula": f"{n}!",
            "steps": " × ".join(str(i) for i in range(1, n + 1)) if n > 0 else "1"
        }

Ключевые принципы:

  1. Модульность: мы назначаем основную функцию setup_basic_math_routes(), которая аргументом всегда принимает наш сервер — server. Далее последующая логика ничем не будет отличаться от той, которую мы рассматривали ранее.

  2. Безопасность: в calculate_basic мы ограничиваем доступные функции, чтобы предотвратить выполнение опасного кода.

  3. Подробные ответы: каждая функция возвращает структурированную информацию с пояснениями.

Геометрические вычисления

По остальным модулям приведу основные функции с комментариями:

# routes/geometry.py
import math
from fastmcp import FastMCP

def setup_geometry_routes(server: FastMCP):
    """Настройка геометрических функций."""

    @server.tool
    def circle_properties(radius: float) -> dict:
        """Вычислить свойства окружности по радиусу."""
        if radius <= 0:
            return {"error": "Радиус должен быть положительным числом"}

        return {
            "radius": radius,
            "diameter": 2 * radius,
            "circumference": 2 * math.pi * radius,
            "area": math.pi * radius**2,
            "formulas": {
                "circumference": "2πr",
                "area": "πr²"
            }
        }

    @server.tool
    def triangle_area(base: float, height: float) -> dict:
        """Вычислить площадь треугольника."""
        if base <= 0 or height <= 0:
            return {"error": "Основание и высота должны быть положительными"}

        area = 0.5 * base * height
        return {
            "base": base,
            "height": height,
            "area": area,
            "formula": "½ × основание × высота"
        }

    @server.tool
    def distance_between_points(x1: float, y1: float, x2: float, y2: float) -> dict:
        """Вычислить расстояние между двумя точками."""
        distance = math.sqrt((x2 - x1)**2 + (y2 - y1)**2)

        return {
            "point1": {"x": x1, "y": y1},
            "point2": {"x": x2, "y": y2},
            "distance": distance,
            "formula": "√[(x₂-x₁)² + (y₂-y₁)²]"
        }

Промпты для обучения математике

Промпты — это мощный инструмент для создания образовательного контента:

# routes/prompts.py
from fastmcp import FastMCP

def setup_math_prompts(server: FastMCP):
    """Настройка математических промптов."""

    @server.prompt
    def explain_solution(problem: str, solution: str, level: str = "intermediate") -> str:
        """Промпт для объяснения математического решения."""

        level_instructions = {
            "beginner": "Объясни очень простыми словами, как будто учишь школьника",
            "intermediate": "Дай подробное объяснение с промежуточными шагами",
            "advanced": "Включи математическое обоснование и альтернативные методы решения"
        }

        instruction = level_instructions.get(level, level_instructions["intermediate"])

        return f"""
Ты математический преподаватель. {instruction}.

Задача: {problem}
Решение: {solution}

Твоя задача:
1. Объясни каждый шаг решения
2. Укажи какие математические правила применялись
3. Покажи почему именно так решается задача
4. Дай советы для решения похожих задач

Используй ясный язык и приводи примеры где это уместно.
"""

    @server.prompt
    def create_practice_problems(topic: str, difficulty: str = "medium", count: int = 5) -> str:
        """Промпт для создания практических задач."""

        difficulty_descriptions = {
            "easy": "простые задачи для начинающих",
            "medium": "задачи среднего уровня сложности", 
            "hard": "сложные задачи для продвинутых учеников"
        }

        diff_desc = difficulty_descriptions.get(difficulty, "задачи среднего уровня")

        return f"""
Создай {count} {diff_desc} по теме "{topic}".

Требования:
1. Каждая задача должна иметь четкое условие
2. Укажи правильный ответ для каждой задачи
3. Задачи должны быть разнообразными
4. Приведи краткое решение для каждой

Формат:
Задача 1: [условие]
Ответ: [правильный ответ]
Решение: [краткие шаги]

Тема: {topic}
Сложность: {difficulty}
Количество: {count}
"""

Математические ресурсы-справочники

Ресурсы предоставляют справочную информацию:

# routes/resources.py
import json
import math
from fastmcp import FastMCP

def setup_math_resources(server: FastMCP):
    """Настройка математических ресурсов-справочников."""

    @server.resource("math://formulas/basic")
    def basic_formulas() -> str:
        """Основные математические формулы."""
        formulas = {
            "Алгебра": {
                "Квадратное уравнение": "ax² + bx + c = 0, x = (-b ± √(b²-4ac)) / 2a",
                "Разность квадратов": "a² - b² = (a + b)(a - b)",
                "Квадрат суммы": "(a + b)² = a² + 2ab + b²",
                "Квадрат разности": "(a - b)² = a² - 2ab + b²"
            },
            "Геометрия": {
                "Площадь круга": "S = πr²",
                "Длина окружности": "C = 2πr", 
                "Площадь треугольника": "S = ½ × основание × высота",
                "Теорема Пифагора": "c² = a² + b²",
                "Площадь прямоугольника": "S = длина × ширина"
            },
            "Тригонометрия": {
                "Основное тригонометрическое тождество": "sin²α + cos²α = 1",
                "Формула синуса двойного угла": "sin(2α) = 2sin(α)cos(α)",
                "Формула косинуса двойного угла": "cos(2α) = cos²α - sin²α"
            }
        }
        return json.dumps(formulas, ensure_ascii=False, indent=2)

    @server.resource("math://constants/mathematical")
    def math_constants() -> str:
        """Математические константы."""
        constants = {
            "π (Пи)": {
                "value": math.pi,
                "description": "Отношение длины окружности к её диаметру",
                "approximation": "3.14159"
            },
            "e (Число Эйлера)": {
                "value": math.e,
                "description": "Основание натурального логарифма",
                "approximation": "2.71828"
            },
            "φ (Золотое сечение)": {
                "value": (1 + math.sqrt(5)) / 2,
                "description": "Золотое сечение",
                "approximation": "1.61803"
            },
            "√2": {
                "value": math.sqrt(2),
                "description": "Квадратный корень из 2",
                "approximation": "1.41421"
            }
        }
        return json.dumps(constants, ensure_ascii=False, indent=2)

Статистика и анализ данных

# routes/statistics.py
import statistics
from typing import List
from fastmcp import FastMCP

def setup_statistics_routes(server: FastMCP):
    """Настройка статистических функций."""

    @server.tool
    def analyze_dataset(numbers: List[float]) -> dict:
        """Полный статистический анализ набора данных."""
        if not numbers:
            return {"error": "Пустой набор данных"}

        n = len(numbers)

        return {
            "dataset": numbers,
            "count": n,
            "sum": sum(numbers),
            "mean": statistics.mean(numbers),
            "median": statistics.median(numbers),
            "mode": statistics.mode(numbers) if len(set(numbers)) < n else "Нет моды",
            "range": max(numbers) - min(numbers),
            "min": min(numbers),
            "max": max(numbers),
            "variance": statistics.variance(numbers) if n > 1 else 0,
            "std_deviation": statistics.stdev(numbers) if n > 1 else 0,
            "quartiles": {
                "q1": statistics.quantiles(numbers, n=4)[0] if n >= 4 else None,
                "q2": statistics.median(numbers),
                "q3": statistics.quantiles(numbers, n=4)[2] if n >= 4 else None
            }
        }

    @server.tool
    def correlation_coefficient(x_values: List[float], y_values: List[float]) -> dict:
        """Вычислить коэффициент корреляции Пирсона между двумя наборами данных."""
        if len(x_values) != len(y_values):
            return {"error": "Наборы данных должны быть одинакового размера"}

        if len(x_values) < 2:
            return {"error": "Нужно минимум 2 точки данных"}

        try:
            correlation = statistics.correlation(x_values, y_values)

            # Интерпретация силы корреляции
            abs_corr = abs(correlation)
            if abs_corr >= 0.8:
                strength = "очень сильная"
            elif abs_corr >= 0.6:
                strength = "сильная"
            elif abs_corr >= 0.4:
                strength = "умеренная"
            elif abs_corr >= 0.2:
                strength = "слабая"
            else:
                strength = "очень слабая"

            direction = "положительная" if correlation > 0 else "отрицательная"

            return {
                "x_values": x_values,
                "y_values": y_values,
                "correlation_coefficient": correlation,
                "interpretation": {
                    "strength": strength,
                    "direction": direction,
                    "description": f"{strength} {direction} корреляция"
                }
            }
        except Exception as e:
            return {"error": f"Ошибка вычисления: {str(e)}"}

Сборка проекта: главный файл сервера

Для сборки проекта в корне опишем файл server.py:

# server.py
from datetime import datetime
from fastmcp import FastMCP
from routes.basic_math import setup_basic_math_routes
from routes.prompts import setup_math_prompts
from routes.resources import setup_math_resources
from routes.statistics import setup_statistics_routes
from routes.geometry import setup_geometry_routes


def create_math_server() -> FastMCP:
    """Создать и настроить математический MCP-сервер."""

    server = FastMCP("Mathematical Calculator & Tutor")

    # Подключаем все модули
    setup_basic_math_routes(server)
    setup_statistics_routes(server)
    setup_geometry_routes(server)
    setup_math_resources(server)
    setup_math_prompts(server)

    # Дополнительные общие инструменты
    @server.tool
    def server_info() -> dict:
        """Информация о математическом сервере."""
        return {
            "name": "Mathematical Calculator & Tutor",
            "version": "1.0.0",
            "description": "Полнофункциональный математический MCP-сервер",
            "capabilities": {
                "tools": [
                    "Базовые вычисления",
                    "Решение квадратных уравнений", 
                    "Статистический анализ",
                    "Геометрические вычисления",
                    "Факториалы"
                ],
                "resources": [
                    "Математические формулы",
                    "Константы",
                    "Справка по статистике",
                    "Примеры решений"
                ],
                "prompts": [
                    "Объяснение решений",
                    "Создание задач",
                    "Репетиторство",
                    "Анализ ошибок"
                ]
            },
            "created_at": datetime.now().isoformat()
        }

# ================================
# ЗАПУСК СЕРВЕРА
# ================================

if __name__ == "__main__":
    math_server = create_math_server()
    math_server.run(transport="http", port=8000, host="0.0.0.0")

Деплой и тестирование через ИИ-агентов

И так, мы подняли с вами собственный MCP-сервер, к которому можно подключаться удаленно (по HTTP-протоколу), но без деплоя в этом большого смысла не будет, так как подключиться сейчас к нашему серверу можно только на локальном компьютере. По сути, сейчас он работает не как transport="http", а как stdio.

Давайте исправлять эту ситуацию!

Зачем нужен деплой MCP-сервера?

Локальный запуск ограничивает возможности:

  • Сервер доступен только с вашего компьютера

  • Нельзя поделиться с коллегами или интегрировать в продакшн

  • ИИ-агенты не могут подключиться удаленно

  • Нет постоянной доступности (выключили компьютер — сервер недоступен)

Деплой решает эти проблемы:

  • Доступность 24/7 из любой точки мира

  • Возможность интеграции с ИИ-платформами

  • Масштабируемость и надежность

  • Простое подключение через URL

Выбор платформы для деплоя

Самое простое решение для деплоя — взять сервис, на который достаточно будет доставить свое FastMCP-приложение и на котором это приложение запустится автоматически. Кроме того, чтобы не тратиться на покупке доменного имени, хотелось бы, чтобы его нам дали в подарок.

Такое решение — облачный хостинг Amvera Cloud.

Почему Amvera?

  • Бесплатный домен в подарок

  • 111 рублей на баланс за регистрацию

  • Автоматический деплой из Git или через интерфейс

  • Простая настройка через конфиг‑файл

  • Автоматическое обновление при изменении кода

  • Стабильный доступ к LLM API — на Amvera, Claude и ChatGPT работают без VPN и прокси «из коробки», что критично для продакшн‑проектов в России

  • Можно подключить API LLM с оплатой рублями. Не нужно иметь иностранную карту.

Подготовка к деплою

Весь процесс деплоя будет сводиться к тому, чтобы доставить файлы вашего приложения с заготовленным конфиг-файлом в созданный на сайте Amvera проект. Доставить можно как просто перетягиванием файлов через интерфейс на сайте, так и через стандартные команды Git (тут как кто привык).

1. Создаем файл конфигурации Amvera

Подготовим файл с настройками amvera.yml:

meta:
  environment: python
  toolchain:
    name: pip
    version: "3.11"
build:
  requirementsPath: requirements.txt
run:
  scriptName: server.py
  persistenceMount: /data
  containerPort: 8000

Что означают параметры:

  • environment: python — используем Python-окружение

  • toolchain.version: "3.11" — версия Python

  • requirementsPath — путь к файлу с зависимостями

  • scriptName — главный файл для запуска

  • containerPort: 8000 — порт приложения (должен совпадать с тем, что в коде)

2. Подготавливаем requirements.txt

В нашем случае содержимое файла минимальное:

fastmcp==2.10.6

При необходимости добавьте другие зависимости, которые использует ваш проект.

Пошаговый деплой на Amvera

Этого достаточно! Теперь действуем пошагово:

Шаг 1: Регистрация и создание проекта

  1. Заходим на сайт amvera.ru и регистрируемся (за регистрацию, кстати, получаем 111 рублей на внутренний баланс — достаточно для бесплатного старта)

  2. Кликаем на «Создать проект». Даем ему имя (например, «math‑mcp‑server») и выбираем тариф. Для тестов будет достаточно «Начальный плюс»

Шаг 2: Загрузка файлов

  1. На экране загрузки файлов выбираем удобный способ. Я выбрал «Через интерфейс». Загружаем файлы:

    • server.py

    • amvera.yml

    • requirements.txt

    • Папку routes/ со всеми модулями

    Жмем «Далее»

Шаг 3: Проверка настроек

  1. Если вы загрузили файл с настройками, то на новом экране вы увидите заполненные поля. Проверяем, чтобы все было корректно, и нажимаем «Завершить»

Шаг 4: Активация домена

  1. Проваливаемся в проект, там выбираем вкладку «Домены» и активируем бесплатное доменное имя. Не забываем передвинуть ползунок для активации!

Шаг 5: Ожидание запуска

После этого ждем 2–3 минуты и ваш сервис доступен по выделенному доменному имени. Если доменное имя не применилось — просто кликаем на кнопку «Пересобрать проект», но обычно этого не требуется.

Получаем URL для подключения

В моем случае ссылка на доступ к MCP-серверу будет иметь следующий вид:

https://math-mcp-server-yakvenalex.amvera.io/mcp/

И, следовательно, для подключения к моему MCP-серверу мне будет достаточно указать следующую конструкцию:

"math_mcp": {
    "transport": "streamable_http",
    "url": "https://math-mcp-server-yakvenalex.amvera.io/mcp/"
}

Пример с кода:

async def get_all_tools():
    """Получение всех инструментов: ваших + MCP"""
    # Настройка MCP клиента
    mcp_client = MultiServerMCPClient(
        {
            "filesystem": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", "."],
                "transport": "stdio",
            },
            "match_mcp": {
                "transport": "streamable_http",
                "url": "https://mcpserver-yakvenalex.amvera.io/mcp/",
            },
            "context7": {
                "transport": "streamable_http",
                "url": "https://mcp.context7.com/mcp",
            },
        }
    )

    # Получаем MCP инструменты
    mcp_tools = await mcp_client.get_tools()

    # Объединяем ваши инструменты с MCP инструментами
    return custom_tools + mcp_tools

Имя сервера (math_mcp) может быть любым.

MCP-сервер отлично взаимодействует как с LangGraph и LangChain, так и с другими агентами, такими как Cursor, Claude Code, Claude Desktop, Gemini Cli и другими.

Заключение

Вот и подошла к концу наша большая статья о MCP-серверах и ИИ-агентах. Признаюсь честно — когда я начинал её писать, думал, что получится что-то покороче. Но тема оказалась настолько увлекательной и многогранной, что остановиться было просто невозможно!

Что мы с вами прошли

За это время мы проделали немалый путь:

  • Разобрались, что такое MCP и чем он отличается от обычных инструментов

  • Научились создавать собственные тулзы и биндить их к нейросетям

  • Освоили подключение готовых MCP-серверов

  • Поняли разницу между ручным биндом и React Agent

  • Создали полноценный математический MCP-сервер с нуля

  • И даже задеплоили его в облако Amvera Cloud!

Мои впечатления

Знаете, что меня больше всего поражает в этой теме? Скорость развития. Буквально полгода назад о MCP мало кто слышал, а сегодня это уже стандарт де-факто для ИИ-агентов. И темп только нарастает — каждую неделю появляются новые фреймворки, новые возможности, новые горизонты.

Но самое классное — это простота. Помните, как раньше интеграция с ИИ была болью? Нужно было разбираться с API, форматами, протоколами... А сейчас? Написал функцию, повесил декоратор @tool — и вуаля, нейросеть уже может её использовать!

Что дальше?

Эта статья — только начало. В планах у меня ещё много интересного:

  • Детальный разбор LangGraph (если увижу отклик на эту статью)

  • Создание сложных многоагентных систем

  • Интеграция MCP с популярными инструментами разработки

  • Может быть, даже видеокурс по теме

Призыв к действию

А пока — экспериментируйте! Создавайте свои MCP-серверы, подключайте их к разным ИИ-моделям, делитесь результатами. Именно сейчас, когда технология только набирает обороты, у каждого из нас есть шанс стать частью этой революции.

Где найти меня

Весь код из статьи, дополнительные материалы и эксклюзивный контент — в моём Telegram-канале «Лёгкий путь в Python». Там я делюсь не только готовыми решениями, но и процессом разработки — со всеми ошибками, инсайтами и «эврика-моментами».

Последние слова

ИИ-агенты перестают быть фантастикой — они становятся частью нашей повседневной работы. И те, кто научится создавать для них правильные инструменты, получат огромное преимущество.

Удачи в ваших экспериментах с MCP! И помните — лучшее время посадить дерево было 20 лет назад, а второе лучшее время — сегодня. То же самое с изучением ИИ-агентов.

P.S. Если статья была полезной — не забудьте поставить лайк и поделиться с коллегами. А ещё лучше — напишите в комментариях, какие MCP-серверы создали вы! Всегда интересно посмотреть на чужие решения.

Комментарии (0)