This commit is contained in:
parent
f60dff0674
commit
f4137630c0
12 changed files with 933 additions and 207 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue