This commit is contained in:
parent
f60dff0674
commit
f4137630c0
61 changed files with 933 additions and 207 deletions
17
.changeset/spec-3002-servinfo-meta.md
Normal file
17
.changeset/spec-3002-servinfo-meta.md
Normal file
|
|
@ -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'`).
|
||||
|
|
@ -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'
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1275,7 +1289,10 @@ 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;
|
||||
|
|
@ -1283,16 +1300,22 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,49 @@ 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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Exercises the full inbound pipeline — transport.onmessage →
|
||||
// isJSONRPCResultResponse (neutral JSONRPCResultResponseSchema) →
|
||||
// probe classification — not a pre-built ProbeOutcome. A strict
|
||||
// neutral serverInfo key made this connect fail before the frame
|
||||
// reached the lenient wire schema.
|
||||
const client = await connectAgainst({ ...base, _meta: { 'io.modelcontextprotocol/serverInfo': 'utterly-bogus' } });
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
|
||||
expect(client.getServerVersion()).toBeUndefined();
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function classify(outcome: ProbeOutcome, context: Partial<ProbeClassifierContext
|
|||
const discoverResult = (supportedVersions: string[]) => ({
|
||||
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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
|
|||
'ResourceTemplateSchema',
|
||||
'ResourceUpdatedNotificationParamsSchema',
|
||||
'ResourceUpdatedNotificationSchema',
|
||||
'ResultMetaObjectSchema',
|
||||
'ResultSchema',
|
||||
'RoleSchema',
|
||||
'RootSchema',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue