feat(#700): serve MRTR to 2026-07-28 clients on the public MCP endpoint - #704
Merged
Conversation
The public endpoint ran the v1 SDK, which never answers `server/discover`, so
every connection to it negotiated the legacy era no matter what the client
supported. On that era a modern client strips `resultType`, which made omadia's
MRTR invisible to it — and changing the emitted dialect could not have fixed
that, because the era, not the body, was the blocker.
Two SDK generations now sit behind the one path, routed by `isLegacyRequest`.
That is a router rather than a port because neither generation can serve both
eras here: the v1 line cannot negotiate 2026-07-28 at all, and the v2 line
refuses to emit omadia's flat `inputRequests` array on either era ("each
inputRequests entry must be an embedded elicitation/create, sampling/createMessage,
or roots/list request"). Routing is the SDK's own documented composition for
keeping an existing legacy deployment next to a strict modern endpoint.
The documented 2025 contract is untouched, byte for byte — the existing
endpoint suite passes unmodified. Modern callers get the revision's shape
instead: one embedded `elicitation/create` request plus an opaque
`requestState`, with the answers arriving as the spec's top-level
`inputResponses`.
`requestState` is HMAC-signed over a vault-persisted key, bound to the API key
and the method, one-hour TTL. The spec makes integrity the server author's job
and the SDK provides no default; a modified, expired or borrowed state is
refused with the frozen `-32602`. The signed round counter also replaces the
2025 dialect's guessable bounce cap, which a caller could reset by stripping
`inputResponses` from its arguments.
Both `requestState.verify` and `inputRequired.legacyShim` belong on the SERVER
options. Passing either to `createMcpHandler` compiles, runs, and silently does
nothing — a tampered state is then accepted while the endpoint looks healthy.
Measured, and the tamper test catches it.
A tool cannot tell which era its caller spoke: it still asks with the
`_pendingInputRequest` sentinel and still receives a flat `inputResponses`
object. All translation stops at the endpoint.
Known gap, recorded rather than papered over: the revision's elicitation schema
admits only the `email`, `date`, `uri` and `date-time` string formats and has no
masked-input concept, so a field a tool marked `secret` is named in the prose
instead of flagged in the schema. Emitting a `password` format anyway would
produce a request a conforming client rejects.
Mutation checks (dist rebuilt each time): moving `requestState.verify` to the
handler options turns the tamper and round-cap tests red; disabling the era
routing turns 5 of 16 MRTR tests red.
Closes #700
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #700. Completes the MRTR story started in #562: the client learned both dialects in phase 3 (#699), this teaches the server surface the modern one without withdrawing the old.
Why the issue's original framing was wrong
#700 was first written as "emit the spec's
inputRequestsmap instead of omadia's array". Two measurements killed that:publicMcpServer.tsran the v1 SDK, which never answersserver/discover. Every connection to it therefore negotiated the legacy era, whatever the client supported. On that era a v2 client stripsresultTypewhen it decodes the reply as a complete result, so MRTR was invisible to it — regardless of the body:{content, inputRequests, message}—resultTypegone{content, requestState, inputRequests}—resultTypegone-32603 … each inputRequests entry must be an embedded elicitation/create, sampling/createMessage, or roots/list request. The same wall feat(#562): serve the loopback MCP server on the v2 SDK family (phase 1) #695 hit. So a straight port would have withdrawn the contractmiddleware/src/mcp/README.mddocuments and existing integrations are built on.The era, not the dialect, was the blocker — and the port was a breaking change rather than a prerequisite to schedule.
What this does instead
Two SDK generations behind one path, routed by
isLegacyRequest. Legacy-classified traffic goes to the existing v1 wiring, byte for byte. Everything else goes tocreateMcpHandler(…, { legacy: 'reject' }). This is the SDK's own documented composition for "keep an existing legacy deployment serving 2025 traffic next to a strict modern endpoint", not a workaround.Both legs share
listToolsFor/callToolFor, so the four authorization gates (binding, allowlist, scope, privacy) are not reimplemented per era and cannot drift apart. Only the MRTR dialect and the round accounting differ.inputRequestsarrayelicitation/createarguments.inputResponsesinputResponsesparaminputResponsesbeing presentrequestStateA tool cannot tell which era its caller spoke. It still asks with the
_pendingInputRequestsentinel and still receives a flatinputResponsesobject; all translation stops at the endpoint. That is asserted, not assumed.requestStateis integrity-protectedThe spec makes this the server author's job and the SDK provides no default. HMAC-SHA256 via the SDK's own codec over a vault-persisted key (so any instance behind the load balancer verifies what another minted — a per-process key would fail retries only when they land elsewhere, intermittently and under load), bound to the API key and the method, one-hour TTL because a human is typing. A modified, expired or borrowed state is refused with the frozen
-32602.It also replaces the 2025 dialect's guessable bounce cap: there the round is inferred from the arguments, so a caller that strips
inputResponsesgets a fresh card forever. Here the counter is under the endpoint's own MAC.The trap worth naming:
requestState.verifyandinputRequired.legacyShimbelong on the server options. Passing either tocreateMcpHandlercompiles, runs, and silently does nothing — a tampered state is then accepted while the endpoint looks perfectly healthy. Found by measurement, not review.Known gap, recorded rather than papered over
The revision's elicitation schema admits only
email,date,urianddate-timestring formats and has no masked-input concept. A field a tool markedsecretis therefore named in server-authored prose rather than flagged in the schema. Emitting apasswordformat anyway would produce a request a conforming client rejects — a missing hint traded for a broken call. Documented in the endpoint README so integrators are not surprised.Mutation checks
dist/rebuilt before each run.requestState.verifyto the handler optionsCoverage
test/publicMcp/publicMcpInputRequired.test.ts— the 2025-era half, unmodified.test/publicMcp/publicMcpModernMrtr.test.ts(new) — a real@modelcontextprotocol/client@2against the same mounted route: era is asserted, the embedded elicitation andrequestStateare asserted, the retry completes, a tampered state is refused, and the signed round cap holds against a retry carrying no answers at all.Verification
npm run build— cleannpm run typecheck— cleannpm run typecheck:test— 406 known errors, no regressions (baseline 406)npm run lint— cleannpm test— 6421 pass / 0 fail / 12 skipped (6433 tests, 1343 suites)Closes #700
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.