Skip to content

feat(mcp): serve /mcp through the SDK envelope behind a flag, at parity - #9677

Merged
JSONbored merged 1 commit into
mainfrom
feat/mcp-sdk-envelope-wiring
Aug 6, 2026
Merged

feat(mcp): serve /mcp through the SDK envelope behind a flag, at parity#9677
JSONbored merged 1 commit into
mainfrom
feat/mcp-sdk-envelope-wiring

Conversation

@JSONbored

@JSONbored JSONbored commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Routes /mcp through @modelcontextprotocol/sdk's Server + WebStandardStreamableHTTPServerTransport behind a default-off MCP_SDK_ENVELOPE flag. Nothing changes in production until the flag is set; the point of shipping it dark is that flipping it — and flipping it back — is a config change rather than a deploy, on the surface with the most external callers.

The SDK takes the envelope only: JSON-RPC parse, batch fan-out and correlation, 202-on-notification, JSON/SSE framing. Every method still resolves through dispatchMessage, so the single instrumentation chokepoint (#8993, #8994, #9054, #9639, #9642) is untouched.

Total delegation

initialize and ping are reclaimed with removeRequestHandler, so no method is answered by the SDK. Three reasons, each of which was a real defect in the partial-delegation draft:

  • telemetrydispatchMessage's finally emits the protocol usage event for every method. A method the SDK answered internally is a method that silently stops being counted, initialize most of all.
  • validationsetRequestHandler wraps a handler in a zod parse of the SDK's own schema. Registering one for initialize would reject a handshake with no protocolVersion that we negotiate today, and answer with zod's text.
  • drift — one funnel is the property the whole instrumentation story rests on.

Six divergences, zero response shims

Found by running the SDK, not reading it:

Divergence Resolution
initialize._meta (registry backlink) dropped total delegation returns our result verbatim
A thrown handler leaks its message as -32603 dispatchMessage returns, never throws
McpError rewrites text to MCP error -N: … JsonRpcFailure carries a code the SDK serialises as-is
Transport 406s any Accept lacking both media types normalized on the rebuilt request
Transport sets a bare content-type, nothing on 202 MCP_HEADERS overlaid — CORS and no-store survive
Malformed input → 400 -32700 for the whole request gated: the SDK never sees a bad message

The Accept one would have presented as "the migration broke every script" — most traffic here is curl/python-requests sending */*, which fails the transport's literal substring test.

The malformed-input one loses data rather than relabelling it: a batch with one bad member currently answers the valid ones, where the SDK rejects the batch whole. Ours is also the more spec-correct classification, since -32700 is reserved for JSON that did not parse. So malformed input is routed away from the SDK rather than shimmed back.

The batch ceiling moves out of the hand-rolled branch so both envelopes share it — the transport fans out with no bound of its own, and a flag that swaps envelopes must not also remove a resource limit.

mergeInitializeMeta is deleted, not left behind: total delegation makes it unreachable.

Verification

  • tests/mcp-sdk-parity.test.ts — 32 tests driving the real served path (handleMcpRequest, flag off then on) across every method, both tools/call failure shapes, malformed input, batches, headers and status codes. The previous version compared the adapter against the dispatcher through a stub, which proved nothing about the wiring around it.
  • A flag test guards the whole file against passing vacuously — two envs that both take the hand-rolled path would compare equal too.
  • Sabotage-checked: altering the error text, the Accept normalization, and the header overlay each fail the harness.
npx tsc --noEmit                 clean
npm run validate                 129 subnets, 3442 surfaces, 136 providers
npx vitest run tests/            703 files, 17014 tests, all passing
patch coverage (diff ∩ v8)       statements 42/42, branches 20/20 — 100%

Not in scope

Deleting the hand-rolled envelope (#9647 step 4) stays open until the flag has been on through a full deploy cycle.

Closes #9676
Refs #9647


Update: a seventh divergence, found reviewing my own diff

The SDK dispatches notifications fire-and-forget. Protocol._onnotification runs the handler as Promise.resolve().then(() => handler(n)) and never awaits it, so the 202 returns while the handler is mid-flight — and on Workers the request context is then torn down with the telemetry write unfinished.

Measured: at response time the dispatch had started and not completed.

This is invisible to a response comparison — every parity test above passes either way, because the HTTP response is byte-identical. The only symptom would have been notifications/initialized quietly disappearing from PostHog some time after the flag flipped, with nothing pointing at the cause.

Fixed by draining pending notification dispatches before answering (allSettled, so a failing telemetry write can never become the caller's response), with two tests: one asserting the funnel finished rather than merely started, one asserting a rejecting dispatch still yields a 202. Both sabotage-checked.

And an eighth gap — in my own harness

observe() compared status, body, content-type, CORS and cache-control, but not mcp-session-id. Session minting reads the dispatched response, which the SDK path has to capture on its way past rather than parse back out of a serialized body — so getting it wrong would have cost every caller its identity (#9054) with nothing in the suite objecting.

Now asserted three ways: that a successful initialize mints on both paths, that the id is well-formed ([\x21-\x7E]{1,128}), and that a refused request mints nothing — an id the client never received would be a session the hub holds for nobody. Sabotage-checked by breaking the capture: three tests fail.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-registry-sync-api 64bf18b Aug 06 2026, 12:11 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-data-api 64bf18b Aug 06 2026, 12:11 PM

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@JSONbored JSONbored self-assigned this Aug 6, 2026
@JSONbored
JSONbored force-pushed the feat/mcp-sdk-envelope-wiring branch from 8934b68 to e691933 Compare August 6, 2026 12:06
Routes /mcp through @modelcontextprotocol/sdk's Server and web-standard
transport when MCP_SDK_ENVELOPE=1, default off. The SDK takes the envelope
only -- JSON-RPC parse, batch fan-out, 202-on-notification, framing -- while
every method still resolves through dispatchMessage, so the single
instrumentation chokepoint is unchanged.

Total delegation is what makes that possible: initialize and ping are
reclaimed with removeRequestHandler, so no method is answered by the SDK.
That keeps their telemetry (client attribution, session identity) and avoids
setRequestHandler's zod re-validation, which would have rejected a handshake
with no protocolVersion that this server negotiates today.

Running the SDK rather than reading it turned up seven ways the swap would
not have been behaviour-neutral. None became a response shim:

  _meta dropped        total delegation returns our own result verbatim
  thrown-message leak  dispatchMessage returns, never throws
  McpError rewrites    JsonRpcFailure carries a code the SDK serialises as-is
  Accept 406           normalized on the rebuilt request; callers send */*
  headers lost         MCP_HEADERS overlaid, keeping CORS and no-store
  malformed input      gated away: the SDK never sees a bad message
  notification race    pending dispatches drained before the response

The malformed-input one loses data rather than relabelling it -- a batch with
one bad member currently answers the valid ones, where the SDK rejects the
batch whole. Ours is also the more spec-correct classification, since -32700
is reserved for JSON that did not parse.

The notification one is invisible to a response comparison. The SDK's
Protocol dispatches notifications fire-and-forget, so the 202 returns while
the handler is still running and the request context is torn down with the
telemetry write unfinished. Measured: at response time the dispatch had
started and not completed. Left alone, notifications/initialized would simply
have stopped appearing once the flag flipped.

The batch ceiling moves out of the hand-rolled branch so both envelopes share
it; the SDK transport fans out with no bound of its own, and a flag that
swaps envelopes must not also remove a resource limit.

mergeInitializeMeta is deleted rather than left behind: total delegation makes
it unreachable.

Parity is asserted against the real served path -- handleMcpRequest with the
flag off, then on -- across every method, both failure shapes, malformed
input, batches, headers, status codes and session minting, with a flag test
so the comparison cannot pass vacuously.

Closes #9676
Refs #9647
@JSONbored
JSONbored force-pushed the feat/mcp-sdk-envelope-wiring branch from e691933 to 64bf18b Compare August 6, 2026 12:10
@JSONbored
JSONbored merged commit 0625e98 into main Aug 6, 2026
7 checks passed
@JSONbored
JSONbored deleted the feat/mcp-sdk-envelope-wiring branch August 6, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: wire /mcp through the SDK envelope behind a flag, at parity

1 participant