dimhold.by
← Артыкулы

Мой інжэктар збояў хавае outputSchema ад строгіх кліентаў. Claude Code 2.1.292 яе не правяраў зусім

У жніўні я выклаў fault-mcp, сервер MCP, які наўмысна ламае ўласныя інструменты. Адзін з яго 6 збояў называецца schema: інструмент адказвае данымі, якія не супадаюць з абвешчанай формай. Напрыклад, лік прыходзіць радком або знікае абавязковае поле. У README ёсць нататка пра гэтае рашэнне:

A client that validates structured output rejects a schema fault 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, пра яго асобны раздзел ніжэй. З апублікаванай схемай карціна разыходзіцца.

схема схавана схема апублікавана усе 5 сыры TS TS+list Python Claude чысты прайшоў прайшоў прайшоў прайшоў прайшоў прайшоў error прайшоў прайшоў прайшоў прайшоў прайшоў прайшоў empty прайшоў прайшоў прайшоў кінуў кінуў прайшоў truncate прайшоў прайшоў прайшоў кінуў кінуў прайшоў псаванне, праўдападобнае прайшоў прайшоў прайшоў прайшоў прайшоў прайшоў псаванне, звычайнае прайшоў прайшоў прайшоў прайшоў прайшоў прайшоў schema, type-swap прайшоў прайшоў прайшоў кінуў кінуў прайшоў schema, drop-required прайшоў прайшоў прайшоў кінуў кінуў прайшоў schema, wrong-container сервер сервер сервер сервер сервер сервер прайшоў: вынік дайшоў да боку, які кліча кінуў: кліент выкінуў выключэнне сервер: SDK сервера адмовіўся адпраўляць
TS гэта @modelcontextprotocol/sdk 1.32.1, Python гэта mcp 2.3.0, Claude гэта Claude Code 2.1.292. На апублікаваную схему рэагуюць толькі 2 кліенты і толькі калі ў іх ёсць спіс інструментаў.

Кліент 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 палі абавязковыя.

вынік SDK сервера CallToolResultSchema stdio кліент outputSchema мадэль TS пасля listTools, Python × wrong-container × empty, truncate type-swap, drop-required ✓ чысты, error 2 віды псавання Claude Code 2.1.292 × wrong-container → праверкі няма ✓ чысты, error, empty, truncate, 2 віды псавання, type-swap, drop-required
Першая праверка сядзіць у SDK самога faultmcp і спрацоўвае і пры схаванай схеме. Другая ёсць толькі ў частцы кліентаў.

Потым збой 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 я не чапаў.