diff --git a/.changeset/spec-3002-servinfo-meta.md b/.changeset/spec-3002-servinfo-meta.md new file mode 100644 index 0000000000..b4eba489b4 --- /dev/null +++ b/.changeset/spec-3002-servinfo-meta.md @@ -0,0 +1,17 @@ +--- +'@modelcontextprotocol/core-internal': minor +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes from required to SHOULD. + +Before this change the SDK shipped the pre-#3002 shape in both directions: the client hard-rejected a conforming server's `DiscoverResult` (missing body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake against it — a hard connect failure against a modern-only server such as go-sdk v1.7.0-pre.3), and the server rejected conforming clients that omit `clientInfo`. + +Now: + +- The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (a present-but-malformed value still fails validation). +- Servers stamp `_meta['io.modelcontextprotocol/serverInfo']` on every 2026-era response (spec SHOULD; a handler-authored value wins, the 2025-era wire is untouched). This includes the entry-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`. +- Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only). +- Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`). diff --git a/docs/advanced/gateway.md b/docs/advanced/gateway.md index f567c69847..708b7278dc 100644 --- a/docs/advanced/gateway.md +++ b/docs/advanced/gateway.md @@ -44,7 +44,7 @@ The recorded value is the server's whole advertisement — supported versions, c cacheScope: 'private', supportedVersions: [ '2026-07-28' ], capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'gateway-target', version: '1.0.0' }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'gateway-target', version: '1.0.0' } }, resultType: 'complete' } ``` diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index fdc221d930..e098ed40a7 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -26,6 +26,7 @@ below. - [Auth on 2026-07-28](#auth-on-2026-07-28) - [Per-era wire codecs](#per-era-wire-codecs) - [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) +- [Server identity in result `_meta`; `clientInfo` demoted to SHOULD](#server-identity-in-result-_meta-clientinfo-demoted-to-should) - [Multi-round-trip requests](#multi-round-trip-requests) - [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [`subscriptions/listen`](#subscriptionslisten) @@ -417,6 +418,35 @@ the instance's negotiated era. --- +## Server identity in result `_meta`; `clientInfo` demoted to SHOULD + +The final 2026-07-28 revision (spec PR #3002) moved server identity out of the +`DiscoverResult` body: servers identify themselves via +`_meta['io.modelcontextprotocol/serverInfo']` (constant `SERVER_INFO_META_KEY`) on +every response, and the per-request envelope's `clientInfo` is a SHOULD instead of a +requirement. + +What the SDK does: + +- **Server.** Every 2026-era response gets the `_meta` serverInfo stamp (a + handler-authored value wins; 2025-era responses are untouched) — including the + entry-built `subscriptions/listen` graceful-close results, whose `_meta` carries + the identity next to the subscription id. Requests without `clientInfo` are + served; a present-but-malformed value is still rejected. +- **Client.** `clientInfo` is still sent on every request (the spec SHOULD). + `getServerVersion()` reads the discover result's `_meta`. A server that stamps no + identity is simply anonymous: the connection works, `getServerVersion()` is + `undefined`, and the response cache partitions under a per-connection surrogate. +- **Types.** `DiscoverResult` has no `serverInfo` member, and `RequestMetaEnvelope`'s + `clientInfo` is optional. Code that read `discover.serverInfo` moves to + `client.getServerVersion()` (or the `_meta` key directly). + +`serverInfo`/`clientInfo` are self-reported and intended for display, logging, and +debugging — do not use them for behavior or security decisions. A malformed `_meta` +serverInfo value is treated as absent on receive, per the same clause. + +--- + ## Multi-round-trip requests The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers diff --git a/examples/gateway/client.ts b/examples/gateway/client.ts index 9ab5ee9771..ee4e7f85f6 100644 --- a/examples/gateway/client.ts +++ b/examples/gateway/client.ts @@ -38,7 +38,9 @@ check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28'); const discovered = bootstrap.getDiscoverResult(); check.ok(discovered, 'bootstrap connect populated getDiscoverResult()'); -check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' }); +// Server identity: getServerVersion() resolves it from the discover result's +// `_meta['io.modelcontextprotocol/serverInfo']` (spec PR #3002). +check.deepEqual(bootstrap.getServerVersion(), { name: 'gateway-target', version: '1.0.0' }); // The probe was the only request so far; the request_count call is the // second. (createMcpHandler builds one server instance per request.) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9f678cf377..b2dfd99e17 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -61,6 +61,7 @@ import { isJSONRPCErrorResponse, isJSONRPCRequest, isModernProtocolVersion, + isSpecType, legacyProtocolVersions, ListChangedOptionsBaseSchema, mergeCapabilities, @@ -74,6 +75,7 @@ import { scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, + SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; @@ -84,6 +86,18 @@ import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } fro import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation'; import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation'; +/** + * The server identity a `DiscoverResult` carries in + * `_meta['io.modelcontextprotocol/serverInfo']` (a spec SHOULD — absent means + * the server offered no identity). Runtime-validated with the spec-type + * guard because `connect({ prior })` accepts caller-supplied values that + * never saw a wire parse. + */ +function serverInfoFromDiscover(discover: DiscoverResult): Implementation | undefined { + const fromMeta = discover._meta?.[SERVER_INFO_META_KEY]; + return isSpecType.Implementation(fromMeta) ? fromMeta : undefined; +} + /** * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. * @@ -1122,7 +1136,7 @@ export class Client extends Protocol { } this._serverCapabilities = result.discover.capabilities; - this._serverVersion = result.discover.serverInfo; + this._serverVersion = serverInfoFromDiscover(result.discover); this._cache.setServerIdentity(this._deriveServerIdentity(transport)); this._instructions = result.discover.instructions; this._discoverResult = result.discover; @@ -1251,7 +1265,7 @@ export class Client extends Protocol { this._discoverResult = discover; this._serverCapabilities = discover.capabilities; - this._serverVersion = discover.serverInfo; + this._serverVersion = serverInfoFromDiscover(discover); this._cache.setServerIdentity(this._deriveServerIdentity(transport)); this._instructions = discover.instructions; this._negotiatedProtocolVersion = version; @@ -1275,7 +1289,10 @@ export class Client extends Protocol { } /** - * After initialization has completed, this will be populated with information about the server's name and version. + * The connected server's self-reported name and version, when it + * identified itself: required on the legacy `initialize` result; a spec + * SHOULD in the discover result's `_meta` on 2026-07-28, so a successful + * modern connect against an anonymous server leaves this `undefined`. */ getServerVersion(): Implementation | undefined { return this._serverVersion; @@ -1283,16 +1300,22 @@ export class Client extends Protocol { /** * The connected server's identity for response-cache partitioning. The - * `serverInfo` `name@version` pair when available (the spec requires it on - * both `initialize` and `server/discover`); falls back to the transport's - * `sessionId` otherwise. The value itself is server-controlled — the - * collision-safety of the storage partition comes from + * `serverInfo` `name@version` pair when available (required on + * `initialize`; a SHOULD in the discover result's `_meta` since spec PR + * #3002); falls back to the transport's `sessionId`, then to a + * per-connection surrogate. The surrogate matters since #3002 made + * identity optional: without it, two identity-less servers reached over + * sessionId-less transports would share the cache's pre-connect `''` + * partition and read each other's entries — no stable identity means no + * cross-connection cache reuse. The value itself is server-controlled — + * the collision-safety of the storage partition comes from * {@linkcode ClientResponseCache}'s JSON-array encoding around it, not * from any character it does or does not contain. */ private _deriveServerIdentity(transport: Transport): string { const v = this._serverVersion; - return v === undefined ? (transport.sessionId ?? '') : `${v.name}@${v.version}`; + if (v !== undefined) return `${v.name}@${v.version}`; + return transport.sessionId ?? `anonymous:${Date.now()}-${Math.random().toString(36).slice(2)}`; } /** diff --git a/packages/client/src/client/responseCache.ts b/packages/client/src/client/responseCache.ts index 50abd37bc8..e6103d34ce 100644 --- a/packages/client/src/client/responseCache.ts +++ b/packages/client/src/client/responseCache.ts @@ -299,13 +299,13 @@ export class ClientResponseCache { */ private _toolOutputValidatorIndex?: { stamp: number; byName: Map }; /** - * The connected server's identity (`serverInfo.name@version`, or a - * transport-supplied surrogate). Set by the `Client` immediately after a - * successful connect; `''` is the pre-connect sentinel. Every storage - * partition is derived from this (see `_partitionFor`), so two - * clients sharing one store but connected to different servers never - * collide on `tools/list` and a server cannot read another server's - * `'public'` entries. + * The connected server's identity (`serverInfo.name@version`, the + * transport's `sessionId`, or a client-generated per-connection + * surrogate). Set by the `Client` immediately after a successful connect; + * `''` is the pre-connect sentinel. Every storage partition is derived + * from this (see `_partitionFor`), so two clients sharing one store but + * connected to different servers never collide on `tools/list` and a + * server cannot read another server's `'public'` entries. */ private _serverIdentity = ''; @@ -347,10 +347,15 @@ export class ClientResponseCache { /** * Record the connected server's identity. Called by `Client` immediately - * after a successful connect (`serverInfo.name@version`, or the - * transport's `sessionId` when `serverInfo` is unavailable). Every - * partition derived after this call is scoped to this identity; entries - * written under the pre-connect `''` sentinel are no longer reachable. + * after a successful connect: `serverInfo.name@version` when the server + * identified itself, else the transport's `sessionId`, else a + * client-generated per-connection surrogate (`serverInfo` is a spec + * SHOULD on 2026-07-28, so anonymous servers exist). Surrogate-keyed + * partitions are NOT stable across reconnects — no identity means no + * cross-connection cache reuse, and a shared long-lived store should + * bound its own size accordingly. Every partition derived after this + * call is scoped to this identity; entries written under the pre-connect + * `''` sentinel are no longer reachable. */ setServerIdentity(identity: string): void { this._serverIdentity = identity; diff --git a/packages/client/test/client/connectPrior.test.ts b/packages/client/test/client/connectPrior.test.ts index 0060e04dd7..9de9ae4b34 100644 --- a/packages/client/test/client/connectPrior.test.ts +++ b/packages/client/test/client/connectPrior.test.ts @@ -48,7 +48,7 @@ class ScriptedTransport implements Transport { const discoverResult = (supportedVersions: string[]): DiscoverResult => ({ supportedVersions, capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'persisted-server', version: '1.0.0' }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'persisted-server', version: '1.0.0' } }, instructions: 'persisted instructions' }); @@ -149,7 +149,7 @@ describe('getDiscoverResult() round-trip', () => { result: { supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'probed-server', version: '2.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'probed-server', version: '2.0.0' } } } }); } @@ -157,7 +157,7 @@ describe('getDiscoverResult() round-trip', () => { const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } }); await bootstrap.connect(bootstrapTransport); const probed = bootstrap.getDiscoverResult(); - expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' }); + expect(bootstrap.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' }); expect(probed?.supportedVersions).toEqual([MODERN]); await bootstrap.close(); // close() clears per-connection state. @@ -190,17 +190,19 @@ describe('getDiscoverResult() round-trip', () => { cacheScope: 'public', supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'rediscovered', version: '3.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'rediscovered', version: '3.0.0' } } } }); } }); const client = new Client({ name: 'c', version: '0' }); await client.connect(transport, { prior: modernPrior([MODERN]) }); - expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server'); + const metaName = (r?: { _meta?: Record }) => + (r?._meta?.['io.modelcontextprotocol/serverInfo'] as { name?: string } | undefined)?.name; + expect(metaName(client.getDiscoverResult())).toBe('persisted-server'); const fresh = await client.discover(); - expect(fresh.serverInfo.name).toBe('rediscovered'); - expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered'); + expect(metaName(fresh)).toBe('rediscovered'); + expect(metaName(client.getDiscoverResult())).toBe('rediscovered'); await client.close(); }); }); @@ -348,4 +350,24 @@ describe('connect({ prior }) — malformed persisted blobs (runtime hardening)', ); expect(transport.sent).toHaveLength(0); }); + + test('a stale blob with a body serverInfo (not a spec field) connects with anonymous identity — the body is never read', async () => { + // The 2026-07-28 revision has no body serverInfo on DiscoverResult: + // identity travels in _meta (spec PR #3002). A persisted blob carrying + // the non-spec body member is still a schema-valid DiscoverResult + // (loose results tolerate unknown members), so validatePrior accepts + // it — but the identity read consults _meta only, so the connection is + // anonymous rather than adopting a field the spec does not define. + const transport = new ScriptedTransport(); + const client = new Client({ name: 'worker', version: '0' }); + + const blob = + '{"kind":"modern","discover":{"supportedVersions":["2026-07-28"],"capabilities":{},' + + '"serverInfo":{"name":"stale-server","version":"0.4.0"}}}'; + await client.connect(transport, { prior: JSON.parse(blob) as PriorDiscovery }); + expect(transport.sent).toHaveLength(0); + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getServerVersion()).toBeUndefined(); + await client.close(); + }); }); diff --git a/packages/client/test/client/discover.test.ts b/packages/client/test/client/discover.test.ts index af5c4ab318..316e56467f 100644 --- a/packages/client/test/client/discover.test.ts +++ b/packages/client/test/client/discover.test.ts @@ -44,7 +44,7 @@ const discoverBody = { cacheScope: 'public', supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'modern-server', version: '1.0.0' }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'modern-server', version: '1.0.0' } }, instructions: 'modern instructions' }; @@ -78,7 +78,7 @@ describe('Client.discover()', () => { const advertisement = await client.discover(); expect(advertisement.supportedVersions).toEqual([MODERN]); - expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' }); + expect(advertisement._meta?.['io.modelcontextprotocol/serverInfo']).toEqual({ name: 'modern-server', version: '1.0.0' }); expect(advertisement.instructions).toBe('modern instructions'); await client.close(); @@ -99,3 +99,37 @@ describe('Client.discover()', () => { await client.close(); }); }); + +describe('server identity from a DiscoverResult (#3002: _meta only)', () => { + const base = { resultType: 'complete', ttlMs: 0, cacheScope: 'public', supportedVersions: [MODERN], capabilities: {} }; + const metaIdentity = { name: 'meta-server', version: '2.0.0' }; + + async function connectAgainst(result: Record): Promise { + const transport = new ScriptedTransport((message, t) => { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + t.reply({ jsonrpc: '2.0', id: message.id, result }); + } + }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(transport); + return client; + } + + test('serverInfo in _meta yields the identity', async () => { + const client = await connectAgainst({ ...base, _meta: { 'io.modelcontextprotocol/serverInfo': metaIdentity } }); + expect(client.getServerVersion()).toEqual(metaIdentity); + await client.close(); + }); + + test('a stray body serverInfo is ignored — identity comes from _meta only', async () => { + const client = await connectAgainst({ ...base, serverInfo: { name: 'body-only', version: '0.9.0' } }); + expect(client.getServerVersion()).toBeUndefined(); + await client.close(); + }); + + test('absent identity → undefined, connection still established (serverInfo is a SHOULD)', async () => { + const client = await connectAgainst(base); + expect(client.getServerVersion()).toBeUndefined(); + await client.close(); + }); +}); diff --git a/packages/client/test/client/envelopeAutoEmission.test.ts b/packages/client/test/client/envelopeAutoEmission.test.ts index 0ab3deecd3..4eca79cb06 100644 --- a/packages/client/test/client/envelopeAutoEmission.test.ts +++ b/packages/client/test/client/envelopeAutoEmission.test.ts @@ -47,7 +47,7 @@ async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = tr resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted-modern-server', version: '1.0.0' } } } }); } else { diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts index 786a95857f..e2d8c16dde 100644 --- a/packages/client/test/client/inputRequiredEngine.test.ts +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -45,7 +45,7 @@ async function scriptedModernServer(respondToToolCall: (request: JSONRPCRequest, resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'scripted-mrtr-server', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' } } } }); return; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 408fe1ea39..60f96be47a 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -36,7 +36,7 @@ async function scriptedModern(onListen?: (id: number | string, filter: unknown, resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, - serverInfo: { name: 'scripted', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1' } } } }); } @@ -73,7 +73,7 @@ async function scriptedModernNoAck() { resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, - serverInfo: { name: 'scripted', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1' } } } }); } @@ -249,7 +249,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } @@ -278,7 +278,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } @@ -340,7 +340,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } @@ -419,7 +419,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } @@ -554,7 +554,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } @@ -679,7 +679,7 @@ describe('Client.listen()', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } } }); } diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts index 7a567d16ed..42dd206071 100644 --- a/packages/client/test/client/mcpParamMirroring.test.ts +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -72,7 +72,7 @@ async function scriptedModernServer(pages: Tool[][], rejectFirstCall = false): P resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'scripted', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } } }); } else if (r.method === 'tools/list') { diff --git a/packages/client/test/client/modernEraInboundDrop.test.ts b/packages/client/test/client/modernEraInboundDrop.test.ts index 61d433f99d..f0b483a2f8 100644 --- a/packages/client/test/client/modernEraInboundDrop.test.ts +++ b/packages/client/test/client/modernEraInboundDrop.test.ts @@ -36,7 +36,7 @@ async function scriptedServerSide(eras: 'modern' | 'legacy') { resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: {} }, - serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted-modern-server', version: '1.0.0' } } } }); } else { diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 74d0c3248f..566a69baad 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -31,7 +31,7 @@ function classify(outcome: ProbeOutcome, context: Partial ({ supportedVersions, capabilities: { tools: {} }, - serverInfo: { name: 'fixture-server', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'fixture-server', version: '1.0.0' } } }); /** The deployed-fleet 400 body for a JSON-RPC error (server streamableHttp `createJsonErrorResponse`). */ @@ -57,7 +57,7 @@ describe('row: DiscoverResult with version overlap → modern, select from suppo expect(verdict.kind).toBe('modern'); if (verdict.kind === 'modern') { expect(verdict.discover.capabilities).toEqual({ tools: {} }); - expect(verdict.discover.serverInfo.name).toBe('fixture-server'); + expect(verdict.discover._meta?.['io.modelcontextprotocol/serverInfo']).toEqual({ name: 'fixture-server', version: '1.0.0' }); } }); }); diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts index d823b89c9c..687ca3b11f 100644 --- a/packages/client/test/client/probeFixtureCorpus.test.ts +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -53,6 +53,20 @@ const DEPLOYED_SESSION_REQUIRED_BODY = JSON.stringify({ error: { code: -32_000, message: 'Bad Request: Server not initialized' } }); +/** + * The exact discover result a go-sdk v1.7.0-pre.3 server answers the probe + * with (spec PR #3002 final 2026-07-28 shape): `serverInfo` lives in the + * result `_meta`, the body field is gone. + */ +const GO_V17_DISCOVER_RESULT = { + resultType: 'complete', + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'server', version: 'v0.0.1' } }, + ttlMs: 0, + cacheScope: 'public', + supportedVersions: ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'], + capabilities: { logging: {} } +}; + interface CorpusRow { name: string; outcome: ProbeOutcome; @@ -78,7 +92,7 @@ const CORPUS: CorpusRow[] = [ name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)', outcome: { kind: 'result', - result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + result: { supportedVersions: ['2027-01-01'], capabilities: {} } }, expected: 'legacy' }, @@ -86,7 +100,7 @@ const CORPUS: CorpusRow[] = [ name: 'T9: DiscoverResult with no mutual version + NO fallback (pin / modern-only) → typed error, never initialize', outcome: { kind: 'result', - result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } } + result: { supportedVersions: ['2027-01-01'], capabilities: {} } }, context: { fallbackAvailable: false }, expected: 'error' @@ -164,6 +178,26 @@ const CORPUS: CorpusRow[] = [ name: 'wire-real: -32601 method-not-found → legacy fallback', outcome: { kind: 'rpc-error', code: -32_601, message: 'Method not found' }, expected: 'legacy' + }, + // --- Wire-real shape C: the #3002 final-revision DiscoverResult (go v1.7.0-pre.3). + // Regression: before the #3002 alignment the wire schema required body + // `serverInfo`, so this conforming response failed parse and misclassified + // legacy — the client then attempted `initialize` against a modern server. + { + name: 'wire-real: go v1.7.0-pre.3 DiscoverResult (#3002 shape, serverInfo in _meta) → modern', + outcome: { kind: 'result', result: GO_V17_DISCOVER_RESULT }, + expected: 'modern' + }, + // A malformed _meta serverInfo is display-only material and must not + // demote a conforming DiscoverResult to legacy (receiver leniency: the + // wire schema drops the bad value instead of failing the parse). + { + name: 'recognizer: DiscoverResult with a malformed _meta serverInfo → still modern', + outcome: { + kind: 'result', + result: { ...GO_V17_DISCOVER_RESULT, _meta: { 'io.modelcontextprotocol/serverInfo': 'bogus' } } + }, + expected: 'modern' } ]; @@ -179,7 +213,7 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => { const verdict = classifyProbeOutcome( { kind: 'result', - result: { supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } } + result: { supportedVersions: [MODERN], capabilities: {} } }, baseContext ); diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index 510c6ff510..cec28ac6d8 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -345,7 +345,7 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): resultType: 'complete', supportedVersions: [MODERN], capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} }, - serverInfo: opts.serverInfo ?? { name: 'scripted', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': opts.serverInfo ?? { name: 'scripted', version: '1.0.0' } } } }); } else if (r.method === 'tools/list') { @@ -1210,7 +1210,7 @@ describe('Client honours cacheHints (SEP-2549)', () => { resultType: 'complete', supportedVersions: [MODERN], capabilities: { resources: {} }, - serverInfo: { name: 'scripted', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted', version: '1.0.0' } } } }); } else if (r.method === 'resources/read') { diff --git a/packages/client/test/client/responseCacheCodec.test.ts b/packages/client/test/client/responseCacheCodec.test.ts index 6d73941fb5..2796b4e5c8 100644 --- a/packages/client/test/client/responseCacheCodec.test.ts +++ b/packages/client/test/client/responseCacheCodec.test.ts @@ -27,7 +27,7 @@ async function connectedPair() { resultType: 'complete', supportedVersions: ['2026-07-28'], capabilities: { tools: {} }, - serverInfo: { name: 's', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1.0.0' } } } }); } else if (r.method === 'tools/list') { diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 698bccd6ee..f45d028835 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -118,7 +118,7 @@ class ScriptedTransport implements Transport { const discoverResult = (supportedVersions: string[]) => ({ supportedVersions, capabilities: {}, - serverInfo: { name: 'scripted-modern-server', version: '1.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'scripted-modern-server', version: '1.0.0' } } }); /** A scripted dual-era server: answers server/discover with a DiscoverResult and initialize like a 2025 server. */ diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts index 9b714d1125..072e9b41b9 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/specSchemaNames.ts @@ -121,6 +121,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ResourceTemplateSchema', 'ResourceUpdatedNotificationParamsSchema', 'ResourceUpdatedNotificationSchema', + 'ResultMetaObjectSchema', 'ResultSchema', 'RoleSchema', 'RootSchema', diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index bf23e2e6f7..bf985d4160 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -92,6 +92,7 @@ export { PARSE_ERROR, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, + SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY, SUPPORTED_PROTOCOL_VERSIONS, TRACEPARENT_META_KEY, diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index ffab6e8df8..0a19770082 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -10,6 +10,7 @@ import type { ElicitRequestURLParams, ElicitResult, HandlerResultTypeMap, + Implementation, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -1113,7 +1114,7 @@ export abstract class Protocol { // −32603 rather than stranding the request until timeout. let encoded: Result; try { - encoded = codec.encodeResult(request.method, result); + encoded = codec.encodeResult(request.method, result, this._outboundServerInfo()); } catch (error) { this._onerror(new Error(`Failed to encode result for ${request.method}: ${error}`)); sendErrorResponse(ProtocolErrorCode.InternalError, 'Internal error'); @@ -1752,6 +1753,19 @@ export abstract class Protocol { return handler; } + /** + * Hook for subclasses to supply the implementation identity the 2026-era + * encode seam stamps into outbound result `_meta` under + * `io.modelcontextprotocol/serverInfo` (spec PR #3002: servers SHOULD + * identify themselves on every response). The default is `undefined` — no + * stamp. Only `Server` overrides this: the key identifies the software + * producing a response, and the 2025-era codec never stamps anything + * regardless (the never-stamp guarantee). + */ + protected _outboundServerInfo(): Implementation | undefined { + return undefined; + } + /** * Removes the request handler for the given method. */ diff --git a/packages/core-internal/src/types/constants.ts b/packages/core-internal/src/types/constants.ts index 28df24d505..2e51720274 100644 --- a/packages/core-internal/src/types/constants.ts +++ b/packages/core-internal/src/types/constants.ts @@ -18,6 +18,7 @@ export { PARSE_ERROR, PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, + SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY, SUPPORTED_PROTOCOL_VERSIONS, TRACEPARENT_META_KEY, diff --git a/packages/core-internal/src/types/schemas.ts b/packages/core-internal/src/types/schemas.ts index 5e1c1e14b6..7e25d49801 100644 --- a/packages/core-internal/src/types/schemas.ts +++ b/packages/core-internal/src/types/schemas.ts @@ -124,6 +124,7 @@ export { ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, + ResultMetaObjectSchema, ResultSchema, RoleSchema, RootSchema, diff --git a/packages/core-internal/src/types/spec.types.2026-07-28.ts b/packages/core-internal/src/types/spec.types.2026-07-28.ts index a8ec4f8077..f4430b850f 100644 --- a/packages/core-internal/src/types/spec.types.2026-07-28.ts +++ b/packages/core-internal/src/types/spec.types.2026-07-28.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: f68d864a813754e188c6df52dcc5772a12f96c63 + * Last updated from commit: 71e306956a4959c9655e5036be215d41986596e6 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2026-07-28 @@ -82,12 +82,19 @@ export interface RequestMetaObject extends MetaObject { */ 'io.modelcontextprotocol/protocolVersion': string; /** - * Identifies the client software making the request. Required. + * Identifies the client software making the request. Clients SHOULD + * include this field on every request unless specifically configured not + * to do so. * * The {@link Implementation} schema requires `name` and `version`; other * fields are optional. + * + * The value is self-reported by the client and is not verified by the + * protocol. It is intended for display, logging, and debugging. Servers + * SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + * security decisions. */ - 'io.modelcontextprotocol/clientInfo': Implementation; + 'io.modelcontextprotocol/clientInfo'?: Implementation; /** * The client's capabilities for this specific request. Required. * @@ -133,6 +140,30 @@ export interface NotificationMetaObject extends MetaObject { 'io.modelcontextprotocol/subscriptionId'?: RequestId; } +/** + * Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface ResultMetaObject extends MetaObject { + /** + * Identifies the server software producing the response. Servers SHOULD + * include this field on every response unless specifically configured not + * to do so. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + * + * The value is self-reported by the server and is not verified by the + * protocol. It is intended for display, logging, and debugging. Clients + * SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + * security decisions. + */ + 'io.modelcontextprotocol/serverInfo'?: Implementation; +} + /** * A progress token, used to associate progress notifications with the original request. * @@ -197,7 +228,7 @@ export type ResultType = 'complete' | 'input_required' | string; * @category Common Types */ export interface Result { - _meta?: MetaObject; + _meta?: ResultMetaObject; /** * Indicates the type of the result, which allows the client to determine * how to parse the result object. @@ -650,10 +681,6 @@ export interface DiscoverResult extends CacheableResult { * The capabilities of the server. */ capabilities: ServerCapabilities; - /** - * Information about the server software implementation. - */ - serverInfo: Implementation; /** * Natural-language guidance describing the server and its features. * @@ -1284,13 +1311,13 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest { } /** - * Extends {@link MetaObject} with the subscription-stream identifier carried by a + * Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a * {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. * * @see {@link MetaObject} for key naming rules and reserved prefixes. * @category `subscriptions/listen` */ -export interface SubscriptionsListenResultMeta extends MetaObject { +export interface SubscriptionsListenResultMeta extends ResultMetaObject { /** * Identifies the subscription stream this response closes, so the client can * correlate it with the originating subscription — mirroring the same key on @@ -1333,10 +1360,16 @@ export interface SubscriptionsAcknowledgedNotificationParams extends Notificatio } /** - * Sent by the server as the first message on a - * {@link SubscriptionsListenRequest | subscriptions/listen} stream to acknowledge - * that the subscription has been established and to report which notification - * types it agreed to honor. + * Sent by the server to acknowledge that a + * {@link SubscriptionsListenRequest | subscriptions/listen} subscription has been + * established and to report which notification types it agreed to honor. + * + * This notification MUST be the first message the server sends carrying the + * subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST + * NOT send any notification on the subscription before acknowledging it. On + * stdio, where every subscription shares one channel, this ordering is defined + * per subscription ID and not per channel: messages belonging to other + * subscriptions MAY be interleaved before it. * * @example Listen acknowledged * {@includeCode ./examples/SubscriptionsAcknowledgedNotification/listen-acknowledged.json} diff --git a/packages/core-internal/src/types/specTypeSchema.ts b/packages/core-internal/src/types/specTypeSchema.ts index f7a109cbd5..427b65935b 100644 --- a/packages/core-internal/src/types/specTypeSchema.ts +++ b/packages/core-internal/src/types/specTypeSchema.ts @@ -146,6 +146,7 @@ const SPEC_SCHEMA_KEYS = [ 'ResourceTemplateReferenceSchema', 'ResourceUpdatedNotificationSchema', 'ResourceUpdatedNotificationParamsSchema', + 'ResultMetaObjectSchema', 'ResultSchema', 'RoleSchema', 'RootSchema', diff --git a/packages/core-internal/src/types/types.ts b/packages/core-internal/src/types/types.ts index f4720631c0..f2bc9d67fc 100644 --- a/packages/core-internal/src/types/types.ts +++ b/packages/core-internal/src/types/types.ts @@ -127,6 +127,7 @@ import type { ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, + ResultMetaObjectSchema, ResultSchema, RoleSchema, RootSchema, @@ -246,7 +247,11 @@ export type NotificationParams = Infer; */ export type RequestMetaEnvelope = { [PROTOCOL_VERSION_META_KEY]: string; - [CLIENT_INFO_META_KEY]: Implementation; + /** + * Optional since spec PR #3002: clients SHOULD send it, servers must not + * require it (and should not rely on it for behavior or security). + */ + [CLIENT_INFO_META_KEY]?: Implementation; [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilities; [LOG_LEVEL_META_KEY]?: LoggingLevel; }; @@ -916,6 +921,11 @@ export interface MessageExtraInfo { export type MetaObject = Record; export type RequestMetaObject = RequestMeta; +/** + * The contents of a result's `_meta` field (the 2026-07-28 `ResultMetaObject`): + * loose, with the optional self-reported serverInfo key typed when present. + */ +export type ResultMetaObject = Infer; /** * {@linkcode CreateMessageRequestParams} without tools - for backwards-compatible overload. diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 9efb9a12f3..7672a8d64a 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -254,13 +254,18 @@ export interface WireCodec { /** * Outbound result mapping (the stamp seam). The 2025-era codec is the - * identity — it has NO stamp code path (the never-stamp guarantee). The - * 2026-era codec strictly enforces the 2026 wire shape for the known - * deleted-field set (`execution.taskSupport`, `capabilities.tasks` — - * Q1-SD3 iii), stamps `resultType`, and fills the required - * `ttlMs`/`cacheScope` fields on cacheable results. + * identity — it has NO stamp code path (the never-stamp guarantee; it + * never reads `serverInfo`). The 2026-era codec strictly enforces the + * 2026 wire shape for the known deleted-field set + * (`execution.taskSupport`, `capabilities.tasks` — Q1-SD3 iii), stamps + * `resultType`, fills the required `ttlMs`/`cacheScope` fields on + * cacheable results, and — when the caller supplies its identity — + * stamps `_meta['io.modelcontextprotocol/serverInfo']` on every result + * (spec PR #3002 SHOULD; a handler-authored value wins). `Server` + * instances pass their identity; clients and hand-constructed protocol + * objects pass nothing and no identity is ever invented. */ - encodeResult(method: string, result: Result): Result; + encodeResult(method: string, result: Result, serverInfo?: Implementation): Result; /** * Outbound error-code mapping (the error half of the stamp seam). A diff --git a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts index a8aa6b7262..6831062fd3 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts @@ -21,10 +21,25 @@ * * No 2025-era traffic ever touches this module, so requiredness here is * bare and spec-exact (the shared-schema `.catch` hazards do not apply). + * + * SPEC-CURRENCY RE-SEAL (2026-07-17): the 2026-07-28 revision was re-sealed + * upstream by spec PR #3002 (commit 71e30695, merged 2026-07-15 — after the + * previous anchor pin f68d864a): the envelope's `clientInfo` demoted from + * required to SHOULD, and `DiscoverResult.serverInfo` moved from the result + * body to the new `ResultMetaObject` key + * `_meta['io.modelcontextprotocol/serverInfo']` (optional on every result). + * The shapes below are the re-sealed anchor, exactly — no pre-#3002 shape is + * modeled anywhere (per ruling: the final revision is the only 2026-07-28). */ import * as z from 'zod/v4'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY +} from '../../types/constants'; import type { JSONObject, JSONValue } from '../../types/types'; /** @@ -665,9 +680,10 @@ function build() { */ [PROTOCOL_VERSION_META_KEY]: z.string(), /** - * Identifies the client software making the request. + * Identifies the client software making the request. Optional since + * spec PR #3002 (clients SHOULD send it; servers must not require it). */ - [CLIENT_INFO_META_KEY]: ImplementationSchema, + [CLIENT_INFO_META_KEY]: ImplementationSchema.optional(), /** * The client's capabilities for this specific request. An empty object means the * client supports no optional capabilities. Servers must not infer capabilities @@ -758,7 +774,19 @@ function build() { /** Open union per the anchor: 'complete' | 'input_required' | string. */ const ResultTypeSchema = z.string(); - const wireMeta = z.record(z.string(), z.unknown()).optional(); + /** + * Result `_meta` (anchor `ResultMetaObject`, added by spec PR #3002): + * loose, with the serverInfo key typed when present; the outbound stamp + * is the encode contract's `stampServerInfoMeta` step. + */ + const ResultMetaSchema = z.looseObject({ + // Malformed drops to absent, per the spec: the value "is not verified + // by the protocol" and clients "SHOULD NOT use it to change their behavior". + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod `.catch()`, not a Promise + [SERVER_INFO_META_KEY]: ImplementationSchema.optional().catch(undefined) + }); + + const wireMeta = ResultMetaSchema.optional(); function wireResult(shape: T) { return z.looseObject({ @@ -848,7 +876,8 @@ function build() { cacheScope: z.enum(['public', 'private']).catch('private'), supportedVersions: z.array(z.string()), capabilities: ServerCapabilities2026Schema, - serverInfo: ImplementationSchema, + // No body `serverInfo` (spec PR #3002) — identity lives in the result + // `_meta['io.modelcontextprotocol/serverInfo']`. instructions: z.string().optional() }); @@ -1040,8 +1069,12 @@ function build() { const subscriptionsListenParamsShape = { notifications: SubscriptionFilterSchema }; const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape); - /** Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on the graceful-close result. */ - const SubscriptionsListenResultMetaSchema = z.looseObject({ + /** + * Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on + * the graceful-close result. Extends `ResultMetaObject` since spec PR + * #3002 (composed, so the serverInfo key and its leniency stay single-sourced). + */ + const SubscriptionsListenResultMetaSchema = ResultMetaSchema.extend({ 'io.modelcontextprotocol/subscriptionId': RequestIdSchema }); @@ -1135,7 +1168,7 @@ function build() { cacheScope: z.enum(['public', 'private']).catch('private'), supportedVersions: z.array(z.string()), capabilities: ServerCapabilities2026Schema, - serverInfo: ImplementationSchema, + // No body `serverInfo` (spec PR #3002; see DiscoverResultSchema). instructions: z.string().optional() }), // `subscriptions/listen` receives a JSON-RPC result only on a server-side @@ -1321,6 +1354,7 @@ function build() { SamplingMessageContentBlockSchema, SamplingMessageSchema, ResultTypeSchema, + ResultMetaSchema, ResultSchema, PaginatedResultSchema, CallToolResultSchema, diff --git a/packages/core-internal/src/wire/rev2026-07-28/codec.ts b/packages/core-internal/src/wire/rev2026-07-28/codec.ts index c1373a8f41..9bc6c4d737 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/codec.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/codec.ts @@ -29,11 +29,11 @@ import type * as z from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../errors/sdkErrors'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants'; -import type { CallToolResult, Result } from '../../types/types'; +import type { CallToolResult, Implementation, Result } from '../../types/types'; import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec'; import { appendTextFallbackForNonObject } from '../textFallback'; import { buildSchemas2026 } from './buildSchemas'; -import { fillCacheFields, stampResultType } from './encodeContract'; +import { fillCacheFields, stampResultType, stampServerInfoMeta } from './encodeContract'; import { getInputRequestSchema2026, getInputResponseSchema2026 } from './inputRequired'; import { getNotificationSchema2026, @@ -56,8 +56,13 @@ function triState(schema: z.ZodType | undefined, raw: unknown): ValidateOu const NOT_IN_ERA: ValidateOutcome = { ok: false, reason: 'not-in-era' }; -/** The reserved `_meta` keys an envelope must carry on this era (in reporting order). */ -const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]; +/** + * The reserved `_meta` keys an envelope must carry on this era (in reporting + * order). `clientInfo` is NOT here: spec PR #3002 demoted it to SHOULD, so a + * request without it is accepted (a present-but-malformed value still fails + * the envelope schema parse below). + */ +const REQUIRED_ENVELOPE_KEYS: readonly string[] = [PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY]; /** Strip the known deleted-field set from an outbound result (Q1-SD3 iii). */ function enforceDeletedFields(method: string, result: Result): Result { @@ -251,12 +256,14 @@ export const rev2026Codec: WireCodec & { return { kind: 'complete', result: lifted as Result }; }, - encodeResult(method: string, result: Result): Result { + encodeResult(method: string, result: Result, serverInfo?: Implementation): Result { // The stamp seam, in pinned order: deleted-field strictness, then the // resultType stamp (handler pass-through only for methods whose // vocabulary goes beyond 'complete'), then the cache fill for the - // cacheable operations (only on post-stamp 'complete' results). - return fillCacheFields(method, stampResultType(method, enforceDeletedFields(method, result))); + // cacheable operations (only on post-stamp 'complete' results), then + // the `_meta` serverInfo identity stamp (#3002 — every result, + // handler-authored value wins). + return stampServerInfoMeta(fillCacheFields(method, stampResultType(method, enforceDeletedFields(method, result))), serverInfo); }, // The −32002 resource-not-found domain code maps to −32602 Invalid Params @@ -267,7 +274,7 @@ export const rev2026Codec: WireCodec & { if (material.envelope === undefined) { return ( 'Request is missing the required _meta envelope for protocol revision 2026-07-28 ' + - '(io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo, io.modelcontextprotocol/clientCapabilities)' + '(io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities)' ); } const parsed = buildSchemas2026().RequestMetaEnvelopeSchema.safeParse(material.envelope); diff --git a/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts b/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts index df3e71b5d0..757d385a53 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/encodeContract.ts @@ -17,6 +17,10 @@ * server layer, then the conservative defaults * `{ ttlMs: 0, cacheScope: 'private' }`. Invalid handler-returned values * never reach the wire — they fall through to the next author. + * 3. {@linkcode stampServerInfoMeta} — the `_meta` serverInfo key on every + * result (spec PR #3002: servers SHOULD identify themselves on every + * response). A handler-authored value wins; without a supplied identity + * the step is the identity function. * * Ordering matters and is pinned by tests: the stamp runs before the fill, so * an `input_required` result is never given cache fields. @@ -29,9 +33,10 @@ import { isValidCacheTtlMs, RESULT_CACHE_HINT_FALLBACK } from '../../shared/resultCacheHints'; +import { SERVER_INFO_META_KEY } from '../../types/constants'; import { ProtocolErrorCode } from '../../types/enums'; import { ProtocolError } from '../../types/errors'; -import type { Result } from '../../types/types'; +import type { Implementation, Result } from '../../types/types'; /** The default cache policy when neither the handler nor configuration provides one. */ export const DEFAULT_CACHE_TTL_MS = 0; @@ -112,6 +117,42 @@ export function fillCacheFields(method: string, result: Result): Result { return filled as Result; } +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Step 3 of the encode contract: stamp the server's identity into the + * result's `_meta` under `io.modelcontextprotocol/serverInfo` (spec PR #3002: + * servers SHOULD include it on every response). + * + * - No `serverInfo` supplied (a client instance, or a hand-constructed + * protocol object) → identity function. + * - The result's `_meta` already carries the key → kept as-is (the handler + * is the more specific author; mirrors the cache-fill resolution order). + * - A present-but-non-object `_meta` (a dynamic-caller bug) → kept as-is: + * the stamp never rewrites handler material, and the malformed value fails + * loudly at the peer instead of being silently replaced here. + * - Otherwise → the key is added, preserving any other `_meta` entries. + * + * Runs for every result regardless of `resultType`: the anchor types + * `Result._meta` as `ResultMetaObject` on all results, `input_required` + * included. + */ +export function stampServerInfoMeta(result: Result, serverInfo: Implementation | undefined): Result { + if (serverInfo === undefined) return result; + const meta = (result as Record)['_meta']; + if (meta === undefined) { + return { ...result, _meta: { [SERVER_INFO_META_KEY]: serverInfo } } as Result; + } + if (!isPlainObject(meta)) return result; + // Value check, not `in`: a present-but-undefined key (an unset optional in + // handler code) must not suppress the stamp — JSON would drop the key and + // the response would ship with no identity at all. + if (meta[SERVER_INFO_META_KEY] !== undefined) return result; + return { ...result, _meta: { ...meta, [SERVER_INFO_META_KEY]: serverInfo } } as Result; +} + function resolveTtlMs(fallback: CacheHint | undefined): number { return fallback !== undefined && isValidCacheTtlMs(fallback.ttlMs) ? fallback.ttlMs : DEFAULT_CACHE_TTL_MS; } diff --git a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts index ed545f7a99..b342ded914 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts @@ -98,6 +98,7 @@ export const ToolResultContentSchema = s.ToolResultContentSchema; export const SamplingMessageContentBlockSchema = s.SamplingMessageContentBlockSchema; export const SamplingMessageSchema = s.SamplingMessageSchema; export const ResultTypeSchema = s.ResultTypeSchema; +export const ResultMetaSchema = s.ResultMetaSchema; export const ResultSchema = s.ResultSchema; export const PaginatedResultSchema = s.PaginatedResultSchema; export const CallToolResultSchema = s.CallToolResultSchema; diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json index 9f636318c4..c8d400dffb 100644 --- a/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResult/server-capabilities-discovery.json @@ -5,9 +5,11 @@ "tools": {}, "resources": {} }, - "serverInfo": { - "name": "ExampleServer", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + } }, "instructions": "This server provides weather and resource utilities. Prefer `get_weather` for forecast lookups.", "ttlMs": 3600000, diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json index 1a162891bb..0a9951930d 100644 --- a/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/DiscoverResultResponse/discover-result-response.json @@ -8,9 +8,11 @@ "tools": {}, "resources": {} }, - "serverInfo": { - "name": "ExampleServer", - "version": "1.0.0" + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "ExampleServer", + "version": "1.0.0" + } }, "ttlMs": 3600000, "cacheScope": "public" diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json index ef08b0f827..eeeae659cd 100644 --- a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json @@ -3,7 +3,7 @@ "source": { "repo": "modelcontextprotocol/modelcontextprotocol", "path": "schema/draft/examples", - "commit": "f68d864a813754e188c6df52dcc5772a12f96c63" + "commit": "71e306956a4959c9655e5036be215d41986596e6" }, "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", "directoryCount": 87, diff --git a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json index 82cac4d58b..cc44564e33 100644 --- a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json +++ b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json @@ -123,7 +123,7 @@ "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -208,7 +208,7 @@ "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "content": { "description": "A list of content objects that represent the unstructured result of the tool call.", @@ -501,7 +501,7 @@ "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "completion": { "properties": { @@ -740,7 +740,7 @@ "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -762,10 +762,6 @@ "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", "type": "string" }, - "serverInfo": { - "$ref": "#/$defs/Implementation", - "description": "Information about the server software implementation." - }, "supportedVersions": { "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", "items": { @@ -783,7 +779,6 @@ "cacheScope", "capabilities", "resultType", - "serverInfo", "supportedVersions", "ttlMs" ], @@ -1084,7 +1079,7 @@ "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "description": { "description": "An optional description for the prompt.", @@ -1310,7 +1305,7 @@ "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "inputRequests": { "$ref": "#/$defs/InputRequests" @@ -1644,7 +1639,7 @@ "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1733,7 +1728,7 @@ "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1822,7 +1817,7 @@ "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -1947,7 +1942,7 @@ "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2299,7 +2294,7 @@ "PaginatedResult": { "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "nextCursor": { "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", @@ -2600,7 +2595,7 @@ "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "cacheScope": { "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", @@ -2700,7 +2695,7 @@ }, "io.modelcontextprotocol/clientInfo": { "$ref": "#/$defs/Implementation", - "description": "Identifies the client software making the request. Required.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional." + "description": "Identifies the client software making the request. Clients SHOULD\ninclude this field on every request unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the client and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Servers\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." }, "io.modelcontextprotocol/logLevel": { "$ref": "#/$defs/LoggingLevel", @@ -2717,7 +2712,6 @@ }, "required": [ "io.modelcontextprotocol/clientCapabilities", - "io.modelcontextprotocol/clientInfo", "io.modelcontextprotocol/protocolVersion" ], "type": "object" @@ -3005,7 +2999,7 @@ "description": "Common result fields.", "properties": { "_meta": { - "$ref": "#/$defs/MetaObject" + "$ref": "#/$defs/ResultMetaObject" }, "resultType": { "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", @@ -3017,6 +3011,16 @@ ], "type": "object" }, + "ResultMetaObject": { + "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + } + }, + "type": "object" + }, "ResultType": { "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", "type": "string" @@ -3312,7 +3316,7 @@ "type": "object" }, "SubscriptionsAcknowledgedNotification": { - "description": "Sent by the server as the first message on a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream to acknowledge\nthat the subscription has been established and to report which notification\ntypes it agreed to honor.", + "description": "Sent by the server to acknowledge that a\n{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been\nestablished and to report which notification types it agreed to honor.\n\nThis notification MUST be the first message the server sends carrying the\nsubscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST\nNOT send any notification on the subscription before acknowledging it. On\nstdio, where every subscription shares one channel, this ordering is defined\nper subscription ID and not per channel: messages belonging to other\nsubscriptions MAY be interleaved before it.", "properties": { "jsonrpc": { "const": "2.0", @@ -3410,8 +3414,12 @@ "type": "object" }, "SubscriptionsListenResultMeta": { - "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + }, "io.modelcontextprotocol/subscriptionId": { "$ref": "#/$defs/RequestId", "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." diff --git a/packages/core-internal/test/corpus/schema-twins/manifest.json b/packages/core-internal/test/corpus/schema-twins/manifest.json index 5f7e6f56c8..9047083294 100644 --- a/packages/core-internal/test/corpus/schema-twins/manifest.json +++ b/packages/core-internal/test/corpus/schema-twins/manifest.json @@ -2,12 +2,12 @@ "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to the sha256/bytes below by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`, ATOMICALLY with the matching spec.types anchor (see packages/core-internal/src/types/README.md lifecycle rule 4).", "source": { "repository": "modelcontextprotocol/modelcontextprotocol", - "commit": "f68d864a813754e188c6df52dcc5772a12f96c63" + "commit": "71e306956a4959c9655e5036be215d41986596e6" }, "files": { "2026-07-28": { - "sha256": "14398c3dd2c66b9c3f6661fc7a7eaa24174952ed1598d0b7f011b686ba5c4c83", - "bytes": 178613, + "sha256": "9281c4890630e2d1e61792fa23b4084c4ea360cd58519610cd050545ab7b8708", + "bytes": 180695, "upstreamPath": "schema/draft/schema.json" }, "2025-11-25": { diff --git a/packages/core-internal/test/shared/inboundClassification.test.ts b/packages/core-internal/test/shared/inboundClassification.test.ts index 16017d7562..e2cbc2c04e 100644 --- a/packages/core-internal/test/shared/inboundClassification.test.ts +++ b/packages/core-internal/test/shared/inboundClassification.test.ts @@ -89,10 +89,18 @@ describe('envelope claim detection (claim = the reserved protocol-version key)', }); describe('envelope validation issues are self-identifying (key + problem)', () => { - test('missing required keys are reported in canonical order', () => { + test('missing required keys are reported in canonical order (clientInfo is a SHOULD, never required)', () => { const issues = validateEnvelopeMeta({ [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }); - expect(issues.map(issue => issue.key)).toEqual([CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY]); + expect(issues.map(issue => issue.key)).toEqual([CLIENT_CAPABILITIES_META_KEY]); expect(issues.every(issue => issue.problem === 'missing')).toBe(true); + const withoutVersion = validateEnvelopeMeta({}); + expect(withoutVersion.map(issue => issue.key)).toEqual([PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY]); + }); + + test('an envelope without clientInfo is valid (SHOULD since spec PR #3002)', () => { + const withoutClientInfo: Record = { ...ENVELOPE }; + delete withoutClientInfo[CLIENT_INFO_META_KEY]; + expect(validateEnvelopeMeta(withoutClientInfo)).toEqual([]); }); test('a malformed value inside a present key names the key', () => { @@ -196,7 +204,7 @@ describe('body-primary era predicate', () => { httpStatus: 400, code: -32_602, settled: true, - data: { envelope: { key: CLIENT_INFO_META_KEY, problem: 'missing' } } + data: { envelope: { key: CLIENT_CAPABILITIES_META_KEY, problem: 'missing' } } }); }); diff --git a/packages/core-internal/test/spec.types.2026-07-28.test.ts b/packages/core-internal/test/spec.types.2026-07-28.test.ts index 4b55f9b20f..4bdc3d11fc 100644 --- a/packages/core-internal/test/spec.types.2026-07-28.test.ts +++ b/packages/core-internal/test/spec.types.2026-07-28.test.ts @@ -492,6 +492,13 @@ const wireParityChecks = { sdk = spec; spec = sdk; }, + // Result `_meta` (anchor ResultMetaObject, spec PR #3002): the typed + // optional serverInfo key — servers SHOULD identify themselves on every + // response; the encode seam owns the outbound stamp. + ResultMetaObject: (sdk: z4.infer, spec: SpecTypes.ResultMetaObject) => { + sdk = spec; + spec = sdk; + }, EmptyResult: (sdk: WResult, spec: SpecTypes.EmptyResult) => { sdk = spec; spec = sdk; @@ -867,7 +874,8 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); expect(specTypes).toContain('SubscriptionsListenResult'); - expect(specTypes).toHaveLength(153); + expect(specTypes).toContain('ResultMetaObject'); + expect(specTypes).toHaveLength(154); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { diff --git a/packages/core-internal/test/types.test.ts b/packages/core-internal/test/types.test.ts index e836086bfc..0d5002af1a 100644 --- a/packages/core-internal/test/types.test.ts +++ b/packages/core-internal/test/types.test.ts @@ -1112,14 +1112,18 @@ describe('2026-07-28 wire shapes', () => { } }); - test.each([PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY])( - 'rejects an envelope missing %s', - key => { - const incomplete: Record = { ...envelope }; - delete incomplete[key]; - expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); - } - ); + test.each([PROTOCOL_VERSION_META_KEY, CLIENT_CAPABILITIES_META_KEY])('rejects an envelope missing %s', key => { + const incomplete: Record = { ...envelope }; + delete incomplete[key]; + expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); + }); + + test('accepts an envelope without clientInfo (SHOULD since spec PR #3002), but rejects a malformed one', () => { + const withoutClientInfo: Record = { ...envelope }; + delete withoutClientInfo[CLIENT_INFO_META_KEY]; + expect(RequestMetaEnvelopeSchema.safeParse(withoutClientInfo).success).toBe(true); + expect(RequestMetaEnvelopeSchema.safeParse({ ...envelope, [CLIENT_INFO_META_KEY]: 'not-an-object' }).success).toBe(false); + }); test('rejects an invalid log level', () => { const result = RequestMetaEnvelopeSchema.safeParse({ ...envelope, [LOG_LEVEL_META_KEY]: 'loud' }); @@ -1146,22 +1150,26 @@ describe('2026-07-28 wire shapes', () => { describe('DiscoverResult', () => { const result = { supportedVersions: ['2026-07-28'], - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'test-server', version: '1.0.0' } + capabilities: { tools: { listChanged: true } } }; - test('parses a discover result', () => { - const parsed = DiscoverResultSchema.safeParse({ ...result, resultType: 'complete', instructions: 'Use the echo tool.' }); + test('parses a discover result (serverInfo in _meta since spec PR #3002)', () => { + const parsed = DiscoverResultSchema.safeParse({ + ...result, + resultType: 'complete', + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'test-server', version: '1.0.0' } }, + instructions: 'Use the echo tool.' + }); expect(parsed.success).toBe(true); if (parsed.success) { expect(parsed.data.supportedVersions).toEqual(['2026-07-28']); expect(parsed.data.capabilities).toEqual({ tools: { listChanged: true } }); - expect(parsed.data.serverInfo).toEqual({ name: 'test-server', version: '1.0.0' }); + expect(parsed.data._meta?.['io.modelcontextprotocol/serverInfo']).toEqual({ name: 'test-server', version: '1.0.0' }); expect(parsed.data.instructions).toBe('Use the echo tool.'); } }); - test.each(['supportedVersions', 'capabilities', 'serverInfo'])('rejects a discover result missing %s', key => { + test.each(['supportedVersions', 'capabilities'])('rejects a discover result missing %s', key => { const incomplete: Record = { ...result }; delete incomplete[key]; expect(DiscoverResultSchema.safeParse(incomplete).success).toBe(false); diff --git a/packages/core-internal/test/types/discoverWiring.test.ts b/packages/core-internal/test/types/discoverWiring.test.ts index ea0c1f409b..29451de3df 100644 --- a/packages/core-internal/test/types/discoverWiring.test.ts +++ b/packages/core-internal/test/types/discoverWiring.test.ts @@ -23,7 +23,7 @@ describe('server/discover typed-funnel wiring (LC-02)', () => { const parsed = ServerResultSchema.safeParse({ supportedVersions: ['2026-07-28'], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } }); expect(parsed.success).toBe(true); }); @@ -45,7 +45,7 @@ describe('server/discover typed-funnel wiring (LC-02)', () => { const result = DiscoverResultSchema.parse({ supportedVersions: ['2026-07-28'], capabilities: { tools: {} }, - serverInfo: { name: 'modern-server', version: '2.0.0' }, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'modern-server', version: '2.0.0' } }, instructions: 'use the tools' }); expect(result.supportedVersions).toEqual(['2026-07-28']); diff --git a/packages/core-internal/test/types/schemaBoundaryPins.test.ts b/packages/core-internal/test/types/schemaBoundaryPins.test.ts index d28a7963e0..cad20712cd 100644 --- a/packages/core-internal/test/types/schemaBoundaryPins.test.ts +++ b/packages/core-internal/test/types/schemaBoundaryPins.test.ts @@ -203,13 +203,20 @@ describe('RequestMetaEnvelopeSchema', () => { 'io.modelcontextprotocol/clientCapabilities': {} }; - test('requires protocolVersion, clientInfo, and clientCapabilities', () => { + test('requires protocolVersion and clientCapabilities; clientInfo is optional (SHOULD since spec PR #3002)', () => { expect(RequestMetaEnvelopeSchema.safeParse(validEnvelope).success).toBe(true); - for (const key of Object.keys(validEnvelope)) { + for (const key of ['io.modelcontextprotocol/protocolVersion', 'io.modelcontextprotocol/clientCapabilities']) { const incomplete: Record = { ...validEnvelope }; delete incomplete[key]; expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); } + const withoutClientInfo: Record = { ...validEnvelope }; + delete withoutClientInfo['io.modelcontextprotocol/clientInfo']; + expect(RequestMetaEnvelopeSchema.safeParse(withoutClientInfo).success).toBe(true); + // Present-but-malformed clientInfo still fails the typed key. + expect( + RequestMetaEnvelopeSchema.safeParse({ ...validEnvelope, 'io.modelcontextprotocol/clientInfo': 'not-an-object' }).success + ).toBe(false); }); test('is loose: foreign _meta keys pass through', () => { diff --git a/packages/core-internal/test/wire/encodeContract.test.ts b/packages/core-internal/test/wire/encodeContract.test.ts index 32c404010d..0aa3fd168f 100644 --- a/packages/core-internal/test/wire/encodeContract.test.ts +++ b/packages/core-internal/test/wire/encodeContract.test.ts @@ -12,6 +12,9 @@ * specific author first (valid handler-returned values, then the * attached configured hint, then the defaults), with an encode-time * validity gate on handler-returned values. + * step 3 — `_meta` serverInfo stamp (spec PR #3002): the caller-supplied + * identity lands on every result's `_meta`, handler-authored value + * wins, no identity → identity function. * * The ordering (stamp before fill, `input_required` excluded from the fill) * is pinned here. @@ -34,7 +37,8 @@ import { DEFAULT_CACHE_TTL_MS, EXTENDED_RESULT_TYPE_METHODS, fillCacheFields, - stampResultType + stampResultType, + stampServerInfoMeta } from '../../src/wire/rev2026-07-28/encodeContract'; const asResult = (value: Record): Result => value as unknown as Result; @@ -202,11 +206,74 @@ describe('the codec integration (encodeResult applies the contract in pinned ord }); }); +describe('step 3 — the _meta serverInfo stamp (spec PR #3002)', () => { + const identity = { name: 'stamp-server', version: '9.9.9' }; + const metaOf = (result: Result) => (result as Record)['_meta'] as Record | undefined; + + test('stamps the identity into a fresh _meta when the result has none', () => { + const stamped = stampServerInfoMeta(asResult({ tools: [] }), identity); + expect(metaOf(stamped)).toEqual({ 'io.modelcontextprotocol/serverInfo': identity }); + }); + + test('preserves other _meta entries', () => { + const stamped = stampServerInfoMeta(asResult({ _meta: { 'com.example/trace': 'abc' } }), identity); + expect(metaOf(stamped)).toEqual({ 'com.example/trace': 'abc', 'io.modelcontextprotocol/serverInfo': identity }); + }); + + test('a handler-authored serverInfo wins (never overwritten)', () => { + const authored = { name: 'authored', version: '0.1.0' }; + const stamped = stampServerInfoMeta(asResult({ _meta: { 'io.modelcontextprotocol/serverInfo': authored } }), identity); + expect(metaOf(stamped)).toEqual({ 'io.modelcontextprotocol/serverInfo': authored }); + }); + + test('no identity supplied → identity function (no _meta invented)', () => { + const result = asResult({ tools: [] }); + expect(stampServerInfoMeta(result, undefined)).toBe(result); + }); + + test('a present-but-non-object _meta is never rewritten (the malformed value fails loudly at the peer)', () => { + const result = asResult({ _meta: ['not-an-object'] }); + expect(stampServerInfoMeta(result, identity)).toBe(result); + }); + + test('encodeResult stamps every result — complete and input_required alike', () => { + const complete = rev2026Codec.encodeResult('tools/list', asResult({ tools: [] }), identity); + expect(metaOf(complete)?.['io.modelcontextprotocol/serverInfo']).toEqual(identity); + const inputRequired = rev2026Codec.encodeResult( + 'resources/read', + asResult({ resultType: 'input_required', inputRequests: {} }), + identity + ); + expect(metaOf(inputRequired)?.['io.modelcontextprotocol/serverInfo']).toEqual(identity); + }); + + test('the 2025 codec never stamps, identity supplied or not (the never-stamp guarantee)', () => { + const result = asResult({ tools: [] }); + expect(rev2025Codec.encodeResult('tools/list', result, identity)).toBe(result); + }); + + test('receive side: a malformed _meta serverInfo drops to absent instead of failing the wire parse (display-only leniency)', () => { + const parsed = Wire2026DiscoverResultSchema.safeParse({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'public', + supportedVersions: ['2026-07-28'], + capabilities: {}, + _meta: { 'io.modelcontextprotocol/serverInfo': 'not-an-implementation', 'com.example/keep': 1 } + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data._meta?.['io.modelcontextprotocol/serverInfo']).toBeUndefined(); + expect(parsed.data._meta?.['com.example/keep']).toBe(1); + } + }); +}); + describe('inbound receiver-side defaults (the parse-side leniency that lets the probe classifier route through the codec)', () => { const minimalDiscover = { supportedVersions: ['2026-07-28'], capabilities: {}, - serverInfo: { name: 's', version: '1' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 's', version: '1' } } }; test("validateResult('server/discover', …) fills ttlMs/cacheScope when absent", () => { diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index b03202e011..fb2eb26d4e 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -360,8 +360,7 @@ describe('encode-side deleted-field strictness (Q1-SD3 iii)', () => { ttlMs: 0, cacheScope: 'private', supportedVersions: ['2026-07-28'], - capabilities: { tools: {}, tasks: { list: {} } }, - serverInfo: { name: 's', version: '0' } + capabilities: { tools: {}, tasks: { list: {} } } })) as never ); } diff --git a/packages/core-internal/test/wire/stampingSuppression.test.ts b/packages/core-internal/test/wire/stampingSuppression.test.ts index 707e43e0da..a35bdbf164 100644 --- a/packages/core-internal/test/wire/stampingSuppression.test.ts +++ b/packages/core-internal/test/wire/stampingSuppression.test.ts @@ -215,7 +215,7 @@ describe('S5 — stamping is response-side only', () => { cacheScope: 'private', supportedVersions: ['2026-07-28'], capabilities: {}, - serverInfo: { name: 'peer', version: '0.0.0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '0.0.0' } } } } as JSONRPCMessage); } diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 61e6ca7f1f..72b5f1c763 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -20,9 +20,22 @@ export const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersio /** * `_meta` key identifying the client software making a request. + * + * Clients SHOULD include it on every request; the value is self-reported and + * intended for display, logging, and debugging — servers should not rely on + * it for behavior or security decisions. */ export const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; +/** + * `_meta` key identifying the server software producing a response. + * + * Servers SHOULD include it on every response; the value is self-reported and + * intended for display, logging, and debugging — clients should not rely on + * it for behavior or security decisions. + */ +export const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; + /** * `_meta` key carrying the client's capabilities for a request. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef0be20770..e8ddda2909 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -133,6 +133,7 @@ export { ResourceTemplateSchema, ResourceUpdatedNotificationParamsSchema, ResourceUpdatedNotificationSchema, + ResultMetaObjectSchema, ResultSchema, RoleSchema, RootSchema, diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index bc64597b98..26f294f6c5 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -1,6 +1,6 @@ import * as z from 'zod/v4'; -import { JSONRPC_VERSION, RELATED_TASK_META_KEY, SUBSCRIPTION_ID_META_KEY } from './constants'; +import { JSONRPC_VERSION, RELATED_TASK_META_KEY, SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY } from './constants'; import type { JSONArray, JSONObject, JSONValue } from './types'; export const JSONValueSchema: z.ZodType = z.lazy(() => @@ -89,12 +89,27 @@ export const NotificationSchema = z.object({ params: NotificationsParamsSchema.loose().optional() }); +/** + * The contents of a result's `_meta` field (the 2026-07-28 `ResultMetaObject`). + * Loose — implementation-specific keys pass through. + * + * The serverInfo key identifies the server software producing the response + * (servers SHOULD include it on every response; the value is self-reported + * and intended for display, logging, and debugging). The getter defers the + * `ImplementationSchema` reference, which is declared later in this file. + */ +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() // `resultType` is wire-only vocabulary (protocol revision 2026-07-28) and // is deliberately NOT modeled here: the neutral result schemas carry no // slot for it. It exists only inside the 2026-era wire codec, which @@ -584,14 +599,13 @@ export const DiscoverResultSchema = ResultSchema.extend({ * The capabilities of the server. */ capabilities: ServerCapabilitiesSchema, - /** - * Information about the server software implementation. - */ - serverInfo: ImplementationSchema, /** * Instructions describing how to use the server and its features. * * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + * + * Server identity is not a body field: it travels in + * `_meta['io.modelcontextprotocol/serverInfo']` (spec PR #3002). */ instructions: z.string().optional() }); @@ -962,9 +976,11 @@ export const SubscriptionsAcknowledgedNotificationSchema = NotificationSchema.ex /** * `_meta` for a {@linkcode SubscriptionsListenResult}: the listen request's * JSON-RPC ID under the canonical subscription-id key (mirroring the same key - * on every notification delivered on the stream). + * on every notification delivered on the stream). Extends + * {@linkcode ResultMetaObjectSchema}, so the optional serverInfo key is typed + * here too. */ -export const SubscriptionsListenResultMetaSchema = z.looseObject({ +export const SubscriptionsListenResultMetaSchema = ResultMetaObjectSchema.extend({ [SUBSCRIPTION_ID_META_KEY]: RequestIdSchema }); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 708bb818ff..f17deaa860 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -63,7 +63,7 @@ import { createListenRouter, DEFAULT_LISTEN_KEEPALIVE_MS, DEFAULT_MAX_SUBSCRIPTI import { McpServer } from './mcp'; import type { PerRequestResponseMode } from './perRequestTransport'; import type { Server } from './server'; -import { installModernOnlyHandlers, seedClientIdentityFromEnvelope } from './server'; +import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus'; import { WebStandardStreamableHTTPServerTransport } from './streamableHttp'; @@ -721,8 +721,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // middleware layer mounted in front of this entry. if (route.messageKind === 'request' && route.message.method === 'subscriptions/listen') { const capabilities = server.getCapabilities(); + const serverInfo = serverIdentityOf(server); void product.close().catch(reportError); - return listenRouter.serve(route.message, request.signal, capabilities); + return listenRouter.serve(route.message, request.signal, capabilities, serverInfo); } // SEP-2243 `Mcp-Param-*` server-side validation (pre-dispatch ladder diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 5aaa948c43..96dcb16beb 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -23,8 +23,14 @@ * transport close carries no response and the client treats it as a * disconnect. */ -import type { JSONRPCRequest, RequestId, ServerCapabilities, SubscriptionFilter } from '@modelcontextprotocol/core-internal'; -import { codecForVersion, MODERN_WIRE_REVISION, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core-internal'; +import type { + Implementation, + JSONRPCRequest, + RequestId, + ServerCapabilities, + SubscriptionFilter +} from '@modelcontextprotocol/core-internal'; +import { codecForVersion, MODERN_WIRE_REVISION, SERVER_INFO_META_KEY, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core-internal'; import type { ServerEventBus } from './serverEventBus'; import { honoredSubset, listenFilterAccepts, serverEventToNotification } from './serverEventBus'; @@ -100,8 +106,11 @@ export interface ListenRouter { * `capabilities` is required: the acknowledged filter is always narrowed * against what the serving instance advertises (honoring a filter without * capabilities would fail open and deliver unadvertised types). + * `serverInfo` is the serving instance's identity, stamped onto the + * graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMeta` + * extends `ResultMetaObject`, so the serverInfo SHOULD applies there too). */ - serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response; + serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities, serverInfo: Implementation): Response; /** * Gracefully close every open subscription stream: emits the empty * `subscriptions/listen` JSON-RPC result (the spec's graceful-close @@ -119,7 +128,12 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { const open = new Set<(graceful: boolean) => void>(); - function serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities): Response { + function serve( + message: JSONRPCRequest, + signal: AbortSignal | undefined, + capabilities: ServerCapabilities, + serverInfo: Implementation + ): Response { // Capacity guard, pre-ack: in-band -32603 on HTTP 200. if (open.size >= maxSubscriptions) { onerror?.(new Error(`subscriptions/listen refused: subscription limit reached (${maxSubscriptions})`)); @@ -166,7 +180,10 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: subscriptionId, - result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: subscriptionId } } + result: { + resultType: 'complete', + _meta: { [SUBSCRIPTION_ID_META_KEY]: subscriptionId, [SERVER_INFO_META_KEY]: serverInfo } + } })}\n\n` ); } @@ -256,6 +273,16 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { * Stdio listen router * ------------------------------------------------------------------------ */ +/** A graceful-close `subscriptions/listen` result frame emitted by {@linkcode StdioListenRouter.teardownAll}. */ +export interface ListenCloseFrame { + jsonrpc: '2.0'; + id: RequestId; + result: { + resultType: 'complete'; + _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId; [SERVER_INFO_META_KEY]?: Implementation }; + }; +} + const CHANGE_NOTIFICATION_METHODS: ReadonlySet = new Set([ 'notifications/tools/list_changed', 'notifications/prompts/list_changed', @@ -281,21 +308,31 @@ export class StdioListenRouter { * what the server can actually deliver. */ private _serverCapabilities: ServerCapabilities | undefined; + /** + * The serving instance's identity, stamped onto the graceful-close + * results' `_meta` (the spec's `SubscriptionsListenResultMeta` extends + * `ResultMetaObject`). Handed over together with the capabilities. + */ + private _serverInfo: Implementation | undefined; constructor( private readonly _maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS, - serverCapabilities?: ServerCapabilities + serverCapabilities?: ServerCapabilities, + serverInfo?: Implementation ) { this._serverCapabilities = serverCapabilities; + this._serverInfo = serverInfo; } /** - * Record the serving instance's declared capabilities once it has been - * constructed. Called by `serveStdio`'s connect path; subsequent - * `serve()` calls narrow the honored filter against these. + * Record the serving instance's declared capabilities and identity once + * it has been constructed. Called by `serveStdio`'s connect path; + * subsequent `serve()` calls narrow the honored filter against the + * capabilities, and `teardownAll()` stamps the identity. */ - setServerCapabilities(capabilities: ServerCapabilities): void { + setServerCapabilities(capabilities: ServerCapabilities, serverInfo?: Implementation): void { this._serverCapabilities = capabilities; + if (serverInfo !== undefined) this._serverInfo = serverInfo; } /** Whether `id` is an active listen subscription on this connection. */ @@ -373,21 +410,24 @@ export class StdioListenRouter { /** * Server-side graceful teardown of every active subscription: returns the * empty `subscriptions/listen` JSON-RPC result for each subscription id — - * the spec's graceful-close signal — for the entry to emit before closing - * the wire. Clears the set so nothing further is delivered. + * the spec's graceful-close signal, `_meta` carrying the subscription id + * and the serving instance's identity — for the entry to emit before + * closing the wire. Clears the set so nothing further is delivered. */ - teardownAll(): { - jsonrpc: '2.0'; - id: RequestId; - result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } }; - }[] { - const out: { - jsonrpc: '2.0'; - id: RequestId; - result: { resultType: 'complete'; _meta: { [SUBSCRIPTION_ID_META_KEY]: RequestId } }; - }[] = []; + teardownAll(): ListenCloseFrame[] { + const out: ListenCloseFrame[] = []; for (const id of this._subs.keys()) { - out.push({ jsonrpc: '2.0', id, result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: id } } }); + out.push({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + _meta: { + [SUBSCRIPTION_ID_META_KEY]: id, + ...(this._serverInfo !== undefined && { [SERVER_INFO_META_KEY]: this._serverInfo }) + } + } + }); } this._subs.clear(); return out; diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index d01067483f..6c46671b50 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -78,7 +78,7 @@ import type { McpServerFactory } from './createMcpHandler'; import { DEFAULT_MAX_SUBSCRIPTIONS, StdioListenRouter } from './listenRouter'; import { McpServer } from './mcp'; import type { Server } from './server'; -import { installModernOnlyHandlers } from './server'; +import { installModernOnlyHandlers, serverIdentityOf } from './server'; import { StdioServerTransport } from './stdio'; /** Options for {@linkcode serveStdio}. */ @@ -518,9 +518,10 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions setNegotiatedProtocolVersion(server, revision); installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS); // The listen router was created before this instance existed; now - // that capabilities are known, hand them over so the acknowledged - // filter is narrowed against what the server actually advertises. - listenRouter.setServerCapabilities(server.getCapabilities()); + // that capabilities are known, hand them over (with the identity + // stamped onto graceful-close results) so the acknowledged filter + // is narrowed against what the server actually advertises. + listenRouter.setServerCapabilities(server.getCapabilities(), serverIdentityOf(server)); } const channel: StdioConnectionChannel = new StdioConnectionChannel( wire, diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index d69e01c1b2..1582f8c6eb 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -206,6 +206,7 @@ export type ServerOptions = ProtocolOptions & { */ let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; +let readServerIdentity: (server: Server) => Implementation; /** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ export interface PerRequestClientIdentity { @@ -239,6 +240,17 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions: installDiscoverHandler(server, servedModernVersions); } +/** + * Package-internal: the instance's implementation identity, for the serving + * entries to stamp onto entry-built results (the `subscriptions/listen` + * graceful-close result — built outside the encode seam, but the spec's + * `SubscriptionsListenResultMeta` extends `ResultMetaObject`, so it carries + * the serverInfo SHOULD like every other result). Not public API. + */ +export function serverIdentityOf(server: Server): Implementation { + return readServerIdentity(server); +} + /** * An MCP server on top of a pluggable transport. * @@ -268,6 +280,7 @@ export class Server extends Protocol { } server.setRequestHandler('server/discover', () => server._ondiscover()); }; + readServerIdentity = server => server._serverInfo; } private _capabilities: ServerCapabilities; private _instructions?: string; @@ -913,18 +926,28 @@ export class Server extends Protocol { /** * Answers `server/discover` (protocol revision 2026-07-28). `supportedVersions` * lists only modern revisions (2025-era versions are negotiated via `initialize`); - * the advertised capabilities exclude the listChanged/subscribe-class capabilities + * the capabilities are advertised as-is, listChanged/subscribe bits included * (see {@linkcode discoverAdvertisedCapabilities}). */ private _ondiscover(): DiscoverResult { + // Server identity is not a body field: the encode seam stamps it into + // the result `_meta` via `_outboundServerInfo` (spec PR #3002). return { supportedVersions: modernProtocolVersions(this._supportedProtocolVersions), capabilities: discoverAdvertisedCapabilities(this.getCapabilities()), - serverInfo: this._serverInfo, ...(this._instructions && { instructions: this._instructions }) }; } + /** + * The identity the 2026-era encode seam stamps into every outbound + * result's `_meta` under `io.modelcontextprotocol/serverInfo` (spec PR + * #3002: servers SHOULD identify themselves on every response). + */ + protected override _outboundServerInfo(): Implementation | undefined { + return this._serverInfo; + } + /** * After initialization has completed, this will be populated with the client's reported capabilities. * diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index f3237221e0..232f781926 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -128,9 +128,12 @@ describe('createMcpHandler — modern path', () => { postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) ); expect(response.status).toBe(200); - const body = (await response.json()) as { result: { supportedVersions: string[]; serverInfo: { name: string } } }; + const body = (await response.json()) as { + result: { supportedVersions: string[]; _meta: Record }; + }; expect(body.result.supportedVersions).toEqual([MODERN_REVISION]); - expect(body.result.serverInfo.name).toBe('entry-test-server'); + // #3002: identity in the result `_meta`, never the body. + expect(body.result._meta['io.modelcontextprotocol/serverInfo']!.name).toBe('entry-test-server'); }); it('backfills the deprecated accessors and the negotiated revision from the validated envelope (per-request instance state)', async () => { @@ -309,7 +312,10 @@ describe('createMcpHandler — modern path', () => { expect(response.status).toBe(400); const body = (await response.json()) as JSONRPCErrorBody; expect(body.error.code).toBe(-32_602); - expect(JSON.stringify(body.error.data)).toContain('clientInfo'); + // clientCapabilities is the missing REQUIRED key; clientInfo is a + // SHOULD since spec PR #3002 and its absence is never an error. + expect(JSON.stringify(body.error.data)).toContain('clientCapabilities'); + expect(JSON.stringify(body.error.data)).not.toContain('clientInfo'); expect(body.id).toBe(1); expect(state.contexts).toHaveLength(0); }); diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 46eec5fde0..2fa5000742 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -208,7 +208,16 @@ describe('createMcpHandler — subscriptions/listen', () => { expect(resultFrame).toEqual({ jsonrpc: '2.0', id: 1, - result: { resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': 1 } } + result: { + resultType: 'complete', + _meta: { + 'io.modelcontextprotocol/subscriptionId': 1, + // #3002: the close result carries the serving instance's + // identity like every other result (SubscriptionsListenResultMeta + // extends ResultMetaObject). + 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } + } + } }); }); diff --git a/packages/server/test/server/discover.test.ts b/packages/server/test/server/discover.test.ts index d1f1db73f6..56fd572e6a 100644 --- a/packages/server/test/server/discover.test.ts +++ b/packages/server/test/server/discover.test.ts @@ -30,6 +30,7 @@ import { isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, + SERVER_INFO_META_KEY, setNegotiatedProtocolVersion, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; @@ -99,12 +100,39 @@ describe('server/discover handler gating', () => { if (isJSONRPCResultResponse(response)) { const result = DiscoverResultSchema.parse(response.result); expect(result.supportedVersions).toEqual([MODERN]); - expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' }); + // #3002: identity travels in the result `_meta`, never the body. + expect(result._meta?.[SERVER_INFO_META_KEY]).toEqual({ name: 'modern-server', version: '2.0.0' }); + expect('serverInfo' in (response.result as Record)).toBe(false); expect(result.instructions).toBe('hello'); } await server.close(); }); + it('serves discover for a client that omits clientInfo (SHOULD since spec PR #3002)', async () => { + const server = new Server( + { name: 'modern-server', version: '2.0.0' }, + { capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS } + ); + const withoutClientInfo: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }; + const response = await sendRaw(server, withoutClientInfo, { markModern: true }); + expect(isJSONRPCResultResponse(response)).toBe(true); + if (isJSONRPCResultResponse(response)) { + const result = DiscoverResultSchema.parse(response.result); + expect(result.supportedVersions).toEqual([MODERN]); + } + await server.close(); + }); + it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); const response = await sendRaw(server, discoverRequest, { markModern: true }); diff --git a/packages/server/test/server/serveStdioListen.test.ts b/packages/server/test/server/serveStdioListen.test.ts index 0edb634398..44deb64396 100644 --- a/packages/server/test/server/serveStdioListen.test.ts +++ b/packages/server/test/server/serveStdioListen.test.ts @@ -121,9 +121,12 @@ describe('serveStdio — subscriptions/listen', () => { // the wire closes. No notifications/cancelled (the pre-#2953 path). const results = inbound.filter(m => 'result' in m) as { id: unknown; result: unknown }[]; expect(results.map(m => m.id)).toEqual(['s1', 's2']); + // #3002: each close result carries the serving instance's identity + // (SubscriptionsListenResultMeta extends ResultMetaObject). + const serverInfo = { name: 's', version: '1' }; expect(results.map(m => m.result)).toEqual([ - { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1' } }, - { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's2' } } + { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1', 'io.modelcontextprotocol/serverInfo': serverInfo } }, + { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's2', 'io.modelcontextprotocol/serverInfo': serverInfo } } ]); expect(inbound.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false); }); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index bcc454700b..1213f40b35 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -28,10 +28,17 @@ client: [] # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- # (empty: SEP-2468/2352/2350/837 burned by the auth bundle; SEP-2106 burned earlier) -server: [] +server: # --- Carried-forward scenarios (also run by the 2025 legs) --- - # (empty: json-schema-2020-12 burned by the SEP-2106 fixture; + # (json-schema-2020-12 burned by the SEP-2106 fixture; # sep-2164-resource-not-found burned by the spec#2907 error-code renumber + - # alpha.5 referee; server-stateless burned by conformance#376 at alpha.9 — - # the referee's `requiredCapabilities` assertion now matches the schema's - # `ClientCapabilities` object, which is what this SDK emits.) + # alpha.5 referee.) + # + # --- spec PR #3002 — referee pinned at alpha.9 asserts the OLD shape --- + # Same three failing checks as the 2025-leg baseline entry + # (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): + # this SDK follows the final revision. Remove when the pin bumps to a + # conformance release that incorporates #3002 (alpha.10+). + - server-stateless diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 79e5facbd2..8e88e75cce 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -23,6 +23,17 @@ client: [] # last referee-side gap — conformance#361 callback-iss — closed at alpha.6) server: + # --- spec PR #3002 — referee pinned at alpha.9 asserts the OLD shape --- + # The alpha.9 `server-stateless` scenario still enforces the pre-#3002 + # spec: clientInfo required in the envelope, serverInfo a DiscoverResult + # body field. This SDK follows the final revision (clientInfo optional; + # identity in the result _meta), so exactly three checks fail: + # - sep-2575-request-meta-invalid-missing-client-info + # - sep-2575-http-server-meta-invalid-400 (the missing-client-info iteration) + # - sep-2575-server-implements-discover (requires body serverInfo) + # Remove this entry when the pin bumps to a conformance release that + # incorporates #3002 (alpha.10+). + - server-stateless # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- # Extension-tagged scenarios; selected only by `--suite all` (the alpha.9 referee # has no server-side `--suite extensions`). The active/draft/2026 legs never select diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 0c1e010fba..3ba48ccbf7 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -366,7 +366,7 @@ function withLocalDiscoverResponse(serverInfo: { name: string; version: string } // list/read/get calls reach the real mock; callers that // only use tools are unaffected by the extra entries. capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} }, - serverInfo + _meta: { 'io.modelcontextprotocol/serverInfo': serverInfo } } }, { status: 200, headers: { 'Content-Type': 'application/json' } } diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 995d5096b9..c81783d509 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1365,7 +1365,24 @@ export const REQUIREMENTS: Record = { }, 'protocol:meta:result-to-client': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta', - behavior: "_meta returned in a handler's result is delivered intact to the requesting client." + behavior: + "_meta returned in a handler's result is delivered intact to the requesting client. On a 2026-07-28 connection the delivered _meta additionally carries the SDK-stamped io.modelcontextprotocol/serverInfo key (spec PR #3002: servers SHOULD identify themselves on every response); on 2025-era connections nothing is ever added." + }, + 'protocol:meta:server-identity': { + source: 'https://modelcontextprotocol.io/specification/draft/server/discover', + behavior: + "On the 2026-07-28 revision the server identifies itself via _meta['io.modelcontextprotocol/serverInfo'] on its responses (spec PR #3002), and the client resolves getServerVersion() from the discover result's _meta.", + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'entryModern is the only arm that negotiates a real 2026-07-28 connection (the other arms run the legacy handshake), and the body also POSTs a raw discover via wired.fetch to assert the wire-level _meta stamp.' + }, + 'protocol:envelope:client-info-optional': { + source: 'https://modelcontextprotocol.io/specification/draft/basic#meta', + behavior: + 'A 2026-07-28 request whose per-request _meta envelope omits io.modelcontextprotocol/clientInfo is served normally — clientInfo is a SHOULD (spec PR #3002), never a validation requirement.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: "The wired SDK client always sends clientInfo (the spec SHOULD), so the omission can only be put on the wire as a raw POST through wired.fetch — which needs the entry arm's in-process endpoint." }, 'protocol:request-id:unique': { entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 1ba2fb85aa..7aaed05b3b 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -35,7 +35,7 @@ import { import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; -import { tapWire, wire } from '../helpers/index'; +import { modernEnvelopeMeta, tapWire, wire } from '../helpers/index'; import { verifies } from '../helpers/verifies'; import type { TestArgs } from '../types'; @@ -1128,8 +1128,71 @@ verifies('protocol:meta:result-to-client', async ({ transport }: TestArgs) => { const result = await client.callTool({ name: 'metered_call', arguments: {} }); expect(result.content).toEqual([{ type: 'text', text: 'metered' }]); - // The _meta the handler attached to its result reaches the requesting client unchanged. - expect(result._meta).toEqual(resultMeta); + // The _meta the handler attached to its result reaches the requesting + // client unchanged. On a 2026-era connection (the entryModern arm) the + // encode seam additionally stamps the server's identity (spec PR #3002: + // `_meta` serverInfo SHOULD on every response); on 2025-era connections + // nothing is ever added (never-stamp). + const expectedMeta = + client.getNegotiatedProtocolVersion() === '2026-07-28' + ? { ...resultMeta, 'io.modelcontextprotocol/serverInfo': { name: 's', version: '0' } } + : resultMeta; + expect(result._meta).toEqual(expectedMeta); +}); + +verifies('protocol:meta:server-identity', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 'identity-server', version: '4.2.0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ tools: [] })); + return s; + }; + const client = newClient(); + await using wired = await wire(transport, makeServer, client); + + // Client-side resolution: getServerVersion() reads the discover _meta. + expect(client.getServerVersion()).toEqual({ name: 'identity-server', version: '4.2.0' }); + + // Wire-side: a raw discover response carries the _meta stamp. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'server/discover' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: modernEnvelopeMeta() } }) + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { _meta?: Record } }; + expect(body.result._meta?.['io.modelcontextprotocol/serverInfo']).toEqual({ name: 'identity-server', version: '4.2.0' }); +}); + +verifies('protocol:envelope:client-info-optional', async ({ transport }: TestArgs) => { + const makeServer = () => { + const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/list', () => ({ tools: [] })); + return s; + }; + const client = newClient(); + await using wired = await wire(transport, makeServer, client); + + const meta = modernEnvelopeMeta(); + delete meta['io.modelcontextprotocol/clientInfo']; + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta } }) + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { result?: { tools?: unknown[] }; error?: unknown }; + expect(body.error).toBeUndefined(); + expect(body.result?.tools).toEqual([]); }); verifies('protocol:request-id:unique', async ({ transport }: TestArgs) => { @@ -1611,7 +1674,7 @@ class LoopbackTransport implements Transport { this.respond(message.id, { supportedVersions: [this.serverProtocolVersion], capabilities: { tools: {} }, - serverInfo: { name: 'loopback-server', version: '3.1.4' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'loopback-server', version: '3.1.4' } } }); break; } diff --git a/test/e2e/scenarios/raw-result-type.test.ts b/test/e2e/scenarios/raw-result-type.test.ts index 38db4c9a25..5d648bfe12 100644 --- a/test/e2e/scenarios/raw-result-type.test.ts +++ b/test/e2e/scenarios/raw-result-type.test.ts @@ -63,7 +63,7 @@ function makeResponder(toolCallBody: unknown) { return { supportedVersions: ['2026-07-28'], capabilities: { tools: {} }, - serverInfo: { name: 'raw-input-required-server', version: '0' } + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'raw-input-required-server', version: '0' } } }; } if (request.method === 'tools/call') return toolCallBody;