Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional#2513
Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional#2513felixweinberger wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: d2da01e The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
There was a problem hiding this comment.
Beyond the inline doc-prose nits, two candidate issues were examined and ruled out: (1) whether Client.discover() re-probing leaves getServerVersion() and the cache partition stale — it only ever updated _discoverResult, a pre-existing behavior this PR does not change (the PR only alters how _serverVersion is derived at connect time); (2) the stampServerInfoMeta / probe-classifier interplay (malformed _meta stamp, handler-authored value, 2025 never-stamp) is pinned by the new encodeContract and probeFixtureCorpus tests and behaves as documented.
Extended reasoning...
Bugs were found (three doc/prose nits, posted inline), so per policy this is an informational note only, not a verdict. The core wire changes (envelope clientInfo optionality, DiscoverResult serverInfo relocation to _meta, the encode-seam stamp, and the transitional dual emission) are extensively test-pinned in this diff; the ruled-out items above were re-checked against packages/client/src/client/client.ts and the new test suites. This PR is large and touches the protocol wire layer with breaking public-type changes, so human maintainer review is still expected.
1f79483 to
ea05888
Compare
ea05888 to
d2da01e
Compare
… clientInfo optional The final 2026-07-28 revision (spec PR #3002) moved serverInfo from the DiscoverResult body to _meta['io.modelcontextprotocol/serverInfo'] and demoted the request envelope's clientInfo from required to SHOULD. We shipped the pre-#3002 shape in both directions: the client hard-rejected a conforming server's discover response (misclassifying it as legacy and attempting initialize), and the server rejected requests without clientInfo. This models the final revision exactly — no pre-#3002 shape support in either direction. - Regenerate the 2026-07-28 anchor types, schema.json twin, and example corpus at spec commit 71e30695. - Wire schemas: envelope clientInfo optional (malformed still rejected); DiscoverResult has no body serverInfo; result _meta typed as the anchor's ResultMetaObject. A malformed _meta serverInfo is treated as absent on receive — the spec marks the field self-reported, unverified, and display-only, so a bad identity stamp never invalidates a result. - Server: stamp _meta serverInfo on every 2026-era result (handler-authored value wins; the 2025-era wire is untouched) — including the entry-built subscriptions/listen graceful-close results (SubscriptionsListenResultMeta extends ResultMetaObject). - Public schemas (@modelcontextprotocol/core): new ResultMetaObjectSchema; result _meta validates against it and SubscriptionsListenResultMetaSchema extends it, with the matching ResultMetaObject type. - Client: still sends clientInfo; reads identity from _meta only. An identity-less server is anonymous: getServerVersion() is undefined and the response cache partitions under a per-connection surrogate so anonymous servers never share cache entries. - Conformance: the pinned referee (0.2.0-alpha.9) still asserts the old shape, so server-stateless is expected-failed on exactly three checks until the pin bumps to a #3002-aware release. - Tests: the exact go-sdk v1.7.0-pre.3 discover response now classifies modern (was legacy); new e2e requirements for the identity stamp and clientInfo optionality; a stale persisted blob with a body serverInfo connects with anonymous identity.
d2da01e to
6eb7046
Compare
| export const ResultMetaObjectSchema = z.looseObject({ | ||
| get [SERVER_INFO_META_KEY]() { | ||
| return ImplementationSchema.optional(); | ||
| } | ||
| }); | ||
|
|
||
| export const ResultSchema = z.looseObject({ | ||
| /** | ||
| * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) | ||
| * for notes on `_meta` usage. | ||
| */ | ||
| _meta: RequestMetaSchema.optional() | ||
| _meta: ResultMetaObjectSchema.optional() |
There was a problem hiding this comment.
🔴 The documented receive-side leniency for a malformed _meta['io.modelcontextprotocol/serverInfo'] ("treated as absent on receive — a bad identity stamp never invalidates a result") was added only to the 2026 wire schema, but the neutral ResultMetaObjectSchema here types the key as ImplementationSchema.optional() without .catch(undefined) — and since JSONRPCResultResponseSchema embeds ResultSchema, isJSONRPCResultResponse() now returns false for any response carrying a malformed stamp. That guard gates both Protocol.connect's message dispatch (protocol.ts:803-811 — the response is dropped as "Unknown message type" and the request hangs until timeout) and the version-negotiation probe window (versionNegotiation.ts:203-207 — connect() times out against a conforming modern server, and on stdio the timeout verdict is 'legacy' → initialize against a modern-only server, the exact regression this PR fixes). Fix: give the neutral ResultMetaObjectSchema serverInfo key the same .catch(undefined) as the wire ResultMetaSchema (this also covers SubscriptionsListenResultMetaSchema at line 983); fixing only the codec decode path would not help, because the frame dies at message classification before any codec runs.
Extended reasoning...
What the bug is. This PR's stated design — pinned in the changeset, the migration doc, and the buildSchemas.ts comment — is that a malformed _meta['io.modelcontextprotocol/serverInfo'] is treated as absent on receive, because the spec marks the field self-reported, unverified, and display-only ("clients SHOULD NOT use it to change their behavior"). The leniency was implemented only in the 2026 wire schema (buildSchemas.ts ResultMetaSchema: ImplementationSchema.optional().catch(undefined)). The neutral ResultMetaObjectSchema added in this file (packages/core/src/schemas.ts:101-105) types the same key as ImplementationSchema.optional() without the .catch(undefined) — and in Zod, a declared key on a looseObject that fails its schema fails the whole parse.
Why it strikes at message classification, not result validation. ResultSchema._meta now uses ResultMetaObjectSchema (line 112 of this diff; pre-PR it was the fully-loose RequestMetaSchema, under which any value passed as an untyped catchall), and JSONRPCResultResponseSchema (schemas.ts:150-156) embeds ResultSchema. isJSONRPCResultResponse() (packages/core-internal/src/types/guards.ts) is a safeParse against that schema, and it is the guard every inbound-dispatch point routes by. So a result response whose only defect is a malformed serverInfo stamp matches no classification branch and is silently dropped.
Step-by-step proof (empirically re-verified against this checkout — ResultSchema.safeParse({_meta:{'io.modelcontextprotocol/serverInfo':'bogus'}}) fails, and isJSONRPCResultResponse returns false for a response carrying it, true for a well-formed one):
- A 2026-era peer (or a gateway that mangles
_meta) answerstools/callwith_meta: { 'io.modelcontextprotocol/serverInfo': 'bogus' }(or{ name: 'peer', version: 123 }—ImplementationSchemarequiresversion: z.string()). Protocol.connect'sonmessage(protocol.ts:803-811) testsisJSONRPCResultResponse→false; every other guard also fails →_onerror('Unknown message type: …')and the frame is dropped. The pending request's response handler never runs, socallToolhangs untilRequestTimeout(default 60s) — a silent hang, not a clean validation error. The lenient wire-schema.catch(undefined)in the codec never runs because_onresponseis never reached.- The probe window (
versionNegotiation.ts:203-207) gates probe-reply recognition on the same guard, beforeclassifyProbeOutcomeand before the lenient wire schema. A fully conformingDiscoverResultwhose only defect is a bad identity stamp is dropped with zero bytes written back; the probe times out. On HTTP that is a typed connect failure; on stdio the timeout verdict is 'legacy', so the client attemptsinitializeagainst a modern-only server — the exact hard-connect-failure regression class this PR's description says it fixes. Multiple independent verifiers reproduced both surfaces end-to-end with scripted transports (probe: "Version negotiation probe timed out" while the wire trace shows the server answered; post-connect:onerror'Unknown message type' + request timeout).
Why the PR's own leniency tests miss it. The corpus test "recognizer: DiscoverResult with a malformed _meta serverInfo → still modern" (probeFixtureCorpus.test.ts) feeds classifyProbeOutcome a pre-built ProbeOutcome, bypassing the probe window's guard; encodeContract.test.ts calls Wire2026DiscoverResultSchema.safeParse directly. Neither routes a malformed stamp through transport.onmessage, which is where the frame dies. So CI is green while the wire behavior contradicts the PR's pinned invariant.
Impact and blast radius. Beyond Protocol dispatch and the probe, the same guard gates SSE stream accounting in the client HTTP transport (streamableHttp.ts:744) and server-side response routing (perRequestTransport.ts, serveStdio.ts, server streamableHttp.ts). And because the guard is era-neutral, this is also a 2025-era receive regression: pre-PR, ResultSchema._meta was the loose RequestMetaSchema, so a legacy response carrying a bad value under the reserved key was delivered; now it is silently dropped — on the era the changeset says is untouched. This also matches this repo's REVIEW.md "Schema Compliance" recurring catch: over-strict Zod on protocol schemas rejecting spec-valid-adjacent payloads from other SDKs.
How to fix. Give the neutral ResultMetaObjectSchema serverInfo key the same ImplementationSchema.optional().catch(undefined) as the wire ResultMetaSchema (one line; SubscriptionsListenResultMetaSchema at line 983 extends it and is covered automatically) — or leave the key untyped in the envelope-level schema. Note that fixing only the codec (decodeResult returning the wire-parsed data instead of the raw object) would not fix this: the frame is dropped at message classification before any codec runs. One earlier verification pass described a milder surface (probe classifies modern, then typed requests fail validation cleanly) — five subsequent independent verifiers established empirically that the failure fires earlier, at the classification guard, producing hangs and probe timeouts; the refutation filed against the companion report disputed only its duplicate status, not the mechanism, and this merged report consolidates both.
There was a problem hiding this comment.
Beyond the previously posted inline findings (which still stand — no new commits since), this run also examined the anonymous-server cache-identity path in Client._deriveServerIdentity and ruled it out: the fallback order (serverInfo → transport sessionId → per-connection surrogate) matches the documented contract, the sessionId fallback is pre-existing behavior, and partition collision-safety comes from the cache's JSON-array encoding rather than the identity value itself.
Extended reasoning...
This run's bug hunters surfaced one candidate — that the anonymous-server cache identity uses the server-controlled sessionId instead of the promised per-connection surrogate — and verification refuted it: the JSDoc on _deriveServerIdentity (packages/client/src/client/client.ts:1301-1319) explicitly documents sessionId as the intermediate fallback before the surrogate, that ordering predates this PR, and the surrogate is only needed for the sessionId-less anonymous case introduced by #3002. Not approving: this is a large breaking wire-protocol change and the prior run's finding on ResultMetaObjectSchema in packages/core/src/schemas.ts (missing .catch(undefined) receive-side leniency, affecting message classification) is still present in the current code with no commits since it was posted.
Spec PR modelcontextprotocol/modelcontextprotocol#3002 (part of the final 2026-07-28
revision) moved
serverInfofrom theDiscoverResultbody to the result-_metakeyio.modelcontextprotocol/serverInfo, and demoted the request envelope'sclientInfofrom required to SHOULD. This PR updates the 2026-07-28 wire implementation to match —
the final revision is modeled exactly, with no pre-#3002 shape support in either
direction.
Motivation and Context
We shipped the pre-#3002 shape in both directions, which breaks interop with SDKs that
follow the final revision (go-sdk v1.7.0-pre.3+, csharp):
serverInfowhen parsing aDiscoverResult, so a conformingserver's discover response failed validation, the version-negotiation probe classified
the server as legacy, and the client attempted an
initializehandshake against it —a hard connect failure against a modern-only server.
_metaenvelope omitsclientInfo, which the finalrevision explicitly allows.
What changed
71e30695(the #3002 merge): the2026-07-28 anchor types, the vendored schema.json twin, and the example corpus.
clientInfois optional (present-but-malformed still fails);DiscoverResulthas nobody
serverInfo; result_metais typed as the anchor'sResultMetaObject. Amalformed
_metaserverInfo value is treated as absent on receive: the spec marks thefield self-reported, unverified, and display-only ("clients SHOULD NOT use it to
change their behavior"), so a bad identity stamp never invalidates a result.
_metaserverInfo stamp (spec SHOULD;a handler-authored value wins; the 2025-era wire is untouched) — including the
entry-built
subscriptions/listengraceful-close results, whose_metacarries theidentity next to the subscription id (
SubscriptionsListenResultMetaextendsResultMetaObject).clientInfoon every request (SHOULD). Reads server identityfrom the discover result's
_metaonly. A server that stamps no identity isanonymous: the connection works,
getServerVersion()isundefined, and the responsecache partitions under a per-connection surrogate so identity-less servers can never
share cache entries.
DiscoverResultno longer declaresserverInfo;RequestMetaEnvelope['io.modelcontextprotocol/clientInfo']is optional; new publicconstant
SERVER_INFO_META_KEY; newResultMetaObjectSchemain@modelcontextprotocol/core(result_metanow validates against it, andSubscriptionsListenResultMetaSchemaextends it), with the matchingResultMetaObjecttype.0.2.0-alpha.9) still asserts the OLDshape, so
server-statelessis expected-failed on exactly three checks(
sep-2575-request-meta-invalid-missing-client-info, the missing-client-infoiteration of
sep-2575-http-server-meta-invalid-400, andsep-2575-server-implements-discover, which requires body serverInfo). The entriesburn when the pin bumps to a referee release that incorporates #3002.
How Has This Been Tested?
(serverInfo in
_meta, no body field) — classifiesmodernnow, misclassifiedlegacybefore this change. A malformed_metaserverInfo also staysmodern._metaand no body field; requests withoutclientInfoare served; every modern response is stamped; 2025-era responses neverare.
_meta; a stray bodyserverInfois ignored(anonymous connect); a persisted stale blob carrying a body
serverInfoconnectswith anonymous identity.
legs) / docs:check / docs:examples — all green.
Breaking Changes
DiscoverResultno longer declaresserverInfo, andRequestMetaEnvelope'sclientInfois optional. Code that readdiscover.serverInfomoves toclient.getServerVersion()(or the_metakey directly). Seedocs/migration/support-2026-07-28.md.Types of changes
Checklist