Skip to content

Protocols · 6 min

You can't test a protocol bridge on your own mocks

Mohib Uddin · August 14, 2026

MCP's 2026-07-28 revision deleted a lot. The initialization handshake, sessions, ping, logging configuration, resource subscriptions, and — the big one — server-initiated requests. The protocol is stateless now. A request carries everything it needs and the server answers it. That's a better protocol.

It is also a breaking change for every server written before it. Plenty of those servers are fine. They do a useful thing, they have no bugs anyone cares about, and nobody is going to port them, because the person who wrote them has moved on. They stop working the day their client goes modern-only.

So I wrote mcp-uplift. It launches one legacy stdio server, keeps a single session warm against it, and presents that session to the outside world as a stateless modern server. Point your client at the bridge instead of the server and nothing about the server changes.

The handshake is not the hard part

Translating initialize is easy. You do it once at startup, keep the result, and hand it back whenever someone calls server/discover. Removed methods get a -32601. Legacy -32002 resource-not-found becomes -32602. That's an afternoon.

The hard part is that a legacy server can interrupt its own call to ask the client a question. You call tools/call, and before you get a result the server sends you a roots/list, or an elicitation/create, or a sampling/createMessage. It wants an answer before it will continue.

A modern client cannot receive that. There is no channel for it. The server does not get to push.

What the bridge does instead is park the call and return a result that says, in effect, I need something from you first:

{
  "resultType": "input_required",
  "inputRequests": {
    "ir_1000": {
      "method": "elicitation/create",
      "params": { "message": "Which environment?" }
    }
  },
  "requestState": "c0a2db7b-62bc-4420-b2e9-31da87f8f999"
}

You send the same request again, carrying that requestState and an inputResponses map keyed the same way. The parked call wakes up, gets its answer, and finishes normally. The round trip that the legacy protocol did with a push, the bridge does with a resumption.

This is where I started distrusting my own tests. I had a fixture server that did exactly this, and the bridge handled it perfectly. Of course it did. I wrote the fixture and the bridge in the same afternoon, from the same reading of the spec, with the same assumptions baked into both. A test like that isn't verification. It's an echo.

79 servers I didn't write

So the real test drivers download published packages off npm and run the bridge against them. Not my fixtures — other people's servers, unmodified, at pinned versions.

0protocol failures across 79 published servers, none of which I wrote

Community servers matter more here than reference ones. The reference servers were written by the people who wrote the spec; they are canonical almost by definition. The community ones were hand-rolled by people who read the 2025 spec once and shipped something that worked. That is exactly where a translation bug hides — in the server that does the handshake slightly oddly, or declares a capability it never uses, or orders its notifications differently.

Each package was checked against the registry for an executable, a pre-v2 SDK dependency, and a publish date before the cutoff, so every one is a genuine legacy server rather than something already modern that would pass trivially.

The number used to be bigger

It said 97 for about a day. I cut it to 79 on purpose, and that is the part of this worth writing down.

Eighteen of those packages front a paid API. Run one without a key and it stops at the missing credential — before the handshake, before a single line of translation code executes. It cannot fail my bridge, because it never reaches my bridge. Counted in the list, they looked like coverage. They were padding.

Servers in the probe list 79
Reached discovery 79
Protocol failures 0
Completed a full subscriptions/listen lifecycle 36
Declared no list_changed capability 43

The second row is the one that matters, and it only becomes meaningful once the unreachable packages are gone. "97 probed" with a third of them stuck at an auth wall is a worse claim than "79 probed, 79 reached" — it is bigger and it means less. 36 completed a subscription lifecycle end to end; the other 43 declare no list_changed capability, so there was nothing to exercise, and the bridge correctly acknowledges a narrower filter instead of promising a notification the wrapped server will never send.

A number you can shrink on purpose is worth more than one you can't.

What still doesn't work

All of this is in the README, because a limitations section that only lists solved problems is decoration.

Every forwarded call is serialized. Any call can be interrupted by a question, and the legacy protocol never links that question back to the call that caused it — there is no correlation id. If two calls were executing and a sampling/createMessage arrived, there would be no sound way to know which one was asking. So the bridge keeps exactly one call in flight upstream and holds it through all of its MRTR rounds. Requests are accepted concurrently up to --max-in-flight, which is admission, not execution: they still run one at a time, and a call parked awaiting client input blocks unrelated calls until it is answered, cancelled, or expires. Correct attribution over throughput.

The bridge is stateful. Only its interface is stateless. This is the one I find most interesting, because it is the seam in the whole premise. Parked MRTR calls live in memory — each one is a promise waiting on the wrapped server's own in-flight call, so it cannot be serialized to a token and handed back. Restart the bridge and they die with the child process. A requestState issued before a restart comes back with restarted: true in the error data, so a client can tell that from a bad token, but the call itself is gone and has to be reissued.

One warm session serves every request. The handshake runs once and the session is shared, so whatever state the wrapped server keeps is shared with it. Wrapping a memory server gets you one store, not one per request. Each client launches its own bridge process over its own pipe, so it is sharing within a client, not between them.

Progress and logging notifications are dropped. A subscriptions/listen stream carries change notifications. Progress and logging belong to an in-flight request, and a stateless request/response shape has nowhere to put them. They go nowhere. I would rather say that plainly than quietly swallow them and let someone discover it in production.

There is more in the README — an unparseable stdout line is skipped in silence because legacy servers print banners there, and exceeding a size cap ends the session rather than dropping the one oversized message.

The part worth keeping

The bridge is maybe 48KB of source. The test drivers are a comparable size, and they were the entire value of the project. Writing translation code from a spec gets you something that reflects your reading of the spec. Running it against eighty servers written by people who never read your code is the only thing that tells you whether your reading was right — and being willing to throw eighteen of them out is what keeps the answer worth having.

npx -y mcp-uplift -- <your-legacy-command> if you have one of these servers sitting in a config somewhere. It is not a sandbox — wrapping a server runs that server with your permissions, so only wrap things you already trust.