Skip to content

feat(#700): serve MRTR to 2026-07-28 clients on the public MCP endpoint - #704

Merged
Weegy merged 2 commits into
mainfrom
feat/700-public-mcp-modern-mrtr
Aug 14, 2026
Merged

feat(#700): serve MRTR to 2026-07-28 clients on the public MCP endpoint#704
Weegy merged 2 commits into
mainfrom
feat/700-public-mcp-modern-mrtr

Conversation

@Weegy

@Weegy Weegy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 inputRequests map instead of omadia's array". Two measurements killed that:

  1. publicMcpServer.ts ran the v1 SDK, which never answers server/discover. Every connection to it therefore negotiated the legacy era, whatever the client supported. On that era a v2 client strips resultType when it decodes the reply as a complete result, so MRTR was invisible to it — regardless of the body:
Server emits Negotiated era What a modern client receives
the array form legacy {content, inputRequests, message}resultType gone
the spec map form legacy {content, requestState, inputRequests}resultType gone
  1. A v2 server refuses to emit omadia's array dialect at all, on either era: -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 contract middleware/src/mcp/README.md documents 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 to createMcpHandler(…, { 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.

2025-era 2026-07-28
the ask flat inputRequests array map with one embedded elicitation/create
the answers arguments.inputResponses top-level inputResponses param
the loop guard inferred from inputResponses being present signed requestState

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. That is asserted, not assumed.

requestState is integrity-protected

The 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 inputResponses gets a fresh card forever. Here the counter is under the endpoint's own MAC.

The trap worth naming: 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 perfectly healthy. Found by measurement, not review.

Known gap, recorded rather than papered over

The revision's elicitation schema admits only email, date, uri and date-time string formats and has no masked-input concept. A field a tool marked secret is therefore named in server-authored prose rather than flagged in the schema. Emitting a password format 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.

mutation result
move requestState.verify to the handler options tamper test and round-cap test red
disable the era routing 5 of 16 MRTR tests red

Coverage

  • test/publicMcp/publicMcpInputRequired.test.ts — the 2025-era half, unmodified.
  • test/publicMcp/publicMcpModernMrtr.test.ts (new) — a real @modelcontextprotocol/client@2 against the same mounted route: era is asserted, the embedded elicitation and requestState are 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 — clean
  • npm run typecheck — clean
  • npm run typecheck:test — 406 known errors, no regressions (baseline 406)
  • npm run lint — clean
  • npm test6421 pass / 0 fail / 12 skipped (6433 tests, 1343 suites)
  • public MCP suite specifically: 208 / 208 (203 pre-existing + 5 new)

Closes #700


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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
@Weegy Weegy self-assigned this Aug 14, 2026
@Weegy
Weegy enabled auto-merge (squash) August 14, 2026 13:02
@Weegy
Weegy merged commit 8f459dc into main Aug 14, 2026
9 checks passed
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.

MRTR interop with modern MCP clients needs the public endpoint on the v2 server family

1 participant