This commit is contained in:
parent
f60dff0674
commit
f4137630c0
51 changed files with 933 additions and 207 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
HandlerResultTypeMap,
|
||||
Implementation,
|
||||
JSONRPCErrorResponse,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
|
|
@ -1113,7 +1114,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
|
|||
// −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<ContextT extends BaseContext> {
|
|||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export {
|
|||
ResourceTemplateSchema,
|
||||
ResourceUpdatedNotificationParamsSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
ResultMetaObjectSchema,
|
||||
ResultSchema,
|
||||
RoleSchema,
|
||||
RootSchema,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue