Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/spec-3002-servinfo-meta.md
Original file line number Diff line number Diff line change
@@ -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'`).
2 changes: 1 addition & 1 deletion docs/advanced/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
```
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
30 changes: 30 additions & 0 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion examples/gateway/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
39 changes: 31 additions & 8 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
isJSONRPCErrorResponse,
isJSONRPCRequest,
isModernProtocolVersion,
isSpecType,
legacyProtocolVersions,
ListChangedOptionsBaseSchema,
mergeCapabilities,
Expand All @@ -74,6 +75,7 @@ import {
scanXMcpHeaderDeclarations,
SdkError,
SdkErrorCode,
SERVER_INFO_META_KEY,
SUBSCRIPTION_ID_META_KEY,
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';
Expand All @@ -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`.
*
Expand Down Expand Up @@ -1122,7 +1136,7 @@ export class Client extends Protocol<ClientContext> {
}

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;
Expand Down Expand Up @@ -1251,7 +1265,7 @@ export class Client extends Protocol<ClientContext> {

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;
Expand All @@ -1275,24 +1289,33 @@ export class Client extends Protocol<ClientContext> {
}

/**
* 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;
}

/**
* 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)}`;
}

/**
Expand Down
27 changes: 16 additions & 11 deletions packages/client/src/client/responseCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,13 +299,13 @@ export class ClientResponseCache {
*/
private _toolOutputValidatorIndex?: { stamp: number; byName: Map<string, unknown> };
/**
* 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 = '';

Expand Down Expand Up @@ -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;
Expand Down
36 changes: 29 additions & 7 deletions packages/client/test/client/connectPrior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});

Expand Down Expand Up @@ -149,15 +149,15 @@ 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' } }
}
});
}
});
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.
Expand Down Expand Up @@ -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<string, unknown> }) =>
(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();
});
});
Expand Down Expand Up @@ -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();
});
});
38 changes: 36 additions & 2 deletions packages/client/test/client/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};

Expand Down Expand Up @@ -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();
Expand All @@ -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<string, unknown>): Promise<Client> {
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();
});
});
2 changes: 1 addition & 1 deletion packages/client/test/client/envelopeAutoEmission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/client/inputRequiredEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading