XNOVA
← Все статьи

СТАТЬЯ / engineering

Как создать MCP-сервер на Python: пошаговый пример

Создаём MCP-сервер с FastMCP, регистрируем инструмент и проверяем вызов из Python-клиента без LLM и API-ключей.

Автор: XNova

Вы пишете сервис на Python. В нём уже есть функция, которая подсказывает, с чего начать диагностику: для API — проверить HTTP-ответ и журнал, для фонового обработчика — очередь задач. Пока этой функцией пользуется только ваш код, всё просто. Но другому приложению понадобятся понятный способ обнаружить её и правила вызова.

MCP-сервер предоставляет такой интерфейс. Он сообщает клиенту, какие инструменты доступны, какие аргументы они принимают и как получить результат. Мы создадим сервер на FastMCP, добавим инструмент find_runbook и вызовем его из Python-клиента.

Сначала проверим работу сервера в одном процессе, затем запустим его через stdio и убедимся, что клиент может общаться с отдельным процессом. Для примера хватит Python и локальных данных: языковая модель, облачный аккаунт и API-ключи не потребуются.

Установка Python и FastMCP

Для работы нужен Python 3.12 и терминал. Используем fastmcp==4.0.5 — фиксированную версию самостоятельной библиотеки FastMCP (страница выпуска на PyPI). Пример также использует модель Pydantic для ответа, поэтому её зависимость укажем явно, а не будем полагаться на косвенную установку.

Создайте каталог mcp-first-server и сохраните в нём файл requirements.txt:

fastmcp==4.0.5
pydantic==2.14.2

Затем в терминале Linux или macOS выполните:

cd mcp-first-server
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m pip show fastmcp

В выводе последней команды найдите Version: 4.0.5. Это поможет сразу заметить, если вы установили другую версию пакета. На Windows изменится путь к Python внутри .venv; способы установки подробно описаны в документации FastMCP.

Если удобнее начать с готовых файлов, используйте закреплённый commit проекта XNova. В нём находятся сервер, оба smoke-сценария, pytest-тесты и CI для Linux, macOS и Windows.

В примерах в интернете можно встретить похожие названия классов из разных библиотек. Самостоятельная библиотека FastMCP использует from fastmcp import FastMCP. В официальном Python SDK mcp версии 2 высокоуровневый класс теперь называется MCPServer; это другой API. Поэтому дальше используем импорты только выбранной библиотеки. Отличия перечислены в описании изменений SDK v2.

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

Создаём MCP-сервер с одним инструментом

Напишем функцию, которая принимает название сервиса и возвращает первую диагностическую подсказку. Для api и worker ответы будут разными; неизвестное имя мы тоже обработаем. Создайте server.py:

from typing import Literal

from fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP("Runbook Demo")

RUNBOOKS: dict[str, str] = {
    "api": "Проверьте HTTP-статус, журнал и подключение к базе.",
    "worker": "Проверьте очередь задач и ошибки обработки.",
}


class RunbookResult(BaseModel):
    service: str
    status: Literal["ok", "not_found"]
    hint: str


@mcp.tool
def find_runbook(service: str) -> RunbookResult:
    """Возвращает первую диагностическую проверку для сервиса."""
    if service not in RUNBOOKS:
        return RunbookResult(
            service=service,
            status="not_found",
            hint="Сервис не найден.",
        )
    return RunbookResult(service=service, status="ok", hint=RUNBOOKS[service])


if __name__ == "__main__":
    mcp.run()

Здесь четыре ключевых элемента. FastMCP("Runbook Demo") создаёт объект сервера. Декоратор @mcp.tool регистрирует функцию find_runbook как доступный клиенту инструмент. Параметр service: str сообщает FastMCP, что клиент должен передать строку; библиотека использует сигнатуру и документацию функции для формирования схемы аргументов. Модель RunbookResult задаёт структуру ответа, а Literal ограничивает status двумя значениями. Эти механизмы описаны в разделе Tools.

На выходе получаем три поля: service (запрошенное имя), status (результат поиска) и hint (что проверить). FastMCP преобразует типизированный объект в структурированный ответ MCP. Неизвестный сервис вернёт not_found как обычный результат функции: клиенту не придётся угадывать, произошёл ли сбой протокола.

В конце mcp.run() запускает сервер: по умолчанию FastMCP использует stdio (Quickstart). Через стандартный вывод идут протокольные сообщения, поэтому обычные print() для отладки туда добавлять нельзя. Диагностику направляйте в stderr, иначе клиент может перестать разбирать ответы.

Теперь сервер умеет объявлять инструмент, но нам ещё нужно увидеть его глазами клиента. Для этого сначала разберём два сообщения протокола, а затем напишем проверку.

Как клиент узнаёт об инструменте

При прямом вызове Python-кода обе стороны должны знать функцию find_runbook. MCP-клиенту достаточно знать адрес или способ запуска сервера. Он сначала узнаёт, какие инструменты тот предлагает, а потом выбирает один из них и передаёт аргументы.

В протоколе это операции tools/list и tools/call. Список содержит имя, описание и схему аргументов инструмента. Для find_runbook обязательный аргумент — service типа string. По этой схеме клиент понимает, какие данные отправить. Формат операций описан в спецификации MCP Tools.

В нашем примере проверим два разных способа соединения:

  • В одном процессе. Client(mcp) связывается непосредственно с объектом FastMCP в памяти. Мы проверим регистрацию и вызов инструмента без запуска дочернего процесса.
  • Через stdio. Клиент запускает server.py как отдельный процесс, передаёт ему протокольные сообщения через стандартный ввод и получает ответы через стандартный вывод. Этот вариант ближе к работе локального MCP-хоста.

MCP-клиент запускает FastMCP-сервер как дочерний процесс, обменивается через stdin и stdout сообщениями initialize, tools/list и tools/call, а сервер вызывает find_runbook; диагностические сообщения идут отдельно в stderr.

Одинаковый результат в этих проверках отвечает на разные вопросы: правильно ли работает инструмент и может ли клиент связаться с отдельным процессом. Подробности — в документации транспортов FastMCP. Начнём с первого вопроса.

Подключаем Python-клиента к MCP-серверу

Создайте рядом с server.py файл smoke.py:

import asyncio

from fastmcp import Client

from server import mcp


async def main() -> None:
    async with Client(mcp) as client:
        tools = await client.list_tools()
        names = sorted(tool.name for tool in tools)
        assert names == ["find_runbook"], names

        found = await client.call_tool("find_runbook", {"service": "api"})
        assert found.data.status == "ok"

        missing = await client.call_tool("find_runbook", {"service": "unknown"})
        assert missing.data.status == "not_found"

    print("tools: find_runbook")
    print("known: ok")
    print("unknown: not_found")


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

Здесь async with Client(mcp) открывает соединение с сервером в памяти. Через list_tools() мы узнаём зарегистрированные инструменты и убеждаемся, что среди них есть find_runbook. Затем дважды вызываем его: сначала с известным сервисом, потом с неизвестным. result.data содержит разобранный типизированный результат, поэтому к полям можно обращаться как к атрибутам. Работу методов подробнее объясняют FastMCP Client и Calling Tools.

В терминале запустите:

.venv/bin/python smoke.py

При успешном запуске вы увидите:

tools: find_runbook
known: ok
unknown: not_found

Первая строка означает, что клиент обнаружил инструмент. Две следующие — что он получил ожидаемые статусы для api и unknown. Если какое-либо условие assert нарушится, программа завершится с ошибкой. Так мы не примем неправильный результат за успешный.

Осталось проверить важную деталь: сможет ли клиент запустить тот же сервер как отдельный процесс и обменяться с ним MCP-сообщениями. Для этого используем stdio.

Проверяем соединение через stdio

Для проверки отдельного процесса создайте stdio_smoke.py рядом с двумя предыдущими файлами:

import asyncio
import sys
from pathlib import Path

from fastmcp import Client
from fastmcp.client.transports import StdioTransport


async def main() -> None:
    server_file = Path(__file__).resolve().with_name("server.py")
    transport = StdioTransport(command=sys.executable, args=[str(server_file)])

    async with Client(transport) as client:
        tools = await client.list_tools()
        assert [tool.name for tool in tools] == ["find_runbook"]

        result = await client.call_tool("find_runbook", {"service": "worker"})
        assert result.data.status == "ok"
        assert result.data.service == "worker"

    print("stdio: find_runbook ok")


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

Здесь StdioTransport запускает server.py с тем же Python-интерпретатором, что и клиент. Это важно: сервер и клиент используют одно установленное окружение FastMCP. Код запрашивает список инструментов, вызывает find_runbook для worker и проверяет ответ. Способ создания StdioTransport(command=..., args=...) описан в документации Client Transports.

Запустите:

.venv/bin/python stdio_smoke.py

При успешном запуске появится stdio: find_runbook ok. В отличие от предыдущей проверки, здесь клиент действительно запустил отдельный процесс и вызвал его инструмент. Если команда завершилась с ошибкой, проверьте сначала, что происходит при обычном вызове smoke.py: так проще отделить ошибку сервера от ошибки соединения.

Что проверить, если вызов не работает

Начинайте с того места, где результат отличается от ожидаемого:

  1. ModuleNotFoundError: fastmcp. Убедитесь, что команда выполняется через Python из созданного .venv, а пакет установлен именно туда.
  2. list_tools() вернул пустой список. Проверьте, что @mcp.tool стоит над функцией, а клиент импортирует правильный объект mcp.
  3. Не прошёл assert на result.data. Проверьте имя инструмента и аргумент service. Значение unknown должно вернуть not_found как обычный ответ нашей функции.
  4. Клиент stdio не получает ответ. Проверьте путь к server.py и путь к интерпретатору, а также отсутствие обычных print() в протокольном stdout. Сообщения об ошибках смотрите в stderr.
  5. Сервер запущен вручную, но ничего не напечатал. Сам .venv/bin/python server.py запускает процесс, ожидающий MCP-сообщения. Используйте stdio_smoke.py для реального обмена, а не ожидайте текстового приветствия.

После исправления повторите smoke.py, затем stdio_smoke.py. Первый тест подтверждает инструмент, второй — соединение через отдельный процесс.

Результат и дальнейшие шаги

Теперь у вас есть MCP-сервер с одним инструментом, а рядом — два клиента для проверки. smoke.py подтверждает, что инструмент зарегистрирован и возвращает предусмотренные ответы. stdio_smoke.py добавляет проверку запуска отдельного процесса и обмена MCP-сообщениями. Это полезное различие: если первая проверка проходит, а вторая нет, искать причину стоит в запуске процесса или соединении.

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

Попробуйте добавить в словарь ещё один сервис и вызвать его из обоих клиентов. Когда новый результат появится и в проверке через stdio, можно переходить к собственному источнику данных — API или базе. Принцип останется тем же: объявить инструмент, проверить аргументы, выполнить вызов и убедиться, что клиент получил ожидаемый результат.

Источники