Свой 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.
Порядок сообщений при подключении:
- Клиент →
initializeс версией протокола. Вы отвечаете своей версией и списком возможностей. - Клиент →
notifications/initialized. Это уведомление, у него нетid, отвечать не нужно и нельзя. - Клиент →
tools/list. Вы отвечаете списком инструментов. - Далее в любой момент →
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 — ничего больше.
- Имя — глагол и объект, без синонимов у соседей.
word_countиcount_wordsв одном сервере — гарантированная путаница. Одно действие — одно имя. - Первое предложение описания отвечает на «что вернётся». Не «работает с текстом», а «возвращает одно число». Модель планирует следующий шаг по типу результата.
- Второе предложение отвечает на «когда применять». Явный триггер: «применяй, когда спрашивают объём текста». Без этого модель либо не зовёт инструмент никогда, либо зовёт его на каждый запрос.
- У каждого параметра — пример значения прямо в описании. Не тип, а именно пример. Компактная модель копирует формат из примера гораздо надёжнее, чем выводит его из слова
string. Это правило даёт наибольший прирост качества на локальных моделях. - Обязательные параметры перечислены в
required. Иначе модель пропустит параметр, а вы получите вызов без него и будете гадать, чья это ошибка. - Опишите поведение при ошибке и верните её текстом.
"[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?
Не для первого сервера. Инструментов достаточно для подавляющего большинства задач, а ресурсы и заготовки запросов подключаются иначе и решают другие задачи — их стоит добавлять, когда появилась конкретная потребность.
#Вывод
Транспорт занимает час и больше не меняется; описания инструментов вы будете переписывать несколько раз, и именно они определяют, работает ваш сервер или лежит без дела. Начните с одного инструмента, добейтесь трёх правильных строк в ручной проверке, и только потом подключайте к ассистенту.
Если действие разовое или уже покрыто готовым сервером — своего не пишите. Если пишете — закладывайте таймаут и ограничение каталогов внутри самого сервера, не рассчитывая на клиента.