Skip to content

Commit f413763

Browse files
Align 2026-07-28 wire with spec PR #3002: serverInfo in result _meta, clientInfo optional (#2513)
1 parent f60dff0 commit f413763

61 files changed

Lines changed: 933 additions & 207 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@modelcontextprotocol/core-internal': minor
3+
'@modelcontextprotocol/core': minor
4+
'@modelcontextprotocol/client': minor
5+
'@modelcontextprotocol/server': minor
6+
---
7+
8+
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.
9+
10+
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`.
11+
12+
Now:
13+
14+
- 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).
15+
- 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`.
16+
- 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).
17+
- Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).

docs/advanced/gateway.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The recorded value is the server's whole advertisement — supported versions, c
4444
cacheScope: 'private',
4545
supportedVersions: [ '2026-07-28' ],
4646
capabilities: { tools: { listChanged: true } },
47-
serverInfo: { name: 'gateway-target', version: '1.0.0' },
47+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'gateway-target', version: '1.0.0' } },
4848
resultType: 'complete'
4949
}
5050
```

docs/migration/support-2026-07-28.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ below.
2626
- [Auth on 2026-07-28](#auth-on-2026-07-28)
2727
- [Per-era wire codecs](#per-era-wire-codecs)
2828
- [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types)
29+
- [Server identity in result `_meta`; `clientInfo` demoted to SHOULD](#server-identity-in-result-_meta-clientinfo-demoted-to-should)
2930
- [Multi-round-trip requests](#multi-round-trip-requests)
3031
- [Legacy shim for `input_required`](#legacy-shim-for-input_required)
3132
- [`subscriptions/listen`](#subscriptionslisten)
@@ -417,6 +418,35 @@ the instance's negotiated era.
417418

418419
---
419420

421+
## Server identity in result `_meta`; `clientInfo` demoted to SHOULD
422+
423+
The final 2026-07-28 revision (spec PR #3002) moved server identity out of the
424+
`DiscoverResult` body: servers identify themselves via
425+
`_meta['io.modelcontextprotocol/serverInfo']` (constant `SERVER_INFO_META_KEY`) on
426+
every response, and the per-request envelope's `clientInfo` is a SHOULD instead of a
427+
requirement.
428+
429+
What the SDK does:
430+
431+
- **Server.** Every 2026-era response gets the `_meta` serverInfo stamp (a
432+
handler-authored value wins; 2025-era responses are untouched) — including the
433+
entry-built `subscriptions/listen` graceful-close results, whose `_meta` carries
434+
the identity next to the subscription id. Requests without `clientInfo` are
435+
served; a present-but-malformed value is still rejected.
436+
- **Client.** `clientInfo` is still sent on every request (the spec SHOULD).
437+
`getServerVersion()` reads the discover result's `_meta`. A server that stamps no
438+
identity is simply anonymous: the connection works, `getServerVersion()` is
439+
`undefined`, and the response cache partitions under a per-connection surrogate.
440+
- **Types.** `DiscoverResult` has no `serverInfo` member, and `RequestMetaEnvelope`'s
441+
`clientInfo` is optional. Code that read `discover.serverInfo` moves to
442+
`client.getServerVersion()` (or the `_meta` key directly).
443+
444+
`serverInfo`/`clientInfo` are self-reported and intended for display, logging, and
445+
debugging — do not use them for behavior or security decisions. A malformed `_meta`
446+
serverInfo value is treated as absent on receive, per the same clause.
447+
448+
---
449+
420450
## Multi-round-trip requests
421451

422452
The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers

examples/gateway/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ check.equal(bootstrap.getNegotiatedProtocolVersion(), '2026-07-28');
3838

3939
const discovered = bootstrap.getDiscoverResult();
4040
check.ok(discovered, 'bootstrap connect populated getDiscoverResult()');
41-
check.deepEqual(discovered?.serverInfo, { name: 'gateway-target', version: '1.0.0' });
41+
// Server identity: getServerVersion() resolves it from the discover result's
42+
// `_meta['io.modelcontextprotocol/serverInfo']` (spec PR #3002).
43+
check.deepEqual(bootstrap.getServerVersion(), { name: 'gateway-target', version: '1.0.0' });
4244

4345
// The probe was the only request so far; the request_count call is the
4446
// second. (createMcpHandler builds one server instance per request.)

packages/client/src/client/client.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
isJSONRPCErrorResponse,
6262
isJSONRPCRequest,
6363
isModernProtocolVersion,
64+
isSpecType,
6465
legacyProtocolVersions,
6566
ListChangedOptionsBaseSchema,
6667
mergeCapabilities,
@@ -74,6 +75,7 @@ import {
7475
scanXMcpHeaderDeclarations,
7576
SdkError,
7677
SdkErrorCode,
78+
SERVER_INFO_META_KEY,
7779
SUBSCRIPTION_ID_META_KEY,
7880
SUPPORTED_MODERN_PROTOCOL_VERSIONS
7981
} from '@modelcontextprotocol/core-internal';
@@ -84,6 +86,18 @@ import { ClientResponseCache, InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } fro
8486
import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation';
8587
import { detectProbeEnvironment, detectProbeTransportKind, negotiateEra, resolveVersionNegotiation } from './versionNegotiation';
8688

89+
/**
90+
* The server identity a `DiscoverResult` carries in
91+
* `_meta['io.modelcontextprotocol/serverInfo']` (a spec SHOULD — absent means
92+
* the server offered no identity). Runtime-validated with the spec-type
93+
* guard because `connect({ prior })` accepts caller-supplied values that
94+
* never saw a wire parse.
95+
*/
96+
function serverInfoFromDiscover(discover: DiscoverResult): Implementation | undefined {
97+
const fromMeta = discover._meta?.[SERVER_INFO_META_KEY];
98+
return isSpecType.Implementation(fromMeta) ? fromMeta : undefined;
99+
}
100+
87101
/**
88102
* Elicitation default application helper. Applies defaults to the `data` based on the `schema`.
89103
*
@@ -1122,7 +1136,7 @@ export class Client extends Protocol<ClientContext> {
11221136
}
11231137

11241138
this._serverCapabilities = result.discover.capabilities;
1125-
this._serverVersion = result.discover.serverInfo;
1139+
this._serverVersion = serverInfoFromDiscover(result.discover);
11261140
this._cache.setServerIdentity(this._deriveServerIdentity(transport));
11271141
this._instructions = result.discover.instructions;
11281142
this._discoverResult = result.discover;
@@ -1251,7 +1265,7 @@ export class Client extends Protocol<ClientContext> {
12511265

12521266
this._discoverResult = discover;
12531267
this._serverCapabilities = discover.capabilities;
1254-
this._serverVersion = discover.serverInfo;
1268+
this._serverVersion = serverInfoFromDiscover(discover);
12551269
this._cache.setServerIdentity(this._deriveServerIdentity(transport));
12561270
this._instructions = discover.instructions;
12571271
this._negotiatedProtocolVersion = version;
@@ -1275,24 +1289,33 @@ export class Client extends Protocol<ClientContext> {
12751289
}
12761290

12771291
/**
1278-
* After initialization has completed, this will be populated with information about the server's name and version.
1292+
* The connected server's self-reported name and version, when it
1293+
* identified itself: required on the legacy `initialize` result; a spec
1294+
* SHOULD in the discover result's `_meta` on 2026-07-28, so a successful
1295+
* modern connect against an anonymous server leaves this `undefined`.
12791296
*/
12801297
getServerVersion(): Implementation | undefined {
12811298
return this._serverVersion;
12821299
}
12831300

12841301
/**
12851302
* The connected server's identity for response-cache partitioning. The
1286-
* `serverInfo` `name@version` pair when available (the spec requires it on
1287-
* both `initialize` and `server/discover`); falls back to the transport's
1288-
* `sessionId` otherwise. The value itself is server-controlled — the
1289-
* collision-safety of the storage partition comes from
1303+
* `serverInfo` `name@version` pair when available (required on
1304+
* `initialize`; a SHOULD in the discover result's `_meta` since spec PR
1305+
* #3002); falls back to the transport's `sessionId`, then to a
1306+
* per-connection surrogate. The surrogate matters since #3002 made
1307+
* identity optional: without it, two identity-less servers reached over
1308+
* sessionId-less transports would share the cache's pre-connect `''`
1309+
* partition and read each other's entries — no stable identity means no
1310+
* cross-connection cache reuse. The value itself is server-controlled —
1311+
* the collision-safety of the storage partition comes from
12901312
* {@linkcode ClientResponseCache}'s JSON-array encoding around it, not
12911313
* from any character it does or does not contain.
12921314
*/
12931315
private _deriveServerIdentity(transport: Transport): string {
12941316
const v = this._serverVersion;
1295-
return v === undefined ? (transport.sessionId ?? '') : `${v.name}@${v.version}`;
1317+
if (v !== undefined) return `${v.name}@${v.version}`;
1318+
return transport.sessionId ?? `anonymous:${Date.now()}-${Math.random().toString(36).slice(2)}`;
12961319
}
12971320

12981321
/**

packages/client/src/client/responseCache.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,13 @@ export class ClientResponseCache {
299299
*/
300300
private _toolOutputValidatorIndex?: { stamp: number; byName: Map<string, unknown> };
301301
/**
302-
* The connected server's identity (`serverInfo.name@version`, or a
303-
* transport-supplied surrogate). Set by the `Client` immediately after a
304-
* successful connect; `''` is the pre-connect sentinel. Every storage
305-
* partition is derived from this (see `_partitionFor`), so two
306-
* clients sharing one store but connected to different servers never
307-
* collide on `tools/list` and a server cannot read another server's
308-
* `'public'` entries.
302+
* The connected server's identity (`serverInfo.name@version`, the
303+
* transport's `sessionId`, or a client-generated per-connection
304+
* surrogate). Set by the `Client` immediately after a successful connect;
305+
* `''` is the pre-connect sentinel. Every storage partition is derived
306+
* from this (see `_partitionFor`), so two clients sharing one store but
307+
* connected to different servers never collide on `tools/list` and a
308+
* server cannot read another server's `'public'` entries.
309309
*/
310310
private _serverIdentity = '';
311311

@@ -347,10 +347,15 @@ export class ClientResponseCache {
347347

348348
/**
349349
* Record the connected server's identity. Called by `Client` immediately
350-
* after a successful connect (`serverInfo.name@version`, or the
351-
* transport's `sessionId` when `serverInfo` is unavailable). Every
352-
* partition derived after this call is scoped to this identity; entries
353-
* written under the pre-connect `''` sentinel are no longer reachable.
350+
* after a successful connect: `serverInfo.name@version` when the server
351+
* identified itself, else the transport's `sessionId`, else a
352+
* client-generated per-connection surrogate (`serverInfo` is a spec
353+
* SHOULD on 2026-07-28, so anonymous servers exist). Surrogate-keyed
354+
* partitions are NOT stable across reconnects — no identity means no
355+
* cross-connection cache reuse, and a shared long-lived store should
356+
* bound its own size accordingly. Every partition derived after this
357+
* call is scoped to this identity; entries written under the pre-connect
358+
* `''` sentinel are no longer reachable.
354359
*/
355360
setServerIdentity(identity: string): void {
356361
this._serverIdentity = identity;

packages/client/test/client/connectPrior.test.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class ScriptedTransport implements Transport {
4848
const discoverResult = (supportedVersions: string[]): DiscoverResult => ({
4949
supportedVersions,
5050
capabilities: { tools: { listChanged: true } },
51-
serverInfo: { name: 'persisted-server', version: '1.0.0' },
51+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'persisted-server', version: '1.0.0' } },
5252
instructions: 'persisted instructions'
5353
});
5454

@@ -149,15 +149,15 @@ describe('getDiscoverResult() round-trip', () => {
149149
result: {
150150
supportedVersions: [MODERN],
151151
capabilities: { tools: {} },
152-
serverInfo: { name: 'probed-server', version: '2.0.0' }
152+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'probed-server', version: '2.0.0' } }
153153
}
154154
});
155155
}
156156
});
157157
const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } });
158158
await bootstrap.connect(bootstrapTransport);
159159
const probed = bootstrap.getDiscoverResult();
160-
expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' });
160+
expect(bootstrap.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' });
161161
expect(probed?.supportedVersions).toEqual([MODERN]);
162162
await bootstrap.close();
163163
// close() clears per-connection state.
@@ -190,17 +190,19 @@ describe('getDiscoverResult() round-trip', () => {
190190
cacheScope: 'public',
191191
supportedVersions: [MODERN],
192192
capabilities: { tools: {} },
193-
serverInfo: { name: 'rediscovered', version: '3.0.0' }
193+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'rediscovered', version: '3.0.0' } }
194194
}
195195
});
196196
}
197197
});
198198
const client = new Client({ name: 'c', version: '0' });
199199
await client.connect(transport, { prior: modernPrior([MODERN]) });
200-
expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server');
200+
const metaName = (r?: { _meta?: Record<string, unknown> }) =>
201+
(r?._meta?.['io.modelcontextprotocol/serverInfo'] as { name?: string } | undefined)?.name;
202+
expect(metaName(client.getDiscoverResult())).toBe('persisted-server');
201203
const fresh = await client.discover();
202-
expect(fresh.serverInfo.name).toBe('rediscovered');
203-
expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered');
204+
expect(metaName(fresh)).toBe('rediscovered');
205+
expect(metaName(client.getDiscoverResult())).toBe('rediscovered');
204206
await client.close();
205207
});
206208
});
@@ -348,4 +350,24 @@ describe('connect({ prior }) — malformed persisted blobs (runtime hardening)',
348350
);
349351
expect(transport.sent).toHaveLength(0);
350352
});
353+
354+
test('a stale blob with a body serverInfo (not a spec field) connects with anonymous identity — the body is never read', async () => {
355+
// The 2026-07-28 revision has no body serverInfo on DiscoverResult:
356+
// identity travels in _meta (spec PR #3002). A persisted blob carrying
357+
// the non-spec body member is still a schema-valid DiscoverResult
358+
// (loose results tolerate unknown members), so validatePrior accepts
359+
// it — but the identity read consults _meta only, so the connection is
360+
// anonymous rather than adopting a field the spec does not define.
361+
const transport = new ScriptedTransport();
362+
const client = new Client({ name: 'worker', version: '0' });
363+
364+
const blob =
365+
'{"kind":"modern","discover":{"supportedVersions":["2026-07-28"],"capabilities":{},' +
366+
'"serverInfo":{"name":"stale-server","version":"0.4.0"}}}';
367+
await client.connect(transport, { prior: JSON.parse(blob) as PriorDiscovery });
368+
expect(transport.sent).toHaveLength(0);
369+
expect(client.getProtocolEra()).toBe('modern');
370+
expect(client.getServerVersion()).toBeUndefined();
371+
await client.close();
372+
});
351373
});

packages/client/test/client/discover.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const discoverBody = {
4444
cacheScope: 'public',
4545
supportedVersions: [MODERN],
4646
capabilities: { tools: {} },
47-
serverInfo: { name: 'modern-server', version: '1.0.0' },
47+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'modern-server', version: '1.0.0' } },
4848
instructions: 'modern instructions'
4949
};
5050

@@ -78,7 +78,7 @@ describe('Client.discover()', () => {
7878

7979
const advertisement = await client.discover();
8080
expect(advertisement.supportedVersions).toEqual([MODERN]);
81-
expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' });
81+
expect(advertisement._meta?.['io.modelcontextprotocol/serverInfo']).toEqual({ name: 'modern-server', version: '1.0.0' });
8282
expect(advertisement.instructions).toBe('modern instructions');
8383

8484
await client.close();
@@ -99,3 +99,49 @@ describe('Client.discover()', () => {
9999
await client.close();
100100
});
101101
});
102+
103+
describe('server identity from a DiscoverResult (#3002: _meta only)', () => {
104+
const base = { resultType: 'complete', ttlMs: 0, cacheScope: 'public', supportedVersions: [MODERN], capabilities: {} };
105+
const metaIdentity = { name: 'meta-server', version: '2.0.0' };
106+
107+
async function connectAgainst(result: Record<string, unknown>): Promise<Client> {
108+
const transport = new ScriptedTransport((message, t) => {
109+
if (isJSONRPCRequest(message) && message.method === 'server/discover') {
110+
t.reply({ jsonrpc: '2.0', id: message.id, result });
111+
}
112+
});
113+
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
114+
await client.connect(transport);
115+
return client;
116+
}
117+
118+
test('serverInfo in _meta yields the identity', async () => {
119+
const client = await connectAgainst({ ...base, _meta: { 'io.modelcontextprotocol/serverInfo': metaIdentity } });
120+
expect(client.getServerVersion()).toEqual(metaIdentity);
121+
await client.close();
122+
});
123+
124+
test('a stray body serverInfo is ignored — identity comes from _meta only', async () => {
125+
const client = await connectAgainst({ ...base, serverInfo: { name: 'body-only', version: '0.9.0' } });
126+
expect(client.getServerVersion()).toBeUndefined();
127+
await client.close();
128+
});
129+
130+
test('absent identity → undefined, connection still established (serverInfo is a SHOULD)', async () => {
131+
const client = await connectAgainst(base);
132+
expect(client.getServerVersion()).toBeUndefined();
133+
await client.close();
134+
});
135+
136+
test('a MALFORMED _meta serverInfo still connects modern through the real transport path (regression: the neutral-schema classification guard must not drop the frame)', async () => {
137+
// Exercises the full inbound pipeline — transport.onmessage →
138+
// isJSONRPCResultResponse (neutral JSONRPCResultResponseSchema) →
139+
// probe classification — not a pre-built ProbeOutcome. A strict
140+
// neutral serverInfo key made this connect fail before the frame
141+
// reached the lenient wire schema.
142+
const client = await connectAgainst({ ...base, _meta: { 'io.modelcontextprotocol/serverInfo': 'utterly-bogus' } });
143+
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
144+
expect(client.getServerVersion()).toBeUndefined();
145+
await client.close();
146+
});
147+
});

0 commit comments

Comments
 (0)