From 2c19a18e2c136a6331ae20af3475b0bcdb05ee38 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Wed, 23 Sep 2026 15:29:14 +0100 Subject: [PATCH] test: cover optional legacy extension capability round trips --- SDK_INTEGRATION.md | 67 ++++++++ .../clients/typescript/everything-client.ts | 19 +++ .../typescript/legacy-extensions-broken.ts | 34 ++++ .../servers/typescript/everything-server.ts | 24 +++ .../typescript/legacy-extensions-broken.ts | 92 ++++++++++ src/index.ts | 9 +- .../client/legacy-extensions.test.ts | 91 ++++++++++ src/scenarios/client/legacy-extensions.ts | 154 +++++++++++++++++ src/scenarios/index.ts | 21 ++- src/scenarios/legacy-extensions.ts | 59 +++++++ src/scenarios/server/all-scenarios.test.ts | 2 + .../server/legacy-extensions.test.ts | 75 ++++++++ src/scenarios/server/legacy-extensions.ts | 161 ++++++++++++++++++ src/types.ts | 1 + 14 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 examples/clients/typescript/legacy-extensions-broken.ts create mode 100644 examples/servers/typescript/legacy-extensions-broken.ts create mode 100644 src/scenarios/client/legacy-extensions.test.ts create mode 100644 src/scenarios/client/legacy-extensions.ts create mode 100644 src/scenarios/legacy-extensions.ts create mode 100644 src/scenarios/server/legacy-extensions.test.ts create mode 100644 src/scenarios/server/legacy-extensions.ts diff --git a/SDK_INTEGRATION.md b/SDK_INTEGRATION.md index a0921150..d0d01ab1 100644 --- a/SDK_INTEGRATION.md +++ b/SDK_INTEGRATION.md @@ -206,3 +206,70 @@ See [`src/conformance/everything-server.ts`](https://github.com/modelcontextprot - [Conformance README](./README.md) - [Design documentation](./src/runner/DESIGN.md) - [TypeScript SDK conformance examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/src/conformance) + +## Legacy extension capability preservation (optional) + +`legacy-extensions` (client) and `server-legacy-extensions` (server) exercise +`capabilities.extensions` in the **2025-11-25 initialize handshake**, following +[SEP-2133](https://modelcontextprotocol.io/seps/2133-extensions#negotiation) and +[spec PR #3364](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3364). +These are opt-in compatibility fixtures, outside core/Tier-1 and dated-spec +selections. They use the Apps capability as an example; passing does **not** +certify Apps behavior, other extensions, older protocol versions, or the newer +per-request capability flow. + +Run without `--spec-version` (extensions are selected outside the core timeline): + +```sh +node dist/index.js client --scenario legacy-extensions --command "npx tsx examples/clients/typescript/everything-client.ts" +node dist/index.js server --scenario server-legacy-extensions --url http://localhost:3000/mcp +``` + +The client scenario is also in `client --suite extensions`; the server scenario +is in `server --suite extensions`. Both appear in `--suite all`. The server +scenario explicitly sends a 2025-11-25 handshake and checks the negotiated +version, bypassing the runner's usual draft-transport default for extensions. + +Configure the **client** with this extension map: + +```json +{ + "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] }, + "com.example/conformance": { + "nested": { "enabled": false, "limit": 0 }, + "values": ["a", 2, null] + }, + "com.example/empty": {} +} +``` + +Configure the **server** with this distinct map: + +```json +{ + "io.modelcontextprotocol/ui": {}, + "com.example/conformance": { + "nested": { "enabled": true, "limit": 3 }, + "values": [null, "b", 4] + }, + "com.example/empty": {} +} +``` + +The `com.example/*` identifiers are test-only fixtures for arbitrary nested +settings and empty objects. They require no implementation beyond this diagnostic +contract. Extra extension identifiers are allowed; each listed settings object +must survive unchanged. + +- **Client fixture:** After connecting, call `test_legacy_extension_capabilities` + with `{ "extensions": }`. +- **Server fixture:** Implement that tool with no required arguments. Return one + text content block containing JSON + `{ "extensions": }`. + +Use the SDK's actual capability accessors (TypeScript: `getServerCapabilities()` +and `getClientCapabilities()`), not raw HTTP input or hardcoded copies. This +makes SDK deserialization loss observable as well as serialization loss. Missing +advertisements fail these explicitly selected fixtures. A missing diagnostic +report/tool is a failing untestable check, not a skip. An implementation that +does not opt into extension support need not run these scenarios. diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index b3ccbed3..cc893bf2 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -12,6 +12,10 @@ * consolidating all the individual test clients into one. */ +import { + CLIENT_EXTENSIONS, + EXTENSIONS_ECHO_TOOL +} from '../../../src/scenarios/legacy-extensions.js'; import { fileURLToPath } from 'url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -195,6 +199,21 @@ async function runBasicClient(serverUrl: string): Promise { } registerScenarios(['initialize', 'tools_call', 'tools-call'], runBasicClient); +registerScenarios(['legacy-extensions'], async (serverUrl) => { + const client = new Client( + { name: 'legacy-extensions-client', version: '1.0.0' }, + { capabilities: { extensions: CLIENT_EXTENSIONS } } + ); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(serverUrl))); + await client.callTool({ + name: EXTENSIONS_ECHO_TOOL, + arguments: { extensions: client.getServerCapabilities()?.extensions } + }); + } finally { + await client.close(); + } +}); // SEP-2106: json-schema-ref-no-deref advertises a tool whose inputSchema // contains a network-URI $ref. A conformant client lists tools normally and diff --git a/examples/clients/typescript/legacy-extensions-broken.ts b/examples/clients/typescript/legacy-extensions-broken.ts new file mode 100644 index 00000000..fa382d76 --- /dev/null +++ b/examples/clients/typescript/legacy-extensions-broken.ts @@ -0,0 +1,34 @@ +/** Deliberately broken clients used by the legacy extension negative controls. */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + CLIENT_EXTENSIONS, + EXTENSIONS_ECHO_TOOL +} from '../../../src/scenarios/legacy-extensions.js'; + +export async function runBrokenLegacyClient( + url: string, + mode: 'advertisement' | 'reception' | 'settings' | 'missing-report' +) { + const client = new Client( + { name: 'broken-extensions-client', version: '1.0.0' }, + { + capabilities: + mode === 'advertisement' ? {} : { extensions: CLIENT_EXTENSIONS } + } + ); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + if (mode === 'missing-report') return; + const extensions = structuredClone( + client.getServerCapabilities()?.extensions ?? {} + ); + if (mode === 'settings') extensions['com.example/conformance'] = {}; + await client.callTool({ + name: EXTENSIONS_ECHO_TOOL, + arguments: { extensions: mode === 'reception' ? {} : extensions } + }); + } finally { + await client.close(); + } +} diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index c76c1f56..c01f8211 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -8,6 +8,10 @@ * we use tool() instead of registerTool() as there is a bug with logging in registerTool(). */ +import { + SERVER_EXTENSIONS, + EXTENSIONS_ECHO_TOOL +} from '../../../src/scenarios/legacy-extensions.js'; import { McpServer, ResourceTemplate @@ -213,6 +217,7 @@ function createMcpServer() { }, { capabilities: { + extensions: SERVER_EXTENSIONS, tools: { listChanged: true }, @@ -229,6 +234,25 @@ function createMcpServer() { } ); + mcpServer.registerTool( + EXTENSIONS_ECHO_TOOL, + { + description: + 'Report SDK-visible client extension capabilities for legacy conformance', + inputSchema: {} + }, + async () => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + extensions: mcpServer.server.getClientCapabilities()?.extensions + }) + } + ] + }) + ); + // SEP-2549: Wrap setRequestHandler so the SDK's own list handlers // automatically get caching hints appended to their responses. const originalSetRequestHandler = mcpServer.server.setRequestHandler.bind( diff --git a/examples/servers/typescript/legacy-extensions-broken.ts b/examples/servers/typescript/legacy-extensions-broken.ts new file mode 100644 index 00000000..d5679912 --- /dev/null +++ b/examples/servers/typescript/legacy-extensions-broken.ts @@ -0,0 +1,92 @@ +/** SDK server with deliberate serializer/accessor loss for negative controls. */ +import express from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + SERVER_EXTENSIONS, + EXTENSIONS_ECHO_TOOL +} from '../../../src/scenarios/legacy-extensions.js'; + +export async function startBrokenLegacyServer( + mode: + | 'advertisement' + | 'reception' + | 'settings' + | 'missing-report' + | 'wrong-version' +) { + const sdk = new McpServer( + { name: 'broken-extensions-server', version: '1.0.0' }, + { capabilities: { tools: {}, extensions: SERVER_EXTENSIONS } } + ); + if (mode !== 'missing-report') + sdk.registerTool(EXTENSIONS_ECHO_TOOL, { inputSchema: {} }, async () => { + const extensions = structuredClone( + sdk.server.getClientCapabilities()?.extensions ?? {} + ); + if (mode === 'settings') extensions['com.example/conformance'] = {}; + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + extensions: mode === 'reception' ? {} : extensions + }) + } + ] + }; + }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + // Reproduce serialization loss after the SDK has constructed the response. + const send = transport.send.bind(transport); + transport.send = async (message, options) => { + if ( + mode === 'wrong-version' && + 'result' in message && + 'protocolVersion' in message.result + ) { + return send( + { + ...message, + result: { ...message.result, protocolVersion: '2025-06-18' } + }, + options + ); + } + if ( + mode === 'advertisement' && + 'result' in message && + 'capabilities' in message.result + ) { + const copy = structuredClone(message); + delete (copy.result.capabilities as Record).extensions; + return send(copy, options); + } + return send(message, options); + }; + await sdk.connect(transport); + const app = express(); + app.use(express.json()); + app.all('/mcp', async (req, res) => { + await transport.handleRequest(req, res, req.body); + }); + const http = app.listen(0, '127.0.0.1'); + await new Promise((resolve, reject) => { + http.once('listening', resolve); + http.once('error', reject); + }); + const address = http.address(); + if (!address || typeof address === 'string') throw new Error('No port'); + return { + url: `http://127.0.0.1:${address.port}/mcp`, + async close() { + await sdk.close(); + http.closeAllConnections(); + await new Promise((resolve) => http.close(() => resolve())); + } + }; +} diff --git a/src/index.ts b/src/index.ts index b376644e..6149f3e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { listClientScenarios, listActiveClientScenarios, listPendingClientScenarios, + listExtensionClientScenarios, listAuthScenarios, listMetadataScenarios, listCoreScenarios, @@ -535,7 +536,7 @@ program ) .option( '--suite ', - 'Suite to run: "active" (default, excludes pending and draft), "all", "draft", or "pending"', + 'Suite to run: "active" (default, excludes pending, draft and optional extension fixtures), "all", "draft", "pending", or "extensions"', 'active' ) .option( @@ -638,6 +639,8 @@ program } else if (suite === 'active' || suite === 'core') { // 'core' is an alias for 'active' - tier 1 requirements scenarios = listActiveClientScenarios(); + } else if (suite === 'extensions') { + scenarios = listExtensionClientScenarios(); } else if (suite === 'pending') { scenarios = listPendingClientScenarios(); } else if (suite === 'draft') { @@ -646,7 +649,9 @@ program scenarios = listDraftClientScenarios(); } else { console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, core, draft, pending'); + console.error( + 'Available suites: active, all, core, draft, pending, extensions' + ); process.exit(1); } diff --git a/src/scenarios/client/legacy-extensions.test.ts b/src/scenarios/client/legacy-extensions.test.ts new file mode 100644 index 00000000..4ead23f6 --- /dev/null +++ b/src/scenarios/client/legacy-extensions.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { LegacyExtensionsScenario } from './legacy-extensions'; +import { CLIENT_EXTENSIONS, EXTENSIONS_ECHO_TOOL } from '../legacy-extensions'; +import { runBrokenLegacyClient } from '../../../examples/clients/typescript/legacy-extensions-broken'; +import { + listCoreScenarios, + listExtensionScenarios, + listScenariosForSpec +} from '../index'; +import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION } from '../../types'; + +describe('legacy client extensions', () => { + it('round-trips both directions through the real SDK', async () => { + const scenario = new LegacyExtensionsScenario(); + const { serverUrl } = await scenario.start(); + const client = new Client( + { name: 'extensions-test', version: '1.0.0' }, + { capabilities: { extensions: CLIENT_EXTENSIONS } } + ); + try { + await client.connect( + new StreamableHTTPClientTransport(new URL(serverUrl)) + ); + await client.callTool({ + name: EXTENSIONS_ECHO_TOOL, + arguments: { extensions: client.getServerCapabilities()?.extensions } + }); + expect(scenario.getChecks()).toHaveLength(7); + expect(scenario.getChecks().every((c) => c.status === 'SUCCESS')).toBe( + true + ); + expect(new Set(scenario.getChecks().map((c) => c.id)).size).toBe(7); + } finally { + await client.close(); + await scenario.stop(); + } + }); + it.each([ + 'advertisement', + 'reception', + 'settings', + 'missing-report' + ] as const)('detects %s loss', async (mode) => { + const scenario = new LegacyExtensionsScenario(); + const { serverUrl } = await scenario.start(); + try { + await runBrokenLegacyClient(serverUrl, mode); + const checks = scenario.getChecks(); + const id = + mode === 'missing-report' + ? 'legacy-extensions-client-report' + : `legacy-extensions-client-${mode === 'advertisement' ? 'advertisement' : 'reception'}-conformance`; + expect(checks.find((c) => c.id === id)?.status).toBe('FAILURE'); + if (mode === 'missing-report') + expect(checks.find((c) => c.id === id)?.details?.untestable).toBe(true); + } finally { + await scenario.stop(); + } + }); + it('fails if no client connects and resets between runs', async () => { + const scenario = new LegacyExtensionsScenario(); + await scenario.start(); + try { + expect(scenario.getChecks().every((c) => c.status === 'FAILURE')).toBe( + true + ); + } finally { + await scenario.stop(); + } + await scenario.start(); + try { + expect(scenario.getChecks().every((c) => c.status === 'FAILURE')).toBe( + true + ); + } finally { + await scenario.stop(); + } + }); + it('is opt-in and outside all core protocol selections', () => { + expect(listExtensionScenarios()).toContain('legacy-extensions'); + expect(listCoreScenarios()).not.toContain('legacy-extensions'); + for (const version of [ + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION + ] as const) { + expect(listScenariosForSpec(version)).not.toContain('legacy-extensions'); + } + }); +}); diff --git a/src/scenarios/client/legacy-extensions.ts b/src/scenarios/client/legacy-extensions.ts new file mode 100644 index 00000000..0ce1db42 --- /dev/null +++ b/src/scenarios/client/legacy-extensions.ts @@ -0,0 +1,154 @@ +import { createServer, type Server } from 'node:http'; +import type { Scenario, ScenarioUrls } from '../../types'; +import { + CLIENT_EXTENSIONS, + SERVER_EXTENSIONS, + EXTENSIONS_ECHO_TOOL, + LEGACY_EXTENSION_VERSION, + EXTENSION_REFERENCES, + extensionChecks +} from '../legacy-extensions'; +import { untestableCheck } from '../untestable'; +import { validateWireMessage } from '../../validation/wire-schema'; + +export class LegacyExtensionsScenario implements Scenario { + name = 'legacy-extensions'; + readonly source = { extensionId: 'io.modelcontextprotocol/ui' } as const; + description = `Optional legacy capability round-trip (fixed protocol 2025-11-25). +Configure the client with CLIENT_EXTENSIONS documented in SDK_INTEGRATION.md. +After initialize, call ${EXTENSIONS_ECHO_TOOL} with { extensions: }. Do not echo a hardcoded fixture +or parse the raw initialize response outside the SDK. This tests capability +preservation, not full Apps support, and is not required for core conformance.`; + private server?: Server; + private advertised: unknown; + private observed: unknown; + private initialized = false; + private reported = false; + private version: unknown; + + async start(): Promise { + this.advertised = this.observed = this.version = undefined; + this.initialized = this.reported = false; + this.server = createServer(async (req, res) => { + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + try { + let text = ''; + for await (const chunk of req) text += chunk; + const message = JSON.parse(text); + validateWireMessage(LEGACY_EXTENSION_VERSION, message, { + origin: 'implementation', + context: 'legacy extension client request' + }); + const send = (result: object) => { + const response = { jsonrpc: '2.0', id: message.id, result }; + validateWireMessage(LEGACY_EXTENSION_VERSION, response, { + origin: 'harness', + context: 'legacy extension response', + requestMethod: message.method + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + }; + if (message.method === 'initialize') { + this.advertised = message.params?.capabilities?.extensions; + this.version = message.params?.protocolVersion; + send({ + protocolVersion: LEGACY_EXTENSION_VERSION, + capabilities: { tools: {}, extensions: SERVER_EXTENSIONS }, + serverInfo: { name: 'legacy-extension-fixture', version: '1.0.0' } + }); + } else if (message.method === 'notifications/initialized') { + this.initialized = true; + res.writeHead(202).end(); + } else if (message.method === 'tools/list') { + send({ + tools: [ + { + name: EXTENSIONS_ECHO_TOOL, + inputSchema: { + type: 'object', + properties: { extensions: { type: 'object' } }, + required: ['extensions'] + } + } + ] + }); + } else if ( + message.method === 'tools/call' && + message.params?.name === EXTENSIONS_ECHO_TOOL + ) { + this.reported = true; + this.observed = message.params.arguments?.extensions; + send({ content: [{ type: 'text', text: 'recorded' }] }); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: 'Method not found' } + }) + ); + } + } catch { + res.writeHead(400).end(); + } + }); + await new Promise((resolve, reject) => { + this.server!.once('error', reject); + this.server!.listen(0, '127.0.0.1', resolve); + }); + const address = this.server.address(); + if (!address || typeof address === 'string') + throw new Error('No listening port'); + return { serverUrl: `http://127.0.0.1:${address.port}/mcp` }; + } + + async stop(): Promise { + if (!this.server) return; + this.server.closeAllConnections(); + await new Promise((resolve, reject) => + this.server!.close((e) => (e ? reject(e) : resolve())) + ); + this.server = undefined; + } + + getChecks() { + return [ + { + id: 'legacy-extensions-client-handshake', + name: 'LegacyExtensionHandshake', + description: 'Client completes a 2025-11-25 initialize handshake', + status: + this.initialized && this.version === LEGACY_EXTENSION_VERSION + ? ('SUCCESS' as const) + : ('FAILURE' as const), + timestamp: new Date().toISOString(), + specReferences: EXTENSION_REFERENCES, + errorMessage: + this.initialized && this.version === LEGACY_EXTENSION_VERSION + ? undefined + : 'Expected 2025-11-25 initialization and notifications/initialized' + }, + ...extensionChecks( + 'client-advertisement', + this.advertised, + CLIENT_EXTENSIONS + ), + ...(this.reported + ? extensionChecks('client-reception', this.observed, SERVER_EXTENSIONS) + : [ + untestableCheck( + 'legacy-extensions-client-report', + 'LegacyExtensionReport', + 'Client reports SDK-visible server extensions', + `Client did not call ${EXTENSIONS_ECHO_TOOL}`, + EXTENSION_REFERENCES + ) + ]) + ]; + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 375d7010..964ddb10 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -9,6 +9,8 @@ import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION } from '../types'; +import { LegacyExtensionsScenario } from './client/legacy-extensions'; +import { ServerLegacyExtensionsScenario } from './server/legacy-extensions'; import { InitializeScenario } from './client/initialize'; import { SkillsNoPrefetchScenario } from './client/skills/no-prefetch'; import { SkillsVerificationScenario } from './client/skills/verification'; @@ -113,7 +115,7 @@ import { authScenariosList, backcompatScenariosList, draftScenariosList, - extensionScenariosList + extensionScenariosList as authExtensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; @@ -127,6 +129,19 @@ import { import { JsonSchemaRefDerefScenario } from './client/json-schema-ref-deref'; import { JsonSchema2020_12PreservationScenario } from './client/json-schema-2020-12-preservation'; +const extensionScenariosList: Scenario[] = [ + ...authExtensionScenariosList, + new LegacyExtensionsScenario() +]; + +// Optional server extension fixtures are excluded from the default core suite. +const extensionClientScenariosList: ClientScenario[] = [ + new ServerLegacyExtensionsScenario() +]; +export function listExtensionClientScenarios(): string[] { + return extensionClientScenariosList.map((s) => s.name); +} + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // JSON Schema 2020-12 (SEP-1613) @@ -170,6 +185,7 @@ const pendingClientScenariosList: ClientScenario[] = [ // All client scenarios const allClientScenariosList: ClientScenario[] = [ + ...extensionClientScenariosList, // Lifecycle scenarios new ServerInitializeScenario(), new SessionLifecycleScenario(), @@ -283,6 +299,9 @@ const draftClientScenariosList: ClientScenario[] = const activeClientScenariosList: ClientScenario[] = allClientScenariosList.filter( (scenario) => + !extensionClientScenariosList.some( + (extension) => extension.name === scenario.name + ) && !pendingClientScenariosList.some( (pending) => pending.name === scenario.name ) && diff --git a/src/scenarios/legacy-extensions.ts b/src/scenarios/legacy-extensions.ts new file mode 100644 index 00000000..50d76da3 --- /dev/null +++ b/src/scenarios/legacy-extensions.ts @@ -0,0 +1,59 @@ +import { isDeepStrictEqual } from 'node:util'; +import type { ConformanceCheck } from '../types'; + +// Diagnostic fixtures, not a claim of full Apps conformance. The example.com +// extension exercises arbitrary settings that an SDK cannot know in advance. +export const LEGACY_EXTENSION_VERSION = '2025-11-25'; +export const CLIENT_EXTENSIONS = { + 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }, + 'com.example/conformance': { + nested: { enabled: false, limit: 0 }, + values: ['a', 2, null] + }, + 'com.example/empty': {} +}; +export const SERVER_EXTENSIONS = { + 'io.modelcontextprotocol/ui': {}, + 'com.example/conformance': { + nested: { enabled: true, limit: 3 }, + values: [null, 'b', 4] + }, + 'com.example/empty': {} +}; +export const EXTENSIONS_ECHO_TOOL = 'test_legacy_extension_capabilities'; +export const EXTENSION_REFERENCES = [ + { + id: 'SEP-2133-Negotiation', + url: 'https://modelcontextprotocol.io/seps/2133-extensions#negotiation' + }, + { + id: 'Legacy-Extension-Negotiation', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3364' + } +]; + +export function extensionChecks( + direction: string, + actual: unknown, + expected: Record +): ConformanceCheck[] { + return Object.entries(expected).map(([key, value]) => { + const received = + actual !== null && typeof actual === 'object' + ? (actual as Record)[key] + : undefined; + const preserved = isDeepStrictEqual(received, value); + return { + id: `legacy-extensions-${direction}-${key.split('/')[1]}`, + name: 'LegacyExtensionPreservation', + description: `${direction}: preserve ${key} and its settings`, + status: preserved ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: EXTENSION_REFERENCES, + errorMessage: preserved + ? undefined + : `Missing or altered extension ${key}`, + details: { expected: value, actual: received } + }; + }); +} diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index bce7845f..37c99d21 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -4,6 +4,7 @@ import { createServer } from 'net'; import { getClientScenario, listActiveClientScenarios, + listExtensionClientScenarios, listDraftClientScenarios, listPendingClientScenarios } from '../index'; @@ -133,6 +134,7 @@ describe('Server Scenarios', () => { const pendingScenarios = new Set(listPendingClientScenarios()); const scenarios = [ ...listActiveClientScenarios(), + ...listExtensionClientScenarios(), ...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name)) ]; diff --git a/src/scenarios/server/legacy-extensions.test.ts b/src/scenarios/server/legacy-extensions.test.ts new file mode 100644 index 00000000..17e13a9e --- /dev/null +++ b/src/scenarios/server/legacy-extensions.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { ServerLegacyExtensionsScenario } from './legacy-extensions'; +import { startBrokenLegacyServer } from '../../../examples/servers/typescript/legacy-extensions-broken'; +import { testContext } from '../../connection/testing'; +import { + listActiveClientScenarios, + listExtensionClientScenarios, + listClientScenariosForSpec +} from '../index'; +import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION } from '../../types'; + +describe('legacy server extensions', () => { + it.each([ + 'advertisement', + 'reception', + 'settings', + 'missing-report' + ] as const)('detects %s loss', async (mode) => { + const server = await startBrokenLegacyServer(mode); + try { + const checks = await new ServerLegacyExtensionsScenario().run( + testContext(server.url) + ); + const id = + mode === 'missing-report' + ? 'legacy-extensions-server-report' + : `legacy-extensions-server-${mode === 'advertisement' ? 'advertisement' : 'reception'}-conformance`; + expect(checks.find((c) => c.id === id)?.status).toBe('FAILURE'); + if (mode === 'advertisement') { + // The Python-style response serialization regression must fail even + // though the server still exposes received client capabilities correctly. + expect( + checks + .filter((c) => c.id.includes('server-reception')) + .every((c) => c.status === 'SUCCESS') + ).toBe(true); + } + if (mode === 'missing-report') + expect(checks.find((c) => c.id === id)?.details?.untestable).toBe(true); + } finally { + await server.close(); + } + }); + it('fails rather than silently testing another negotiated version', async () => { + const server = await startBrokenLegacyServer('wrong-version'); + try { + const checks = await new ServerLegacyExtensionsScenario().run( + testContext(server.url) + ); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'legacy-extensions-server-handshake', + status: 'FAILURE' + }); + } finally { + await server.close(); + } + }); + it('is opt-in and outside all core protocol selections', () => { + expect(listExtensionClientScenarios()).toContain( + 'server-legacy-extensions' + ); + expect(listActiveClientScenarios()).not.toContain( + 'server-legacy-extensions' + ); + for (const version of [ + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION + ] as const) { + expect(listClientScenariosForSpec(version)).not.toContain( + 'server-legacy-extensions' + ); + } + }); +}); diff --git a/src/scenarios/server/legacy-extensions.ts b/src/scenarios/server/legacy-extensions.ts new file mode 100644 index 00000000..57d1a39b --- /dev/null +++ b/src/scenarios/server/legacy-extensions.ts @@ -0,0 +1,161 @@ +import type { ClientScenario, ConformanceCheck } from '../../types'; +import type { RunContext } from '../../connection'; +import { readSseJsonRpcResponse } from '../../connection'; +import { terminateSessionRaw } from '../../connection/sdk-client'; +import { validateWireMessage } from '../../validation/wire-schema'; +import { + CLIENT_EXTENSIONS, + SERVER_EXTENSIONS, + EXTENSIONS_ECHO_TOOL, + LEGACY_EXTENSION_VERSION, + EXTENSION_REFERENCES, + extensionChecks +} from '../legacy-extensions'; +import { untestableCheck } from '../untestable'; + +export class ServerLegacyExtensionsScenario implements ClientScenario { + name = 'server-legacy-extensions'; + readonly source = { extensionId: 'io.modelcontextprotocol/ui' } as const; + description = `Optional legacy capability round-trip, pinned to 2025-11-25. +Configure SERVER_EXTENSIONS from SDK_INTEGRATION.md in the SDK server's +capabilities. Implement ${EXTENSIONS_ECHO_TOOL} to return a text JSON object +{ extensions: }. +Do not hardcode the reported capabilities or read them from raw HTTP input. +Selecting this scenario opts into the fixture contract; absent advertisements +or diagnostic tools fail. It does not test full Apps support or affect core +conformance. The per-request capability lifecycle is not exercised.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + let sessionId: string | null = null; + let id = 0; + // Raw requests pin the actual handshake version and inspect serialization + // before a harness SDK parser could discard unknown capability fields. + const request = async ( + method: string, + params?: Record, + notification = false + ) => { + const message = { + jsonrpc: '2.0', + ...(notification ? {} : { id: ++id }), + method, + ...(params ? { params } : {}) + }; + validateWireMessage(LEGACY_EXTENSION_VERSION, message, { + origin: 'harness', + context: 'legacy extension probe' + }); + const response = await fetch(ctx.serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': LEGACY_EXTENSION_VERSION, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + }, + body: JSON.stringify(message), + signal: AbortSignal.timeout(5000) + }); + if (method === 'initialize') + sessionId = response.headers.get('mcp-session-id'); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`${method}: HTTP ${response.status}`); + } + if (notification) { + await response.body?.cancel(); + return {}; + } + const body = response.headers + .get('content-type') + ?.includes('text/event-stream') + ? (await readSseJsonRpcResponse(response, id)).body + : await response.json(); + validateWireMessage(LEGACY_EXTENSION_VERSION, body, { + origin: 'implementation', + context: 'legacy extension response', + requestMethod: method + }); + if (!body || body.id !== id || body.error || !body.result) + throw new Error(`${method}: missing or invalid result`); + return body.result; + }; + try { + const result = await request('initialize', { + protocolVersion: LEGACY_EXTENSION_VERSION, + capabilities: { extensions: CLIENT_EXTENSIONS }, + clientInfo: { name: 'legacy-extension-conformance', version: '1.0.0' } + }); + const correctVersion = + result.protocolVersion === LEGACY_EXTENSION_VERSION; + if (correctVersion) + await request('notifications/initialized', undefined, true); + checks.push({ + id: 'legacy-extensions-server-handshake', + name: 'LegacyExtensionHandshake', + description: + 'Server negotiates 2025-11-25 for the legacy extension fixture', + status: correctVersion ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: EXTENSION_REFERENCES, + errorMessage: correctVersion + ? undefined + : `Fixture requires 2025-11-25; received ${result.protocolVersion}` + }); + if (!correctVersion) return checks; + checks.push( + ...extensionChecks( + 'server-advertisement', + result.capabilities?.extensions, + SERVER_EXTENSIONS + ) + ); + try { + const result = await request('tools/call', { + name: EXTENSIONS_ECHO_TOOL, + arguments: {} + }); + const content = result.content as { type: string; text?: string }[]; + const text = content?.find((c) => c.type === 'text')?.text; + if (result.isError || !text) + throw new Error('Diagnostic tool returned an error or no text'); + checks.push( + ...extensionChecks( + 'server-reception', + JSON.parse(text).extensions, + CLIENT_EXTENSIONS + ) + ); + } catch (error) { + checks.push( + untestableCheck( + 'legacy-extensions-server-report', + 'LegacyExtensionReport', + 'Server reports SDK-visible client extensions', + `${EXTENSIONS_ECHO_TOOL} must return JSON text: ${String(error)}`, + EXTENSION_REFERENCES + ) + ); + } + } catch (error) { + checks.push( + untestableCheck( + 'legacy-extensions-server-handshake', + 'LegacyExtensionHandshake', + 'Server completes the legacy extension handshake', + String(error), + EXTENSION_REFERENCES + ) + ); + } finally { + if (sessionId) + await terminateSessionRaw( + ctx.serverUrl, + sessionId, + LEGACY_EXTENSION_VERSION + ); + } + return checks; + } +} diff --git a/src/types.ts b/src/types.ts index ebe75a27..e6e5e75a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,6 +98,7 @@ export type ScenarioSpecTag = SpecVersion | 'extension'; * `capabilities.extensions`). */ export const EXTENSION_IDS = [ + 'io.modelcontextprotocol/ui', 'io.modelcontextprotocol/oauth-client-credentials', 'io.modelcontextprotocol/enterprise-managed-authorization', 'io.modelcontextprotocol/auth/dpop',