This is the text version. The original on Medium has the hand-drawn diagrams and the terminal recordings of the hang and the fix.
Removing a JSON round-trip from package:dart_mcp exposed the bugs its copies had been hiding.
A request-scoped MCP server gets a ping. The built-in handler answers immediately (there is nothing to compute) and the dispatcher waits for the response so it can hand it back. It waits forever. No exception reaches the caller, no timeout fires, the protocol log shows nothing. The future just never completes.
This happened on a branch of package:dart_mcp, the Dart team's SDK for MCP — the protocol AI agents use to call tools — shortly after I removed jsonDecode from its hot path. It never shipped; running the tests on the branch caught it. But I think the mechanism is worth writing down, because the bug wasn't in any line I changed. It was in a property I deleted without noticing it existed.
The change
Until this change, every channel in the SDK was a StreamChannel<String>. Each message was a JSON string, even between a client and server living in the same process: the sender ran jsonEncode, the receiver ran jsonDecode, and for the request-scoped dispatcher — the piece that runs a short-lived server for each incoming request — that meant encoding a map onto an in-memory channel just to decode it again a microtask later.
During review of the dispatcher PR, the maintainer suggested the obvious fix: make the channels carry decoded messages — StreamChannel<Map<String, Object?>> — and keep encoding at the transport edge where the actual bytes live. dart-lang/ai#531 does that. json_rpc_2 has a constructor for exactly this (Peer.withoutJson), stdioChannel still speaks newline-delimited JSON on the wire, and the whole thing is a few dozen lines of mechanical retyping.
The mechanical part took an hour. Then I ran the tests.
What jsonDecode was actually doing
jsonDecode never gives you back the map that was encoded. It builds a fresh one: growable, string-keyed, private to whoever asked. Which means a JSON round-trip doubles as a copy machine. Every hop through it hands each side its own mutable snapshot, and any code downstream can scribble on that snapshot without anyone else noticing.
Nobody writes this property down, because nobody chose it. It falls out of the encoding. And code quietly grows to depend on it.
The request-scoped dispatcher had one such dependency. MCP reserves a metadata key for the server to record its identity on results, and the dispatcher stamps it into each successful response:
resultMap[Keys.meta] = meta; // fine, resultMap came from jsonDecode
Under StreamChannel<String>, resultMap was the dispatcher's private copy. The moment the decode went away, it became the very map the handler returned. And the built-in ping handler returns this:
factory EmptyResult() => EmptyResult.fromMap(const {});
A const map. Writing into it throws Cannot modify unmodifiable map.
That explains a crash. It doesn't yet explain the silence. The throw happens inside a stream listener, and that listener used to be wrapped in a catch-all whose comment read "never let it wedge or escape the exchange." I had deleted it in the same change — it guarded against undecodable frames, and there were no frames left to decode. Unreachable, I decided.
Unreachable was a property of the old code. In the new code the throw went straight to the zone's unhandled-error handler, the response completer never completed, and the dispatcher settled in to wait for a response that could no longer arrive.
The same bug, three ways
Once you see it as "the copies are gone," a sibling shows up immediately.
For an ordinary growable result, the stamp doesn't throw — it writes protocol metadata into the handler's own object. A handler that caches or reuses its result map now finds an io.modelcontextprotocol/serverInfo entry injected into its private state. Nothing crashes. That's the worst version.
The third one I didn't find. Google's review bot did, two minutes after I opened the PR. The SDK can mirror protocol traffic into a debug sink, and with decoded channels that means re-encoding each message for the log. A map with an integer key is legal Dart and illegal JSON; jsonEncode throws on it, now from inside the logging wrapper. To be fair, that map never survived the old world either — it just died inside the peer's encoder instead. The failure isn't new. It moved somewhere you'd never think to look, and I didn't.
The fixes are small and boring, which is the point. Stamping is copy-on-write now:
return {
...response,
Keys.result: {
...result,
Keys.meta: {...?existingMeta, Keys.serverInfoMeta: serverInfo}, // a copy
},
};
Metadata that isn't a string-keyed map skips the stamp instead of throwing, matching what the code already did for metadata that isn't a map at all. The log falls back to toString when a message won't encode. And the listener guard is back, original comment and all — this time I can say precisely why it stays. Each of the three failure modes got a test that acts like the hostile input: the built-in ping whose result is const, a tool that keeps a reference to its map, a tool with integer metadata keys.
What I'd grep for
If you ever replace a serialized boundary with in-memory passing — objects instead of JSON, a function call instead of a queue — the copies disappear along with the serialization, and every in-place write on data that crosses that seam becomes a question you have to answer again. grep for []= and putIfAbsent on anything downstream of the old boundary. Const-backed values fail loud — reproducible with a ping. Aliasing fails silent; it corrupts quietly and shows up two features later.
And before deleting a guard because it's unreachable, it's worth asking what currently makes it unreachable — and whether that's a fact about the system or about this week's code. Mine was about this week's code.
The change is open as dart-lang/ai#531: typed channels end to end, 218 tests per compiler configuration. The Streamable HTTP transport gets built on top of this. The previous piece in this series, on why the 2026 spec removes the handshake itself, is here.
Top comments (0)