Skip to content
MCP Five

The LedgerProject #537 / 51

The finding

Every tutorial shows a server→client push. It cannot work on serverless, and the SDK says why.

This is the best story in the series, and it is really a story about a habit: reading what is installed instead of what is written about it.

Part 1 · The obvious way to write it

Sampling, in every tutorial, article and example you will find, is one line:

ts
const answer = await server.server.createMessage({ ... });   // ❌

The server pushes a request down the connection to the client, the client runs the model, the answer comes back up. It reads beautifully. It is in the types. It has autocomplete.

It throws.

Part 2 · The habit — read the types on disk first

Before building anything, go and look at what is actually installed. This is the fifth consecutive project where doing so changed the design, and this time it changed it most.

bash
npm ls @anthropic-ai/sdk @modelcontextprotocol/server mcp-handler
grep -rn "createMessage\|registerResource\|registerPrompt\|elicitInput" \
  node_modules/@modelcontextprotocol/server/dist/*.d.mts | head

The types say sampling exists. server.server.createMessage(), registerResource, registerPrompt, elicitInput — all there, all typed. You could stop here and start building, and you would have a working plan and a lost week.

Don't. Every one of them is marked @deprecated, with this beside it:

the 2026-07-28 revision has no server→client request channel

So check the constants

bash
node --input-type=module -e "
import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server';
console.log(LATEST_PROTOCOL_VERSION, JSON.stringify(SUPPORTED_PROTOCOL_VERSIONS));"
terminal
2025-11-25 ["2025-11-25","2025-06-18","2025-03-26","2024-11-05","2024-10-07"]

No 2026-07-28 anywhere. A constant literally named LATEST_PROTOCOL_VERSION does not contain the latest protocol version.

Part 3 · The probe

Checkpoint 1 — the naive version fails, and explains itself

curl → the naive server
{"result":{"content":[{"type":"text","text":
"Cannot request input 'think' (sampling/createMessage): the client on this
 2025-era connection did not declare the required capability (no client
 capabilities are available on this connection — per-request legacy serving
 cannot receive server-to-client requests)"}],"isError":true}}

Read the last clause slowly, because it is the whole finding:

per-request legacy serving cannot receive server-to-client requests

Checkpoint 2 — a second era, hiding behind an envelope

SUPPORTED_PROTOCOL_VERSIONS only describes the old initialize handshake. The modern era does not negotiate at connection time at all, because there is no connection. It travels per request, in a _meta envelope.

bash
META='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
  "io.modelcontextprotocol/clientInfo":{"name":"probe","version":"1"},
  "io.modelcontextprotocol/clientCapabilities":{"sampling":{}}}'
 
curl -s -X POST http://localhost:3000/api/probe \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Mcp-Method: tools/call' -H 'Mcp-Name: probe_sampling' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",
       \"params\":{\"name\":\"probe_sampling\",\"arguments\":{},$META}}"

Same server. Same tool. Different era. And this time:

curl → the same server, modern envelope
{"result":{
"resultType":"input_required",
"inputRequests":{"think":{"method":"sampling/createMessage","params":{…}}},
"requestState":"…"}}

There it is. Sampling is not a callback. It is a retry.

Part 4 · The shape that replaced it

The multi round-trip
Round 1 returns input_required rather than a result. The host runs the model itself, then calls the same tool a second time carrying inputResponses and the requestState it was handed. No connection is held open at any point, which is exactly why it works on serverless.

On the wire:

round 1   →  tools/call
          ←  {"resultType":"input_required",
              "inputRequests":{"summary":{"method":"sampling/createMessage",…}},
              "requestState":"…"}
          ·  the host runs the model
round 2   →  tools/call  + params.inputResponses + params.requestState
          ←  {"resultType":"complete","content":[…]}

A retry needs no connection at all. Which is precisely why they changed it.

This is now the specification's position, in MUST language

Project #5 found this by probing. The spec states it outright:

Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported. This is a breaking change.

And it applies to more than sampling. roots/list and elicitation/create use the identical mechanism — the pattern is called multi round-trip requests, and it is the general answer to "how does a server ask for anything?"

Servers may only return input_required on three requests: tools/call, resources/read, and prompts/get.

Part 5 · Three things that cost a round trip each

All silent failures. Everything looks fine; nothing happens.

SymptomCause
-32020 "the request headers and body disagree"the 2026 era needs an Mcp-Method header, and an Mcp-Name when params carry a name or uri
-32021 "client capabilities do not declare the required capability"you didn't put sampling: {} in the envelope. This is why the danger is opt-in.
the handler asks for the same thing foreveracceptedContent() is elicitation-only. Sampling needs inputResponse(responses, key){kind:"sampling", result}

That third one is worth expanding, because it is an infinite loop that costs money. The two readers are not even the same shape: a sampling view wraps its payload in .result, while an elicitation view is flattenedaction and content sit directly on it. Use the wrong reader and you get undefined, which is indistinguishable from "no answer yet", so the handler asks again. And each ask is a full model call.

And one placement detail that is pure trap: inputResponses and requestState are top-level members of paramsnot inside _meta, where the rest of the modern-era machinery lives. Put them in _meta and the server never sees them, so it asks again. It looks like a loop bug. It is a placement bug.

Part 6 · Why this counts as a finding

The whole thing was discovered by writing a throwaway server and curling it — not by remembering how sampling works, and not by reading a guide.

A deprecation notice on the thing you were about to build on is worth more than a working example of it.

That is the transferable part. A working example tells you that something used to work in somebody's environment. A deprecation notice tells you what the maintainers know about where it is going — and it is sitting in your node_modules, for free, with the reason attached.

What you now know

  • Every sampling tutorial shows createMessage(), a server→client push.
  • A push needs a connection; a serverless function has a request and then it doesn't. The SDK's own error says exactly this.
  • LATEST_PROTOCOL_VERSION describes only the handshake-negotiated era, so it does not list 2026-07-28.
  • The replacement is a retry: input_required → you do the work → call the same tool again with inputResponses and requestState.
  • That pattern (MRTR) is now a MUST, is a documented breaking change, and covers roots and elicitation too.
  • inputResponses and requestState go at the top level of params.
  • When types and constants disagree, the wire is the tiebreaker.