Most MCP write-ups are about giving a model more tools. This one is about the opposite problem: what happens when a tool call brings something back that should never have left the upstream — your own bearer token — and how far it can travel inside a Spring application before anyone notices.
The code is public: mcp-spring-reference v0.1.0. It is a small reference implementation, not a framework and not a product. Ten acceptance criteria, fifty-three tests, one upstream. The interesting part is the three tests that failed before they passed.
The setup
The application is an ordinary Spring Boot service. A user authenticated by Spring Security asks it a question about a Czech company. The service answers by calling a tool on an MCP server that fronts ARES, the Czech public business registry. The tool takes a validated parameter (an IČO, the company identifier), returns a result with provenance attached, and the whole exchange is written to an append-only audit.
Two design choices matter for what follows.
First, the MCP client is our own: java.net.http.HttpClient plus hand-rolled JSON-RPC over the streamable HTTP transport. It is not the Spring AI starter and not the official Java SDK. The reason was not pride. A reference implementation should let the reader see every byte between the service and the upstream, and a client that fits in one class does that. It also means that every mistake below is ours, which is the point of writing it down.
Second, the audit stores metadata and a SHA-256 of the response. Never the body, never the token. That decision predates the leak work, and it is the one thing that held throughout: in none of the three cases did a credential reach the audit table. The leaks were elsewhere, and they were closer to the model and the caller than to the database.
In tests, the ARES MCP server is mocked. The company identifiers in fixtures are checksum-valid but invented; they were checked against the live registry on 13 October 2026 and did not resolve to any real entity.
The threat we were actually testing
The adapter sends a bearer token to the upstream. The question we asked was simple: if the upstream, for any reason, echoes that token back, where does it end up?
"For any reason" is doing a lot of work. An upstream might reflect a request header in an error message. A debug mode might be left on. A proxy might wrap the original response. A compromised or merely sloppy server might include it in a data event. None of these are exotic, and none of them are things you control, because the whole premise of MCP is that the tool server is somebody else's code.
So the acceptance criterion was: a reflected credential must never appear in anything the application hands onward. That covers the ToolResult, the optional response formatter (the last hop before the model or the caller sees text) and the audit.
We wrote the test, it failed, we fixed it, and then it failed twice more in ways we had not anticipated.
Leak path 1: the direct reflection
The mock upstream returns a JSON-RPC response whose payload contains the configured bearer token as plain text.
Before the fix, the adapter accepted the response, built a ToolResult, and kept the raw response alongside the parsed data. The raw response was reachable from the result object. That meant it was reachable from the caller, and from the formatter if one was configured. The token would not have been written anywhere persistent. It would, however, have been available to exactly the components whose output is least controlled: the one that produces text for a language model.
The first fix was a scan of the raw response text for the token before constructing the result. That closed this case and opened the next one.
Leak path 2: the Unicode-escaped token
JSON allows any character to be written as a \uXXXX escape. A token written with escapes is byte-for-byte different on the wire, so a text scan for the literal token does not find it.
The parser, of course, decodes it. The raw response passed the scan, the JSON was parsed, and the decoded token sat in ToolResult.data. That is the field most likely to be used, because it is the structured one.
This is the case I would ask any team to add to their suite, whatever stack they use. The lesson is not about Unicode. It is that any check performed on one representation of the data is only valid for that representation. If you scan text and then parse, you have checked the text, not the parse. The fix was to run the credential check on the decoded form as well.
Leak path 3: the trailing SSE event
The streamable HTTP transport can deliver a response as a sequence of server-sent events. The adapter selects the event that carries the JSON-RPC result it asked for and parses that.
The third test sent a valid, clean result event followed by a later data event containing the escaped token. The adapter selected the first event, checked it, found it clean, and built the result. All the while, it kept the entire raw body, including the trailing event, as rawResponse.
Nothing reached the audit; the hash is not reversible. But the raw body was retained on an object the caller could inspect, and that is a leak by our own criterion.
The fix had two parts: check every SSE event in the stream, not just the selected one; and retain and hash only the selected event rather than the whole body. The second part is the more important one. Keeping "the whole response, just in case" is a habit from debugging, and it is precisely the habit that turns a transport detail into a credential exposure.
What "fixed" means here
In all three cases the behaviour after the fix is the same, and deliberately boring: the adapter fails closed.
- The call returns 503 with an
UNAVAILABLEstatus to the caller. - No
ToolResultis produced: no data, no raw response. - The optional formatter is never invoked.
- The audit row is written, because the attempt happened, but with a null response hash and a generic detail.
We considered redacting the token and returning the rest, and rejected it. Redaction means the adapter judges which parts of an untrusted response are safe. A reflected credential is strong evidence that the upstream is not behaving as expected. A 503 is honest: the service could not get a trustworthy answer.
What we learned
The leak is almost never where you look first. We started by worrying about the database. The database was fine. The exposure was in in-memory result objects and in the hand-off to a formatter. Those are transient places that do not show up in a data-flow diagram unless you draw it with the model as a sink.
Representation matters more than pattern. Text scan, parsed value and stream event are three different views of the same response. A guard on one is a guard on one.
Retain only what you selected. "Keep the raw response for debugging" is an anti-pattern at a trust boundary.
Fail closed and say so. A 503 that says "unavailable" is the one response a caller can act on without trusting your sanitiser.
Own the client if you want to teach from it. A hand-rolled JSON-RPC client over HttpClient is about three hundred lines, small enough to read in one sitting.
None of this makes the adapter safe against an actively hostile upstream. It makes it honest about the narrow thing it checks. The ten acceptance criteria and the table in the README say exactly what is covered.
Where this comes from
The ARES MCP server on the other end of this adapter is the one that cz-agents.dev runs for Czech company data. I work on compliance and AML architecture, where "which tool said what, and can we prove it" is not a nice-to-have. If you are building something similar in Spring and want a second pair of eyes on the integration boundary, write to [email protected].