Skip to content

Commit fb5c48a

Browse files
committed
feat: add server conformance tests for SEP-2575
1 parent 17f1f93 commit fb5c48a

4 files changed

Lines changed: 1027 additions & 1 deletion

File tree

examples/servers/typescript/everything-server.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,149 @@ app.use(
10531053
// Handle POST requests - stateful mode
10541054
app.post('/mcp', async (req, res) => {
10551055
const sessionId = req.headers['mcp-session-id'] as string | undefined;
1056+
const reqVersion = req.headers['mcp-protocol-version'] as string | undefined;
1057+
const body = req.body || {};
1058+
const method = body.method;
1059+
const id = body.id ?? null;
1060+
const params = body.params || {};
1061+
const meta = params._meta;
1062+
const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion'];
1063+
1064+
// If it's a stateless request (no session ID, and has either _meta or MCP-Protocol-Version header indicating stateless mode)
1065+
if (!sessionId && (reqVersion || meta)) {
1066+
if (process.env.STATELESS_NEGATIVE === 'true') {
1067+
return res.json({
1068+
jsonrpc: '2.0',
1069+
id,
1070+
result: {}
1071+
});
1072+
}
1073+
1074+
if (!reqVersion) {
1075+
return res.status(400).json({
1076+
jsonrpc: '2.0',
1077+
id,
1078+
error: { code: -32600, message: 'Missing MCP-Protocol-Version header' }
1079+
});
1080+
}
1081+
1082+
if (
1083+
!meta ||
1084+
!meta['io.modelcontextprotocol/protocolVersion'] ||
1085+
!meta['io.modelcontextprotocol/clientInfo'] ||
1086+
!meta['io.modelcontextprotocol/clientCapabilities']
1087+
) {
1088+
return res.status(200).json({
1089+
jsonrpc: '2.0',
1090+
id,
1091+
error: {
1092+
code: -32602,
1093+
message: 'Invalid params: missing _meta or required fields'
1094+
}
1095+
});
1096+
}
1097+
1098+
if (reqVersion !== metaVersion) {
1099+
return res.status(400).json({
1100+
jsonrpc: '2.0',
1101+
id,
1102+
error: {
1103+
code: -32600,
1104+
message: 'Mismatched MCP-Protocol-Version header'
1105+
}
1106+
});
1107+
}
1108+
1109+
if (metaVersion !== 'DRAFT-2026-v1') {
1110+
return res.status(400).json({
1111+
jsonrpc: '2.0',
1112+
id,
1113+
error: {
1114+
code: -32001,
1115+
message: 'UnsupportedProtocolVersionError',
1116+
data: { supportedVersions: ['DRAFT-2026-v1'] }
1117+
}
1118+
});
1119+
}
1120+
1121+
res.setHeader('mcp-protocol-version', 'DRAFT-2026-v1');
1122+
1123+
if (method === 'server/discover') {
1124+
return res.json({
1125+
jsonrpc: '2.0',
1126+
id,
1127+
result: {
1128+
supportedVersions: ['DRAFT-2026-v1'],
1129+
capabilities: { tools: {} },
1130+
serverInfo: { name: 'everything-stateless-server', version: '1.0.0' }
1131+
}
1132+
});
1133+
}
1134+
1135+
if (method === 'tools/list') {
1136+
return res.json({
1137+
jsonrpc: '2.0',
1138+
id,
1139+
result: {
1140+
tools: [
1141+
{
1142+
name: 'test_missing_capability',
1143+
description: 'Test tool requiring sampling',
1144+
inputSchema: { type: 'object', properties: {} }
1145+
}
1146+
]
1147+
}
1148+
});
1149+
}
1150+
1151+
if (method === 'tools/call') {
1152+
const name = params.name;
1153+
if (name === 'test_missing_capability') {
1154+
const clientCaps = meta['io.modelcontextprotocol/clientCapabilities'];
1155+
if (!clientCaps?.sampling) {
1156+
return res.status(400).json({
1157+
jsonrpc: '2.0',
1158+
id,
1159+
error: {
1160+
code: -32003,
1161+
message: 'MissingRequiredClientCapabilityError',
1162+
data: { requiredCapabilities: ['sampling'] }
1163+
}
1164+
});
1165+
}
1166+
return res.json({
1167+
jsonrpc: '2.0',
1168+
id,
1169+
result: { content: [{ type: 'text', text: 'Success' }] }
1170+
});
1171+
}
1172+
}
1173+
1174+
if (
1175+
[
1176+
'initialize',
1177+
'ping',
1178+
'logging/setLevel',
1179+
'resources/subscribe',
1180+
'resources/unsubscribe'
1181+
].includes(method)
1182+
) {
1183+
return res.status(200).json({
1184+
jsonrpc: '2.0',
1185+
id,
1186+
error: {
1187+
code: -32601,
1188+
message: 'Method not found: removed stateful RPC'
1189+
}
1190+
});
1191+
}
1192+
1193+
return res.status(404).json({
1194+
jsonrpc: '2.0',
1195+
id,
1196+
error: { code: -32601, message: 'Method not found' }
1197+
});
1198+
}
10561199

10571200
try {
10581201
let transport: StreamableHTTPServerTransport;

src/scenarios/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { SSERetryScenario } from './client/sse-retry';
1515

1616
// Import all new server test scenarios
1717
import { ServerInitializeScenario } from './server/lifecycle';
18+
import { ServerStatelessScenario } from './server/stateless';
1819

1920
import {
2021
PingScenario,
@@ -81,13 +82,17 @@ const pendingClientScenariosList: ClientScenario[] = [
8182

8283
// On hold until server-side SSE improvements are made
8384
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1129
84-
new ServerSSEPollingScenario()
85+
new ServerSSEPollingScenario(),
86+
87+
// Stateless MCP architecture (SEP-2575)
88+
new ServerStatelessScenario()
8589
];
8690

8791
// All client scenarios
8892
const allClientScenariosList: ClientScenario[] = [
8993
// Lifecycle scenarios
9094
new ServerInitializeScenario(),
95+
new ServerStatelessScenario(),
9196

9297
// Utilities scenarios
9398
new LoggingSetLevelScenario(),
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { spawn, ChildProcess } from 'child_process';
2+
import path from 'path';
3+
import { ServerStatelessScenario } from './stateless';
4+
5+
function startServer(
6+
scriptPath: string,
7+
port: number,
8+
envOverrides?: Record<string, string>
9+
): Promise<ChildProcess> {
10+
return new Promise((resolve, reject) => {
11+
const isWindows = process.platform === 'win32';
12+
const proc = spawn('npx', ['tsx', scriptPath], {
13+
env: { ...process.env, PORT: port.toString(), ...envOverrides },
14+
stdio: ['ignore', 'pipe', 'pipe'],
15+
shell: isWindows
16+
});
17+
let stderr = '';
18+
proc.stderr?.on('data', (d) => (stderr += d.toString()));
19+
const timeout = setTimeout(() => {
20+
proc.kill('SIGKILL');
21+
reject(
22+
new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`)
23+
);
24+
}, 30000);
25+
proc.stdout?.on('data', (data) => {
26+
if (data.toString().includes('running on')) {
27+
clearTimeout(timeout);
28+
resolve(proc);
29+
}
30+
});
31+
proc.on('error', (err) => {
32+
clearTimeout(timeout);
33+
reject(err);
34+
});
35+
});
36+
}
37+
38+
function stopServer(proc: ChildProcess | null): Promise<void> {
39+
return new Promise((resolve) => {
40+
if (!proc || proc.killed) return resolve();
41+
const t = setTimeout(() => {
42+
proc.kill('SIGKILL');
43+
resolve();
44+
}, 5000);
45+
proc.once('exit', () => {
46+
clearTimeout(t);
47+
resolve();
48+
});
49+
proc.kill('SIGTERM');
50+
});
51+
}
52+
53+
describe('ServerStatelessScenario tests', () => {
54+
describe('passing server', () => {
55+
let serverProcess: ChildProcess | null = null;
56+
const PORT = 3010;
57+
58+
beforeAll(async () => {
59+
serverProcess = await startServer(
60+
path.join(
61+
process.cwd(),
62+
'examples/servers/typescript/everything-server.ts'
63+
),
64+
PORT
65+
);
66+
}, 35000);
67+
68+
afterAll(async () => {
69+
await stopServer(serverProcess);
70+
});
71+
72+
it('emits SUCCESS for all checks against a compliant stateless server', async () => {
73+
const scenario = new ServerStatelessScenario();
74+
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
75+
76+
for (const check of checks) {
77+
if (check.status !== 'SUCCESS') {
78+
console.error('FAILED CHECK:', JSON.stringify(check, null, 2));
79+
}
80+
expect(check.status).toBe('SUCCESS');
81+
}
82+
}, 15000);
83+
});
84+
85+
describe('negative server', () => {
86+
let serverProcess: ChildProcess | null = null;
87+
const PORT = 3012;
88+
89+
beforeAll(async () => {
90+
serverProcess = await startServer(
91+
path.join(
92+
process.cwd(),
93+
'examples/servers/typescript/everything-server.ts'
94+
),
95+
PORT,
96+
{ STATELESS_NEGATIVE: 'true' }
97+
);
98+
}, 35000);
99+
100+
afterAll(async () => {
101+
await stopServer(serverProcess);
102+
});
103+
104+
it('emits FAILURE for checks against a broken stateless server', async () => {
105+
const scenario = new ServerStatelessScenario();
106+
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
107+
108+
const failures = checks.filter((c) => c.status === 'FAILURE');
109+
expect(failures.length).toBeGreaterThan(0);
110+
}, 15000);
111+
});
112+
});

0 commit comments

Comments
 (0)