DEV Community

Baz
Baz

Posted on Edited on Originally published at mohibuddin.com

Your MCP server didn't break. The protocol did.

I updated my editor last week and three MCP servers stopped working. Same error each time:

{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found: initialize"}}
Enter fullscreen mode Exit fullscreen mode

I spent about forty minutes assuming I'd broken something. I hadn't. Neither had the server authors. The spec changed underneath all of us.

If you got here from googling -32601 method not found mcp or initialize method not found or you're just staring at a server that worked fine on Friday, this is what happened.

The short version

MCP revision 2026-07-28 made the protocol stateless. Not "added a stateless mode". Made it stateless, and deleted the parts that assumed otherwise.

Gone:

  • initialize and notifications/initialized
  • ping
  • logging/setLevel
  • resources/subscribe and resources/unsubscribe
  • notifications/roots/list_changed
  • server-initiated requests entirely, which means sampling/createMessage, elicitation/create and roots/list no longer work the way they did

Every MCP server written before mid-2026 relies on at least the first item. That's why yours broke.

What the errors actually mean

Here's the mapping, because the error text on its own is not very helpful.

-32601 method not found on initialize

Your client is modern, your server is not. The client never sent a handshake because there is no handshake anymore. Instead it sends server/discover, which your server has never heard of, and then the reverse happens: your server waits for an initialize that never comes.

This is the single most common symptom.

-32602 with "missing required request metadata"

Because there's no session, every single request now has to carry its own context. It goes in params._meta:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { "roots": {} }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Protocol version and client capabilities on every request. Not once at startup. Every request. If you're writing a client by hand and skipped this, that's your error.

-32022 unsupported protocol version

The version in that _meta block didn't match. Worth knowing that the error codes got renumbered in this revision too, so old code checking for -32001 will silently stop matching. -32020 is header mismatch, -32021 is a missing client capability, -32022 is the version one.

-32021 and a requiredCapabilities object

The server needed something the client didn't declare. Since capabilities now arrive per request instead of being negotiated once, a client that forgets to declare sampling will get this the moment a tool tries to use it.

Your server hangs and never answers

If your server calls back to the client mid-request (an elicitation prompt, a sampling call, asking for roots), it's now waiting forever. Modern clients cannot receive pushes. There's nothing listening.

This one is nastier than the others because there's no error at all. It just sits there.

The replacement for server-initiated requests

This is the part I found genuinely clever, and it's worth understanding even if you never write a server.

Old world: server interrupts its own call, asks the client something, waits for an answer, continues.

New world: the server can't push, so it returns early with a result that says "I need input", and the client calls the same thing again with the answers attached.

{
  "resultType": "input_required",
  "inputRequests": {
    "ir_1000": {
      "method": "elicitation/create",
      "params": { "message": "Which environment?" }
    }
  },
  "requestState": "0f3a...e21"
}
Enter fullscreen mode Exit fullscreen mode

The client answers by re-sending the original request with requestState and an inputResponses map keyed the same way. The call picks up where it left off. It's called a multi round-trip request, MRTR if you read the SEPs.

The bit that catches people: the values in inputResponses are the response body itself, not wrapped in a result field. I got that wrong the first three times.

Notifications moved too

resources/subscribe is gone. Change notifications now live on a single long-lived stream you opt into, and you name the types you want:

{
  "jsonrpc": "2.0",
  "id": "listen-1",
  "method": "subscriptions/listen",
  "params": {
    "notifications": {
      "toolsListChanged": true,
      "resourceSubscriptions": ["file:///project/config.json"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things here are easy to miss. The request stays open, and its JSON-RPC id doubles as the subscription id that gets stamped on every notification. And the server must not send you anything you didn't ask for.

Progress and logging notifications have no home on that stream, by the way. They belong to an in-flight request, and a stateless request/response shape has nowhere to put them. If you were relying on notifications/progress for a progress bar, that's a real loss and there isn't a workaround yet.

So what do you actually do

Three options, in the order I'd try them.

Update the server. If you wrote it, or it's actively maintained, this is the right answer. The official SDKs handle most of the migration for you, and the TypeScript SDK ships a compatibility shim so handlers written in the new style still serve old clients. Check if there's a newer version before doing anything else.

Pin your client. Buys you time, costs you everything else in the update. Fine for a week, bad as a plan.

Wrap the server. This is the option nobody talks about, and it's the one I needed, because two of my three broken servers hadn't been touched in over a year and one was a vendor binary I don't have source for. No amount of "just update it" helps there.

I ended up writing the wrapper, so treat the rest of this as biased. It's called mcp-uplift. You point your client at it instead of at the server:

npx -y mcp-uplift -- npx -y @modelcontextprotocol/server-filesystem .
Enter fullscreen mode Exit fullscreen mode

It keeps one legacy session warm behind the scenes and does the translation: synthesizes server/discover from the old handshake, turns server-initiated requests into input_required round trips, filters legacy notifications onto a subscriptions/listen stream, and answers the deleted methods itself instead of forwarding them.

The server doesn't change. It doesn't even know.

Does it work

Fair question, since a protocol translator that's subtly wrong is worse than nothing.

I didn't trust my own test suite, because I wrote both sides of it and it only proves I'm internally consistent. So I ran it against servers I didn't write: 79 published legacy MCP packages, on a clean CI runner, checking the whole lifecycle each time. Discovery, subscription acknowledgement, the acknowledged filter never claiming a capability the server didn't declare, no response arriving while the stream is open, and a graceful close on shutdown.

79 reached discovery, zero protocol failures, 36 completed a full subscription lifecycle. The run is public if you want to read the log rather than take my word.

I deliberately dropped every package that needs an API key. They stop at the missing credential, never exercise the bridge, and only make the number look bigger. 79 real ones beat 100 with a fifth of them unreachable.

Things it can't do

Because I'd rather you find out here than after adopting it.

Calls are serialized. A legacy server can interrupt any call to ask the client something, and the old protocol never linked that question back to the call that caused it, so only one call runs upstream at a time. Correct attribution, worse throughput.

Progress and logging notifications are dropped, for the reason above. Nothing to be done about that one.

Parked calls don't survive a restart, because each one is waiting on a child process that dies with the bridge.

And wrapping a server runs that server with your permissions. It's a compatibility layer, not a sandbox.

The deadline

Roots, sampling and logging are deprecated with about twelve months of runway. That's the window. After it, "just update it" stops being optional and wrapping stops being a bridge and starts being life support.

Update what you can. Wrap what you can't. Don't pin your client and forget about it.


If you hit an error I didn't cover, drop it in the comments and I'll add it. The list above is from servers that actually broke, not from reading the changelog.

Top comments (2)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The compatibility result is impressive, especially the external-package run. One boundary I would make explicit is that a warm legacy session is now security-relevant state, even though the downstream protocol is stateless.

For a local single-user editor that may be fine. In a shared gateway, one upstream legacy session must not be reused across different authorization contexts. Legacy servers can cache roots, capabilities, credentials, subscriptions, or tool state from initialization. A safe bridge-instance key should therefore include at least the downstream principal/tenant, authorization audience, roots digest, effective capability set, child command/config digest, and server identity.

I would also bind every parked MRTR continuation and subscription to that same partition, then reject:

  • a resume from a different principal or roots set
  • token/role changes while a call is parked
  • reuse after the child process restarts
  • a subscription stream attached to the wrong bridge instance
  • cancellation/timeout followed by a late legacy callback

An explicit local-single-user mode versus shared-gateway mode would help operators understand the assumption. In shared mode, fail closed if a stable authorization identity is unavailable, and expose the upstream session/partition digest in traces without logging credentials.

The stateless front does not erase the stateful back; the translator becomes the place where that state must be isolated.

Collapse
 
bazzz profile image
Baz

yeah, you're right. and it's the class that's the problem, not the CLI.

mcp-uplift is one client per process by design. cli.js reads a single stdin pipe and every client launches its own, so the warm session is scoped to one caller. That's the deployment it's built and swept against, and it's why the shared upstream state is safe there.

The library path is the boundary worth naming. UpliftBridge is exported, and handle(req) takes a bare JSON-RPC request with no slot for a principal, so nothing inside it is partitioned by caller.

Concretely:

  1. MRTR resume validates the method and a deep-equal of the original params. Possession of the requestState is the authorization.
  2. Subscriptions are keyed on the client-chosen JSON-RPC request id, so two callers picking the same id collide.

Both are correct with one caller. Neither survives several.

I'm keeping it single-client rather than adding a partition key. The MRTR store, the subscription store and the warm legacy session would all still be unpartitioned underneath, so a clientKey on handle() would signal isolation that isn't actually there. Single-client is a design constraint, not an oversight, and the README and class docs now state it plainly.