JSON-RPC и обнаружение сервера
Корпус определён. Теперь добавим server.py, который получает JSON-RPC через стандартный ввод и отвечает в стандартный вывод. Первый полезный метод — server/discover: клиент узнаёт версию и возможности нашего сервера. В этой серии используется редакция MCP 2026-07-28. Запросы несут собственные метаданные; прежняя последовательность initialize и протокольные сессии к выбранному профилю не относятся.
Сервер является авторским учебным примером на Python, а не готовым универсальным SDK. Он обслуживает короткие синхронные запросы и не реализует подписки, sampling или старых клиентов. Источники не запускались при подготовке. Файл request.json содержит одну строку, которую читатель сможет самостоятельно передать программе позднее. Мы рассмотрим ожидаемую форму обмена без изображения полученного запуска или подключённого AI-приложения.
Одна строка и одно сообщение
Транспорт stdio связывает клиент с дочерним процессом сервера. Входной поток содержит JSON-сообщения, разделённые переводом строки; выходной поток содержит только сообщения протокола. Наш цикл читает строку, разбирает JSON, вызывает обработчик и записывает ответ. Текст документа может содержать переводы строк, но внутри сериализованной JSON-строки они экранируются. Поэтому один ответ остаётся одной физической строкой канала.
Диагностика отправляется в stderr. Если записать «сервер запущен» в stdout, клиент попытается разобрать эту фразу как JSON и получит ошибку до полезного ответа. Это частая граница при переходе от обычной консольной программы к протокольному процессу. В нашем коде emit отвечает за сообщения, а logging — за отдельный диагностический канал. Признак ошибки определяется формой ответа, а не самим фактом записи чего-либо в stderr.
Строка ограничена по размеру. Если вход превышает предел, цикл дочитывает её ограниченными порциями и возвращает отказ, не разбирая огромный объект. Это защищает учебный обработчик от случайного большого запроса и сохраняет границу следующих сообщений. Но предел строки не решает вопрос скорости источников или всей памяти процесса. Мы используем маленький корпус, отдельный лимит размера документов и синхронные операции; более сложный сервер потребует самостоятельного анализа ресурсов.
Структура запроса
Сообщение содержит jsonrpc, id, method и params. Идентификатор связывает ответ с запросом; это не имя пользователя и не ключ права доступа. В нашем примере id равен единице. Метод называется server/discover. В params._meta записаны версия 2026-07-28, возможности клиента и необязательные сведения о его имени. На следующих вызовах метаданные передаются снова, а не наследуются от обнаружения.
Сервер проверяет форму объекта, тип идентификатора и обязательные метаданные. Обычный int либо строка допустимы, null и boolean в нашем профиле отклоняются. Неизвестная версия получает специальную ошибку с перечнем поддерживаемых версий. Это помогает клиенту отличить несовместимость от произвольной поломки программы. Однако наш сервер поддерживает только одну редакцию и не выполняет автоматический переход к legacy-семантике. Старому клиенту потребуется другая реализация или адаптация.
Сведения clientInfo полезны для диагностики совместимости, но не доказывают личность. Клиент способен назвать себя любым текстом. Поэтому нельзя выдавать editor-доступ только из-за имени приложения в метаданных. В этом состоянии роль reader фиксирована сервером. Позднее доверенная конфигурация процесса определит роль, а содержимое запроса не сможет её переключить. Разделение версии и доступа важно с самого первого сообщения.
Ответ обнаружения
server/discover возвращает supportedVersions, capabilities, сведения сервера и resultType: complete. На втором шаге возможности пусты: инструменты ещё не опубликованы. Это честное описание состояния. Не следует объявлять resources или tools «на будущее», если соответствующие методы недоступны. Клиент использует объявление, чтобы выбрать возможные действия, а не чтобы прочитать список планов автора серии.
В инструкции сервера записано, что он работает с авторским корпусом и не публикует материалы. Эта строка помогает приложению понять назначение подключения, но не заменяет проверки аргументов и доступа. Сервер также возвращает нулевой срок кэширования, чтобы учебный клиент не предполагал длительное сохранение списка возможностей. Когда состояние изменяется между уроками, новое обнаружение должно показать новые методы, а не прежнюю запись из кэша.
Ожидаемый ответ сохраняет id запроса и содержит результат с одной поддерживаемой версией. Если метаданные отсутствуют, вместо него ожидается ошибка параметров. Если method неизвестен, ожидается отказ метода. Эти сценарии объясняются по коду; они не выполнялись при подготовке. Не добавляйте в документацию выдуманный вывод терминала. Читатель сможет получить собственное наблюдение позже и связать его с выбранной папкой состояния.
Вход без id является уведомлением, на которое не отправляют ответ. Наш профиль не использует длительные операции, поэтому отмена не требует сложной диспетчеризации активных задач. Это конкретная граница: синхронный цикл не может прервать чтение большого удалённого сервиса очередным сообщением из stdin. Мы не добавляем такие операции в лабораторию. Если они понадобятся, понадобится другой жизненный цикл обработки, а не только новое имя метода.
Обнаружение с другой версией
Поменяем мысленно в запросе дату версии на неизвестную. Сервер должен вернуть перечень поддерживаемых редакций, а не молча обработать сообщение как текущую. Иначе клиент не будет знать, по каким правилам разобран ответ. Такое различие особенно важно после изменения протокольной формы: привычный метод может остаться, а дополнительные поля или последовательность уже отличаться. Явный отказ лучше неоднозначной успешности.
Теперь удалим clientCapabilities, оставив правильную дату. Это не вопрос несовместимости версии, а нарушение формы метаданных. Клиенту следует восстановить обязательное поле, а не искать старый handshake. Наш сервер не запоминает capabilities после обнаружения и не подставляет их из прежнего сообщения. Каждое действие несёт свою основу. Эти два мысленных изменения помогают увидеть, почему версия и полноценность запроса проверяются независимо.
Ни один вариант не выполнялся при подготовке. request.json сохраняет корректную авторскую форму, а альтернативы объясняют границы. При самостоятельном наблюдении меняйте одну причину за раз и сохраняйте запрос рядом с ответом. Тогда отказ будет относиться к понятному входу, а не к неопределённому «подключение не получилось».
После завершения входного потока сервер выходит из цикла. Такой способ даёт будущему клиенту понятное завершение дочернего процесса. Следующий урок добавит объявление инструмента и фактическую проверку аргументов. Обнаружение будет сообщать возможность tools, а клиент сможет узнать конкретную операцию поиска. Протокольная форма останется прежней, поэтому новый результат будет развиваться на той же основе.
Полный файл request.json из состояния lesson-02. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.
{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "PWReader", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {}}}}
Фрагмент файла server.py из состояния lesson-02. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.
def respond(message):
rid = None
try:
if not isinstance(message,dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"),str):
raise RpcError(-32600,"Invalid request")
if "id" not in message:
if message["method"] == "notifications/cancelled": return None
return None # Never reply to a notification.
rid=message["id"]
if type(rid) not in (int,str): rid=None; raise RpcError(-32600,"Invalid request id")
params=message.get("params",{})
if not isinstance(params,dict): raise RpcError(-32602,"Invalid params")
meta=params.get("_meta",{})
if not isinstance(meta,dict) or not isinstance(meta.get(PREFIX+"protocolVersion"),str) or not isinstance(meta.get(PREFIX+"clientCapabilities"),dict):
raise RpcError(-32602,"Required request metadata missing")
if meta[PREFIX+"protocolVersion"] != VERSION:
raise RpcError(-32022,"Unsupported protocol version",{"supported":[VERSION],"requested":meta[PREFIX+"protocolVersion"]})
return {"jsonrpc":"2.0","id":rid,"result":dispatch(message["method"],params)}
except RpcError as exc:
error={"code":exc.code,"message":exc.message}
if exc.data is not None: error["data"]=exc.data
return {"jsonrpc":"2.0",**({"id":rid} if rid is not None else {}),"error":error}
except Exception:
logging.error("internal failure; request content and secrets omitted")
return {"jsonrpc":"2.0",**({"id":rid} if rid is not None else {}),"error":{"code":-32603,"message":"Internal error"}}
def emit(value):
if value is not None:
sys.stdout.write(json.dumps(value,ensure_ascii=False)+"\n")
sys.stdout.flush()
if __name__ == "__main__":
while True:
raw=sys.stdin.buffer.readline(MAX_LINE+1)
if not raw: break
if len(raw)>MAX_LINE:
while raw and not raw.endswith(b"\n"): raw=sys.stdin.buffer.readline(MAX_LINE+1)
emit({"jsonrpc":"2.0","error":{"code":-32600,"message":"Request too large"}})
continue
try: message=json.loads(raw)
except (ValueError,UnicodeError):
emit({"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}})
continue
emit(respond(message))
Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.
Оглавление серии · Полные исходники
Первичные документы: Источник 1, Источник 2.