Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions SDK_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 extensions obtained from the SDK accessor> }`.
- **Server fixture:** Implement that tool with no required arguments. Return one
text content block containing JSON
`{ "extensions": <client extensions obtained from the SDK accessor> }`.

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.
19 changes: 19 additions & 0 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -195,6 +199,21 @@ async function runBasicClient(serverUrl: string): Promise<void> {
}

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
Expand Down
34 changes: 34 additions & 0 deletions examples/clients/typescript/legacy-extensions-broken.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
24 changes: 24 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -213,6 +217,7 @@ function createMcpServer() {
},
{
capabilities: {
extensions: SERVER_EXTENSIONS,
tools: {
listChanged: true
},
Expand All @@ -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(
Expand Down
92 changes: 92 additions & 0 deletions examples/servers/typescript/legacy-extensions-broken.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<void>((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<void>((resolve) => http.close(() => resolve()));
}
};
}
9 changes: 7 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
listClientScenarios,
listActiveClientScenarios,
listPendingClientScenarios,
listExtensionClientScenarios,
listAuthScenarios,
listMetadataScenarios,
listCoreScenarios,
Expand Down Expand Up @@ -535,7 +536,7 @@ program
)
.option(
'--suite <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(
Expand Down Expand Up @@ -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') {
Expand All @@ -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);
}

Expand Down
Loading
Loading