Většina textů o MCP řeší, jak dát modelu víc nástrojů. Tenhle řeší opačný problém: co se stane, když volání nástroje přinese zpátky něco, co nikdy nemělo opustit upstream — váš vlastní bearer token — a jak daleko se to ve Spring aplikaci dostane, než si toho někdo všimne.
Kód je veřejný: mcp-spring-reference v0.1.0. Je to malá referenční implementace, ne framework a ne produkt. 10 akceptačních kritérií, 53 testů, jeden upstream. Zajímavé jsou ty tři testy, které nejdřív selhaly, než prošly.
Jak je to postavené
Aplikace je obyčejná služba ve Spring Bootu. Uživatel přihlášený přes Spring Security se jí zeptá na nějakou českou firmu. Služba odpoví tak, že zavolá nástroj na MCP serveru, který stojí před ARES, veřejným registrem ekonomických subjektů. Nástroj přijme validovaný parametr (IČO), vrátí výsledek s připojeným původem dat (provenance) a celá výměna se zapíše do append-only auditu.
Pro to, co následuje, jsou důležitá dvě designová rozhodnutí.
Zaprvé: MCP klient je náš vlastní — java.net.http.HttpClient a ručně psaný JSON-RPC nad transportem streamable HTTP. Není to Spring AI starter ani oficiální Java SDK. Důvodem nebyla pýcha. Referenční implementace má čtenáři ukázat každý bajt mezi službou a upstreamem, a klient, který se vejde do jedné třídy, to umí. Zároveň to znamená, že každá chyba popsaná níže je naše — a právě proto to sepisuju.
Zadruhé: audit ukládá metadata a SHA-256 odpovědi. Nikdy tělo, nikdy token. To rozhodnutí je starší než celá práce na únicích a je to jediná věc, která vydržela po celou dobu: ani v jednom ze tří případů se credential do auditní tabulky nedostal. Úniky byly jinde a měly blíž k modelu a k volajícímu než k databázi.
V testech je ARES MCP server mockovaný. IČO ve fixtures mají platný kontrolní součet, ale jsou vymyšlená; 13. října 2026 byla ověřena proti živému registru a žádnému skutečnému subjektu neodpovídala.
Hrozba, kterou jsme skutečně testovali
Adaptér posílá upstreamu bearer token. Otázka, kterou jsme si položili, byla jednoduchá: když upstream ten token z jakéhokoli důvodu vrátí zpátky, kde skončí?
Na tom „z jakéhokoli důvodu“ hodně záleží. Upstream může v chybové hlášce zopakovat hlavičku požadavku. Může zůstat zapnutý debug režim. Proxy může zabalit původní odpověď. Kompromitovaný nebo jen nedbale napsaný server ho může přibalit do události data. Nic z toho není exotické a nic z toho nemáte pod kontrolou, protože celá podstata MCP je v tom, že tool server je cizí kód.
Akceptační kritérium proto znělo: odražený credential se nesmí objevit v ničem, co aplikace předává dál. To zahrnuje ToolResult, volitelný formátovač odpovědi (poslední krok předtím, než text uvidí model nebo volající) a audit.
Napsali jsme test, selhal, opravili jsme to — a pak selhal ještě dvakrát, a to způsobem, který jsme nečekali.
Cesta úniku 1: přímý odraz
Mock upstream vrátí JSON-RPC odpověď, jejíž payload obsahuje nakonfigurovaný bearer token jako prostý text.
Před opravou adaptér odpověď přijal, sestavil ToolResult a vedle naparsovaných dat si ponechal i surovou odpověď. Ta byla dostupná z výsledkového objektu. Dostal se k ní tedy volající, a pokud byl nakonfigurovaný, i formátovač. Token by se nikam trvale nezapsal. Byl by ale k dispozici přesně těm komponentám, jejichž výstup máme pod kontrolou nejméně: té, která vyrábí text pro jazykový model.
První opravou byla kontrola surového textu odpovědi na výskyt tokenu ještě před sestavením výsledku. Tahle oprava zavřela tento případ, ale otevřela další.
Cesta úniku 2: token v Unicode escapech
JSON dovoluje zapsat libovolný znak jako escape \uXXXX. Token zapsaný pomocí escapů vypadá na drátě bajt po bajtu jinak, takže textové hledání doslovného tokenu ho nenajde.
Parser ho samozřejmě dekóduje. Surová odpověď kontrolou prošla, JSON se naparsoval a dekódovaný token seděl v ToolResult.data. To je pole, které se použije nejspíš, protože je strukturované.
Tenhle případ bych doporučil přidat do testů každému týmu, ať používá jakýkoli stack. Ponaučení není o Unicode. Jde o to, že jakákoli kontrola nad jednou reprezentací dat platí jen pro tuto reprezentaci. Když kontrolujete surový text před parsováním, zkontrolovali jste text, ne naparsovaná data. Oprava spočívala v tom, že kontrolu credentials spouštíme i nad dekódovanou podobou.
Cesta úniku 3: pozdější SSE událost
Transport streamable HTTP může odpověď doručit jako sekvenci server-sent events. Adaptér vybere událost, která nese JSON-RPC výsledek, o který žádal, a tu naparsuje.
Třetí test poslal platnou, čistou událost s výsledkem a za ní další událost data s escapovaným tokenem. Adaptér vybral první událost, zkontroloval ji, shledal ji čistou a sestavil výsledek. Mezitím si ale v poli rawResponse ponechal celé surové tělo včetně té pozdější události.
Do auditu se nic nedostalo; hash se zpátky převést nedá. Surové tělo ale zůstalo na objektu, do kterého mohl volající nahlédnout, a to je podle našeho vlastního kritéria únik.
Oprava měla dvě části: kontrolovat každou SSE událost ve streamu, nejen tu vybranou, a uchovávat a hashovat jen vybranou událost místo celého těla. Důležitější je ta druhá. Držet si „celou odpověď, pro jistotu“ je zvyk z debugování — a je to přesně ten zvyk, který z detailu transportu udělá únik credentials.
Co tady znamená „opraveno“
Ve všech třech případech je chování po opravě stejné a záměrně nudné: adaptér se zachová fail closed.
- Volání vrátí volajícímu 503 se stavem
UNAVAILABLE. - Žádný
ToolResultnevznikne: žádná data, žádná surová odpověď. - Volitelný formátovač se vůbec nezavolá.
- Řádek auditu se zapíše, protože pokus proběhl, ale s hashem odpovědi null a s obecným popisem.
Zvažovali jsme, že token zamaskujeme a zbytek vrátíme, a zavrhli jsme to. Maskování znamená, že adaptér posuzuje, které části nedůvěryhodné odpovědi jsou bezpečné. Odražený credential je silný signál, že se upstream nechová tak, jak má. 503 je poctivá odpověď: služba nedokázala získat důvěryhodnou odpověď.
Co jsme se naučili
Únik skoro nikdy není tam, kam se podíváte nejdřív. Začali jsme tím, že jsme se báli o databázi. Databáze byla v pořádku. Expozice byla ve výsledkových objektech v paměti a v předávce formátovači. To jsou přechodná místa, která se v diagramu toku dat neobjeví, pokud do něj nezakreslíte model jako místo, kam data odtékají.
Reprezentace je důležitější než vzor. Textová kontrola, naparsovaná hodnota a událost ve streamu jsou tři různé pohledy na tutéž odpověď. Ochrana na jednom z nich chrání jen ten jeden.
Uchovávejte jen to, co jste vybrali. „Necháme si surovou odpověď kvůli debugování“ je na hranici důvěry anti-pattern.
Fail closed, a řekněte to nahlas. 503 s „unavailable“ je jediná odpověď, podle které může volající jednat, aniž by musel věřit vašemu sanitizéru.
Chcete-li na klientovi učit, napište si ho sami. Ručně psaný JSON-RPC klient nad HttpClient má zhruba 300 řádků — dost málo na to, aby se dal přečíst na jedno posezení.
Nic z toho nedělá adaptér bezpečným vůči aktivně nepřátelskému upstreamu. Dělá ho to poctivým v té úzké věci, kterou kontroluje. Co přesně je pokryté, říká 10 akceptačních kritérií a tabulka v README.
Odkud to pochází
ARES MCP server na druhé straně tohoto adaptéru je ten, který pro data o českých firmách provozuje cz-agents.dev. Pracuju na architektuře compliance a AML, kde otázka „který nástroj řekl co a umíme to doložit“ není nadstandard. Pokud ve Springu stavíte něco podobného a chcete, aby se na integrační hranici podíval ještě někdo další, napište mi na [email protected].