Skip to content

Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional#2513

Open
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/spec-3002-alignment
Open

Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional#2513
felixweinberger wants to merge 1 commit into
mainfrom
fweinberger/spec-3002-alignment

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Spec PR modelcontextprotocol/modelcontextprotocol#3002 (part of the final 2026-07-28
revision) moved serverInfo from the DiscoverResult body to the result-_meta key
io.modelcontextprotocol/serverInfo, and demoted the request envelope's clientInfo
from 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):

  • Our client required body serverInfo when parsing a DiscoverResult, so a conforming
    server's discover response failed validation, the version-negotiation probe classified
    the server as legacy, and the client attempted an initialize handshake against it —
    a hard connect failure against a modern-only server.
  • Our server rejected requests whose _meta envelope omits clientInfo, which the final
    revision explicitly allows.

What changed

  • Spec artifacts regenerated at spec commit 71e30695 (the #3002 merge): the
    2026-07-28 anchor types, the vendored schema.json twin, and the example corpus.
  • Wire schemas (2026-07-28): the final revision exactly — the envelope's
    clientInfo is optional (present-but-malformed still fails); DiscoverResult has no
    body serverInfo; result _meta is typed as the anchor's ResultMetaObject. A
    malformed _meta serverInfo value is treated as absent on receive: the spec marks the
    field self-reported, unverified, and display-only ("clients SHOULD NOT use it to
    change their behavior"), so a bad identity stamp never invalidates a result.
  • Server: every 2026-era response gets the _meta serverInfo stamp (spec SHOULD;
    a handler-authored value wins; the 2025-era wire is untouched) — including the
    entry-built subscriptions/listen graceful-close results, whose _meta carries the
    identity next to the subscription id (SubscriptionsListenResultMeta extends
    ResultMetaObject).
  • Client: still sends clientInfo on every request (SHOULD). Reads server identity
    from the discover result's _meta only. A server that stamps no identity is
    anonymous: the connection works, getServerVersion() is undefined, and the response
    cache partitions under a per-connection surrogate so identity-less servers can never
    share cache entries.
  • Types and public schemas: DiscoverResult no longer declares serverInfo;
    RequestMetaEnvelope['io.modelcontextprotocol/clientInfo'] is optional; new public
    constant SERVER_INFO_META_KEY; new ResultMetaObjectSchema in
    @modelcontextprotocol/core (result _meta now validates against it, and
    SubscriptionsListenResultMetaSchema extends it), with the matching
    ResultMetaObject type.
  • Conformance baseline: the pinned referee (0.2.0-alpha.9) still asserts the OLD
    shape, so server-stateless is expected-failed on exactly three checks
    (sep-2575-request-meta-invalid-missing-client-info, the missing-client-info
    iteration of sep-2575-http-server-meta-invalid-400, and
    sep-2575-server-implements-discover, which requires body serverInfo). The entries
    burn when the pin bumps to a referee release that incorporates #3002.

How Has This Been Tested?

  • Regression fixture: the exact discover response a go-sdk v1.7.0-pre.3 server sends
    (serverInfo in _meta, no body field) — classifies modern now, misclassified
    legacy before this change. A malformed _meta serverInfo also stays modern.
  • Server e2e: discover carries identity in _meta and no body field; requests without
    clientInfo are served; every modern response is stamped; 2025-era responses never
    are.
  • Client e2e: identity resolves from _meta; a stray body serverInfo is ignored
    (anonymous connect); a persisted stale blob carrying a body serverInfo connects
    with anonymous identity.
  • Full suite: typecheck / lint / unit + integration + e2e / conformance (all six CI
    legs) / docs:check / docs:examples — all green.

Breaking Changes

DiscoverResult no longer declares serverInfo, and RequestMetaEnvelope's
clientInfo is optional. Code that read discover.serverInfo moves to
client.getServerVersion() (or the _meta key directly). See
docs/migration/support-2026-07-28.md.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@felixweinberger
felixweinberger requested a review from a team as a code owner July 17, 2026 15:53
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2da01e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major
@modelcontextprotocol/codemod Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2513

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2513

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2513

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2513

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2513

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2513

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2513

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2513

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2513

commit: 6eb7046

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/server/src/server/server.ts Outdated
Comment thread docs/advanced/gateway.md
Comment thread .changeset/spec-3002-servinfo-meta.md Outdated
Comment thread packages/server/src/server/server.ts
@felixweinberger
felixweinberger force-pushed the fweinberger/spec-3002-alignment branch from ea05888 to d2da01e Compare July 17, 2026 17:35
… 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.
@felixweinberger
felixweinberger force-pushed the fweinberger/spec-3002-alignment branch from d2da01e to 6eb7046 Compare July 17, 2026 17:37
Comment on lines +101 to +112
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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):

  1. A 2026-era peer (or a gateway that mangles _meta) answers tools/call with _meta: { 'io.modelcontextprotocol/serverInfo': 'bogus' } (or { name: 'peer', version: 123 }ImplementationSchema requires version: z.string()).
  2. Protocol.connect's onmessage (protocol.ts:803-811) tests isJSONRPCResultResponsefalse; every other guard also fails → _onerror('Unknown message type: …') and the frame is dropped. The pending request's response handler never runs, so callTool hangs until RequestTimeout (default 60s) — a silent hang, not a clean validation error. The lenient wire-schema .catch(undefined) in the codec never runs because _onresponse is never reached.
  3. The probe window (versionNegotiation.ts:203-207) gates probe-reply recognition on the same guard, before classifyProbeOutcome and before the lenient wire schema. A fully conforming DiscoverResult whose 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 attempts initialize against 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.

Comment thread packages/server/src/server/listenRouter.ts

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

1 participant