Гайды

Как написать свой MCP-сервер

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

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

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

#Что понадобится

  • Python 3.10 или новее (подойдёт любой язык, умеющий читать stdin и писать stdout; примеры ниже на Python).
  • Клиент, понимающий MCP по стандартным потокам. Проверялось на реализации, которая шлёт initialize, затем notifications/initialized, затем tools/list и tools/call, с версией протокола 2024-11-05.
  • Понимание того, что сервер получает права запустившего его пользователя. Никакой песочницы протокол не даёт.

Внешние библиотеки не нужны. Официальные SDK существуют и экономят время на больших серверах, но их API меняется между версиями, а сырой протокол — нет; для первого сервера прозрачность важнее удобства.

#Шаг 1. Понять формат обмена

Обмен — это JSON-RPC 2.0, по одному JSON-объекту на строку. Клиент пишет в stdin вашего процесса, читает из stdout. Всё, что вы напечатаете в stdout мимо протокола, сломает разбор — отладочные сообщения выводите только в stderr.

Порядок сообщений при подключении:

  1. Клиент → initialize с версией протокола. Вы отвечаете своей версией и списком возможностей.
  2. Клиент → notifications/initialized. Это уведомление, у него нет id, отвечать не нужно и нельзя.
  3. Клиент → tools/list. Вы отвечаете списком инструментов.
  4. Далее в любой момент → tools/call с именем и аргументами.

Правило, которое экономит час отладки: если у входящего сообщения нет поля id — это уведомление, ответ на него не отправляется. Ответ на уведомление приводит к тому, что клиент получает сообщение с id: null и либо игнорирует его, либо ломается.

#Шаг 2. Минимальный работающий сервер

Полный текст, ничего не сокращено. Сервер предоставляет один инструмент — счётчик слов.

#!/usr/bin/env python3
import json
import sys

TOOLS = [
    {
        "name": "word_count",
        "description": (
            "Посчитать количество слов в тексте. "
            "Возвращает одно число. Применяй, когда спрашивают объём текста."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": 'Текст целиком, например: "привет мир"',
                }
            },
            "required": ["text"],
        },
    }
]


def send(payload):
    sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
    sys.stdout.flush()


def reply(rid, result):
    send({"jsonrpc": "2.0", "id": rid, "result": result})


def fail(rid, code, message):
    send({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}})


def main():
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
        except json.JSONDecodeError:
            continue
        rid = msg.get("id")
        if rid is None:
            continue
        method = msg.get("method")
        if method == "initialize":
            reply(rid, {
                "protocolVersion": "2024-11-05",
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "wordcount", "version": "1.0"},
            })
        elif method == "tools/list":
            reply(rid, {"tools": TOOLS})
        elif method == "tools/call":
            params = msg.get("params") or {}
            if params.get("name") != "word_count":
                fail(rid, -32602, "нет такого инструмента: " + str(params.get("name")))
                continue
            text = str((params.get("arguments") or {}).get("text", ""))
            reply(rid, {"content": [{"type": "text", "text": str(len(text.split()))}]})
        else:
            fail(rid, -32601, "метод не поддерживается: " + str(method))


if __name__ == "__main__":
    main()

Сохраните как wordcount_server.py. Команда запуска для клиента — python wordcount_server.py. Обратите внимание: многие реализации проверяют первое слово команды по белому списку запускателей, поэтому python, node, npx, uvx пройдут, а прямой путь к произвольному бинарнику — нет.

#Шаг 3. Шесть правил описания инструмента

Здесь начинается настоящая работа. Модель видит только name, description и inputSchema — ничего больше.

  1. Имя — глагол и объект, без синонимов у соседей. word_count и count_words в одном сервере — гарантированная путаница. Одно действие — одно имя.
  2. Первое предложение описания отвечает на «что вернётся». Не «работает с текстом», а «возвращает одно число». Модель планирует следующий шаг по типу результата.
  3. Второе предложение отвечает на «когда применять». Явный триггер: «применяй, когда спрашивают объём текста». Без этого модель либо не зовёт инструмент никогда, либо зовёт его на каждый запрос.
  4. У каждого параметра — пример значения прямо в описании. Не тип, а именно пример. Компактная модель копирует формат из примера гораздо надёжнее, чем выводит его из слова string. Это правило даёт наибольший прирост качества на локальных моделях.
  5. Обязательные параметры перечислены в required. Иначе модель пропустит параметр, а вы получите вызов без него и будете гадать, чья это ошибка.
  6. Опишите поведение при ошибке и верните её текстом. "[error] файл не найден: notes.md" — полезное наблюдение, из которого модель исправит путь. Упавший процесс — конец хода. Механика того, почему ошибка обязана быть текстом, разобрана в статье про function calling и точку ответственности.

Отдельно к правилу 1: если инструментов больше десятка, посмотрите, не распадаются ли они на два сервера. Длинный список описаний одинаково плохо влияет и на контекст, и на точность выбора.

#Шаг 4. Проверка результата

Не подключайте сервер к ассистенту, пока не проверили его руками. Проверка занимает минуту.

Отправьте серверу три сообщения подряд, каждое отдельной строкой:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"word_count","arguments":{"text":"раз два три"}}}

Проще всего сложить их в файл probe.jsonl и выполнить:

python wordcount_server.py < probe.jsonl

Успех выглядит так: три строки ответа, у каждой поле id, совпадающее с запросом, и ни одной посторонней строки в выводе. В третьем ответе внутри content должно быть "text": "3".

Типовые провалы этой проверки:

  • вывелось меньше трёх строк — сервер упал, смотрите stderr;
  • вывелось что-то помимо JSON — уберите print из кода;
  • ответы есть, но id не совпадают — вы отвечаете не на тот запрос;
  • всё зависло — вы забыли flush() после записи.

Только после того как эти три строки прошли, подключайте сервер к ассистенту и задайте запрос, где инструмент очевидно нужен. Затем откройте лог вызовов и убедитесь, что вызов был и аргументы правильные: как читать такой лог — в материале про трассировку агента.

#Что может пойти не так

Сервер отвечает на уведомления. Самая частая ошибка. Проверка if rid is None: continue в примере существует именно для этого.

Буферизация вывода. Без flush() клиент ждёт ответа, которого физически ещё нет в трубе, и упирается в таймаут. Симптом — всё работает при запуске из файла и висит при подключении к клиенту.

Отладочный вывод в stdout. Одна забытая строка print("ok") ломает разбор всего потока. Отладка — только в stderr.

Модель не вызывает инструмент. Дело почти всегда в описании: нет триггера применения либо имя похоже на соседнее. Проверяется тем же способом — запрос, где инструмент нужен очевидно, и взгляд в лог.

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

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

#Когда свой сервер писать не надо

  • Действие разовое. Один запуск руками дешевле часа разработки и вечной поддержки.
  • Есть готовый сервер той же категории. Два похожих инструмента в списке хуже одного: модель будет выбирать между ними наугад.
  • И клиент, и инструмент — ваш код. Прямой вызов функции в том же процессе проще, быстрее и отлаживается тривиально. Протокол нужен, когда стороны разделены по времени жизни или по авторству; подробнее — в разборе задачи, которую решает MCP.
  • Действие необратимо, а подтверждения нет. Сначала подтверждение, потом инструмент, а не наоборот.

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

#Обязательно ли использовать официальный SDK?

Нет. Протокол — это JSON-RPC 2.0 по строкам, его можно реализовать на любом языке за несколько десятков строк, как в примере выше. SDK экономит время на больших серверах с ресурсами и подпиской на изменения, но добавляет зависимость, API которой меняется между версиями.

#Как сервер узнаёт, от какого пользователя пришёл вызов?

По стандартным потокам — никак. Сервер запущен клиентом и работает с правами запустившего процесса; понятия «пользователь запроса» в этом транспорте нет. Если вам нужно разграничение, его придётся строить самому: отдельный процесс на пользователя либо явный параметр в аргументах, которому вы всё равно не можете доверять полностью.

#Можно ли отдавать из инструмента картинки и файлы?

В ответе tools/call поле content — это список элементов с типами, среди которых есть текст, изображение и ссылка на ресурс. Практическое ограничение приходит со стороны клиента и модели: не всякий клиент отрисует картинку, и не всякая модель её увидит. Начинайте с текста.

#Сколько инструментов класть в один сервер?

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

#Как проверить, что сервер не подвиснет?

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

#Нужно ли поддерживать resources и prompts?

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

#Вывод

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

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

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт