MAATRIX / Блог / MCP-сервер своими руками на Python

MCP-сервер своими руками на Python

MCP-сервер своими руками на Python

MAATRIX

Про MCP уже написано много объяснений «что это и зачем», а вот до конкретного «как написать свой сервер на Python» руки доходят реже — документация официального SDK разрослась, и непонятно, с какого конца заходить. На деле минимальный рабочий MCP-сервер — это меньше тридцати строк кода. Дальше разберём, из чего он состоит, напишем один настоящий инструмент и проверим, что всё работает, прежде чем тащить это на сервер или подключать к Claude Desktop.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Что понадобится и с чего начать

Нужен Python 3.10 или новее и официальный пакет mcp от Anthropic — он и есть SDK, вокруг которого построена вся статья. Ставить можно классическим pip, но для новых Python-проектов удобнее uv: он сам создаёт виртуальное окружение и решает версии зависимостей.

curl -LsSf https://astral.sh/uv/install.sh | sh
mkdir mcp-demo && cd mcp-demo
uv init
uv add "mcp[cli]"

Хвост [cli] важен: без него ставится только сама библиотека протокола, а вам ещё понадобится консольная утилита mcp — она пригодится для локальной проверки сервера без подключения к Claude Desktop. Если предпочитаете обычный pip и venv:

python3 -m venv .venv
source .venv/bin/activate
pip install "mcp[cli]"

Для минимального примера хватит одного файла — server.py в корне проекта. Отдельный pyproject.toml можно не заводить, если через uv init он уже создался.

Из чего состоит MCP-сервер

Если отвлечься от конкретных названий функций, любой MCP-сервер на Python устроен из трёх частей, и это верно и для официального SDK, и для реализаций на других языках.

Объект сервера — точка, к которой цепляются все инструменты, ресурсы и промпты. У него есть имя, по которому сервер представляется клиенту при подключении.

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

Точка входа — код, который поднимает транспорт (то, как сервер общается с клиентом — через стандартный ввод-вывод или по сети) и запускает цикл обработки запросов.

Официальный Python SDK даёт два способа собрать эти три части. Низкоуровневый API — вы вручную регистрируете обработчик «дай список инструментов» и обработчик «вызови инструмент по имени», сами описываете JSON-схему параметров и сами поднимаете транспорт. Это гибко, но многословно. Для подавляющего большинства задач в SDK есть FastMCP — обёртка, где инструмент объявляется одним декоратором над обычной Python-функцией: имя параметра и его аннотация типа становятся схемой, а строка документации — описанием инструмента для модели. Ниже используем именно её — по объёму кода разница в разы, а результат по протоколу неотличим.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Первый инструмент: читаем файл

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

# server.py
from pathlib import Path
from mcp.server.fastmcp import FastMCP

NOTES_DIR = Path(__file__).parent / "notes"

mcp = FastMCP("notes-demo")

@mcp.tool()
def read_note(filename: str) -> str:
    """Читает текстовый файл из папки notes и возвращает его содержимое.

    filename: имя файла без пути, например "todo.txt".
    """
    path = (NOTES_DIR / filename).resolve()

    if NOTES_DIR.resolve() not in path.parents and path != NOTES_DIR.resolve():
        raise ValueError("Доступ разрешён только к файлам внутри папки notes")

    if not path.is_file():
        raise FileNotFoundError(f"Файл {filename} не найден")

    return path.read_text(encoding="utf-8")

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

Три вещи здесь важнее самого кода. Во-первых, @mcp.tool() берёт имя функции, аннотации типов параметров и докстринг и на их основе сам собирает описание инструмента, которое видит модель — вам не нужно отдельно писать JSON-схему руками. Во-вторых, докстринг — не формальность: это единственное, по чему модель понимает, что делает инструмент и как его вызывать, поэтому пишите его так же внятно, как объясняли бы задачу коллеге. В-третьих, проверка path.resolve() внутри NOTES_DIR — не паранойя, а необходимость: без неё модель (или что-то, подсунувшее ей нужный промпт) может запросом вроде ../../etc/passwd прочитать файл далеко за пределами папки notes. Любой инструмент, работающий с файловой системой, обязан ограничивать доступ явно.

Исключения ValueError и FileNotFoundError SDK сам превращает в понятный модели ответ об ошибке — оборачивать их вручную в try/except не нужно, если вас устраивает стандартное сообщение.

Второй инструмент: обращаемся к своему API

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

import httpx

@mcp.tool()
async def get_server_status(hostname: str) -> str:
    """Запрашивает статус сервера через внутренний API мониторинга.

    hostname: доменное имя или адрес сервера, статус которого нужно узнать.
    """
    async with httpx.AsyncClient(timeout=5.0) as client:
        response = await client.get(
            "https://api.example.internal/status",
            params={"host": hostname},
        )
        response.raise_for_status()
        data = response.json()

    return f"{hostname}: {data['state']}, аптайм {data['uptime_hours']} ч."

httpx нужна установка: uv add httpx.) Обратите внимание на timeout — без него зависший запрос к API подвесит и весь вызов инструмента, а вместе с ним — ответ модели пользователю. response.raise_for_status() превратит ошибку HTTP в исключение, которое SDK так же аккуратно доведёт до клиента, вместо того чтобы молча вернуть модели пустой или обрезанный ответ.

Если инструментов становится несколько, держите их в одном файле только для совсем маленького сервера — начиная с трёх-четырёх стоит выносить в отдельные модули и импортировать в server.py, чтобы декоратор @mcp.tool() всё равно применялся к объекту mcp из одного места.

Локальный запуск и проверка без подключения к клиенту

Прежде чем тянуть сервер в Claude Desktop, его стоит проверить в изоляции — это экономит время на отладке, потому что ошибки видно сразу, без слоя «а вдруг дело в настройках клиента». Для этого в пакете mcp[cli] есть команда dev, которая поднимает сервер и веб-интерфейс инспектора рядом с ним:

uv run mcp dev server.py

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

Здесь же стоит один нюанс, который ломает сервер незаметно: при транспорте по умолчанию (stdio) стандартный вывод процесса — это канал протокола, а не место для логов. Случайный print() внутри инструмента разъезжает JSON-RPC-сообщение, и клиент падает с ошибкой парсинга, которую по тексту не связать с истинной причиной. Если нужна диагностика — пишите в stderr:

import sys
print("отладочное сообщение", file=sys.stderr)

или настройте штатный модуль logging — он по умолчанию тоже пишет в stderr, а не в stdout.

Подключение к Claude Desktop и что дальше

Сервер, который проходит проверку в инспекторе, готов к подключению — но сама процедура регистрации в конфиге Claude Desktop, с путями и форматом JSON, подробно разобрана отдельно, повторять её здесь смысла нет: смотрите статью как подключить MCP-сервер к Claude Desktop. Если в процессе что-то пойдёт не так — сервер не появляется в списке, падает при первом вызове инструмента, — есть отдельный разбор частых ошибок MCP-сервера с типичными причинами и решениями. А если пока не до конца ясна сама идея протокола — с чего лучше начать, что такое хост, клиент и сервер в этой связке, — короткое объяснение без жаргона есть в статье что такое MCP простыми словами.

Локальный stdio-сервер, который мы написали, отлично работает, пока он нужен только вам на своей машине. Как только требуется, чтобы к серверу подключался кто-то ещё — второй человек, веб-клиент, CI-пайплайн — потребуется уже сетевой транспорт вместо stdio и отдельная машина, на которой сервер будет постоянно работать, а не запускаться клиентом по требованию. Это отдельная задача с собственными нюансами — портом, прокси и авторизацией, — и она разобрана в статье как поднять MCP-сервер на VPS. Для локальной разработки и проверки идеи всё, что описано выше, — этого достаточно, дополнительный сервер не нужен.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Частые вопросы

Обязательно ли использовать FastMCP, или можно писать на низкоуровневом API?

Не обязательно — низкоуровневый API даёт больше контроля (например, свою логику формирования схемы параметров или нестандартную обработку списка инструментов), но для подавляющего большинства серверов с несколькими инструментами FastMCP закрывает задачу в разы меньшим объёмом кода. Начинать стоит с неё, а к низкоуровневому API переходить только если упёрлись в конкретное ограничение.

Нужно ли самому валидировать типы входных параметров?

Базовая проверка типов (что параметр — строка, число, булево значение и так далее) делается автоматически на основе аннотаций типов в сигнатуре функции. А вот содержательная проверка — как в примере с чтением файла, где нужно убедиться, что путь не выходит за пределы разрешённой папки, — остаётся на вас; SDK не знает бизнес-логику вашего инструмента.

Как вернуть модели не текст, а структурированные данные?

Функция-инструмент может вернуть не только строку — SDK поддерживает и другие типы содержимого ответа. Для большинства практических случаев, впрочем, достаточно вернуть аккуратно отформатированный текст или JSON-строку: модель разбирает и то, и другое одинаково хорошо.

Что делать, если инструмент должен выполняться долго?

Пишите его как асинхронную функцию и не блокируйте цикл событий синхронными операциями внутри — синхронный requests.get() вместо httpx.AsyncClient в асинхронном инструменте застопорит обработку остальных запросов, пока не выполнится текущий.

Можно ли в одном сервере совмещать инструменты, ресурсы и промпты?

Да, это обычная практика: @mcp.tool() для действий, @mcp.resource() для данных на чтение, @mcp.prompt() для готовых шаблонов запросов — все три вида регистрируются декораторами на одном и том же объекте FastMCP и живут в одном файле или пакете.

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.