В августе я выложил fault-mcp, сервер MCP, который нарочно ломает собственные инструменты. Один из его 6 сбоев называется schema: инструмент отвечает данными, которые не совпадают с объявленной формой. Например, число приходит строкой или пропадает обязательное поле. В README про это есть пояснение к решению:
A client that validates structured output rejects a
schemafault at the transport boundary, so the agent never sees it and you end up measuring the client instead of the agent.
То есть клиент, который проверяет структурированный ответ, отбросит такой сбой на границе транспорта, агент его не увидит и замер получится про клиента, а не про агента. Поэтому сервер по умолчанию прячет outputSchema, а флаг --advertise-output-schema её публикует. В репозитории есть тест на то, что схема скрыта или видна в списке. Теста, который проводит сбой схемы через клиента с опубликованной схемой, нет. Через агента, которым я пользуюсь каждый день, я этот сбой тоже не прогонял. Фраза называет ловушку и не говорит, какие клиенты в неё попадают, а я принял её на веру, потому что звучала она убедительно.
Стенд
fault-mcp 0.1.0 из npm, инструмент get_account_balance, счёт ACC-4471. Чистый ответ 4 071 877 центов, то есть $40 718,77, в structuredContent и одной строкой текста. Я написал 9 профилей с вероятностью 1: чистый, error, empty, truncate, 2 стратегии порчи и 3 режима схемы. latency я не брал, он отдаёт верный ответ, просто поздно. Каждый профиль шёл со скрытой схемой и с опубликованной.
На уровне SDK было 3 клиентские библиотеки в 4 вариантах. Сырой JSON-RPC по stdio без всякого SDK показывает, что сервер положил в канал. Client из пакета TypeScript @modelcontextprotocol/sdk 1.32.1, один раз с прямым callTool и один раз после listTools(). И ClientSession из пакета Python mcp 2.3.0. Потом агент: Claude Code 2.1.292 с claude-opus-5, без встроенных инструментов, только с этим одним сервером MCP, запущенный из свежей папки в /tmp, где лежал только конфиг MCP. Его потоковый лог записывает результат инструмента в том виде, в каком Claude Code передал его модели. Сервер и клиенты SDK работали на Node 22.23.1.
Что сделали клиенты SDK
Со скрытой схемой все 5 клиентов, включая Claude Code, вернули то, что прислал сервер. Исключение одно, wrong-container, про него отдельный раздел ниже. С опубликованной схемой клиенты расходятся.
Клиент TypeScript после listTools и клиент Python бросили исключение на 4 сбоях из 8. 2 из них это сбои схемы, о которых говорит README. Другие 2 меня удивили: empty и truncate. Оба убирают structuredContent, а инструмент с выходной схемой обязан его вернуть. Оба клиента бросают исключение с текстом “has an output schema but did not return structured content”. Так truncate, который задуман тихим сбоем, становится исключением раньше, чем его кто-то прочитает.
Оба вида порчи прошли через всех клиентов в обоих режимах. Правдоподобная порча возвращает строку соседнего счёта под запрошенным номером: $9 381,67 с правильными типами и полями, но от другого счёта. Валидатор проверяет только форму, а форма была в порядке.
Клиент TypeScript без listTools ничего не сверял с выходной схемой. Я заглянул в код SDK. Валидаторы строятся в cacheToolMetadata, а он вызывается только после ответа на listTools(). callTool сам по себе валидатора не находит и отдаёт результат как есть. Python, если имя инструмента ему незнакомо, сам запрашивает список, поэтому проверяет в любом случае. Тестовая обвязка, которая зовёт инструмент по имени без списка, получает от строгого SDK нестрогое поведение.
Строка, которую никто не получил
wrong-container кладёт массив на место объекта. До клиентов он не дошёл ни разу, ни со схемой, ни без. Сырой JSON-RPC получил ошибку -32602 “Invalid tools/call result” от самого сервера.
faultmcp построен на низкоуровневом Server из SDK. В README даже объяснено почему: инъектор сбоев должен уметь возвращать то, что строгий тип результата не пропустит. Но этот Server оборачивает каждый обработчик tools/call и перед отправкой разбирает результат через CallToolResultSchema. Приведение типа в TypeScript успокаивает только компилятор. Проверка во время выполнения в Server всё равно работает. Я проверил 1.30.0, самую старую версию, которую допускает package.json у faultmcp. Там происходит то же самое.
Выходит, один из моих 3 режимов схемы не работал через транспорт ни на одной проверенной версии. Его модульный тест зовёт applyFault напрямую и проходит. У меня было 85 зелёных тестов, но ни один не отправил этот сбой через канал.
Что сделал агент
Больше всего меня интересовал Claude Code, потому что это агент, которым я пользуюсь. Я поставил между Claude Code и сервером маленький перехватчик в духе tee, чтобы видеть, что идёт по каналу. В ответе tools/list была полная outputSchema, где balance_minor объявлен целым числом и все 4 поля обязательны.
Потом сбой type-swap прислал "balance_minor": "4071877" строкой. Claude Code передал это модели без единого замечания. drop-required убрал account_id. Это тоже прошло. Со скрытой схемой было то же самое. Claude Code не проверил структурированный ответ ни в одном моём прогоне.
Модель ответила $40 718,77 во всех 6 прогонах с этими 2 сбоями. Ни про пропавший номер счёта, ни про число в кавычках она не сказала ни разу. Во всех прогонах, где прозвучала сумма, модель предупредила, что дате as_of уже несколько месяцев, но это просто дата в моём выдуманном реестре, а не сбой. На empty, truncate, error и wrong-container она каждый раз отвечала, что баланса нет. Правдоподобная порча оба раза прошла как $9 381,67.
Ещё одного различия между клиентами я не ждал. Если structuredContent есть, Claude Code отдаёт модели его JSON, а не текстовый блок. Обычная порча переписывает текст и структурированную часть по отдельности, поэтому в тексте стоял ACC-4476, а в структурированной части ACC-9471. Модель видела ACC-9471 и оба раза заметила чужой счёт. Обвязка, которая читает content[0].text, получила бы верные $40 718,77 под ACC-4476, а модель в Claude Code получила ACC-9471 с $43 035,36.
Что же измеряет флаг
README прав в том, что строгий клиент может съесть сбой раньше, чем его увидит агент. Но протокол этого не требует. Версия спецификации, которую Claude Code согласовал с сервером, 2025-11-25, говорит, что клиенты SHOULD проверять структурированные результаты, а не MUST. Официальный TypeScript SDK строг только после listTools, Python строг всегда, Claude Code не строг вовсе. С моим сервером опубликованная схема поменяла 4 результата из 9 у 2 клиентов и 0 из 9 у агента.
По умолчанию схема остаётся скрытой, потому что со скрытой схемой все проверенные клиенты получили одно и то же. Но в объяснении в README надо назвать, какие клиенты проверяют. Ещё там надо сказать, что empty и truncate тоже превращаются в исключения. wrong-container надо починить или убрать, потому что сейчас он тестирует SDK сервера. Ничего из этого я пока не запушил.
Чего я не проверял
Агента я запускал по одному разу на ячейку и ещё 2 раза с перехватчиком. Так что числа про модель это подсчёт на горстке прогонов, а не частоты. Модель была одна, claude-opus-5. Cursor, Codex CLI и других агентов я не пробовал, а они могут проверять. Оба SDK были последними релизами на тот день. Ветку 2.x клиента TypeScript, @modelcontextprotocol/client 2.3.1, я проверил только на 3 сбоях. Она вела себя как 1.32.1 и была строгой только после listTools. Транспорт был только stdio, HTTP я не трогал.