Skip to content

Commit a58f460

Browse files
Add stateless server conformance test
Test both directions of the stateless (no session ID) transport path: - Client scenario (stateless_server): mock stateless server verifies clients handle missing Mcp-Session-Id correctly - Server scenario (stateless-server): test client verifies stateless servers omit session headers and return 405 for GET/DELETE Signed-off-by: Adrian Cole <adrian@tetrate.io>
1 parent 050d9cf commit a58f460

3 files changed

Lines changed: 609 additions & 1 deletion

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3+
import {
4+
CallToolRequestSchema,
5+
ListToolsRequestSchema
6+
} from '@modelcontextprotocol/sdk/types.js';
7+
import type { Scenario, ConformanceCheck, SpecVersion } from '../../types';
8+
import express, { Request, Response } from 'express';
9+
import { ScenarioUrls } from '../../types';
10+
import { createRequestLogger } from '../request-logger';
11+
12+
function createServer(checks: ConformanceCheck[]): express.Application {
13+
// Factory: new Server per request (stateless = no shared state)
14+
function getServer(): Server {
15+
const server = new Server(
16+
{
17+
name: 'stateless-server',
18+
version: '1.0.0'
19+
},
20+
{
21+
capabilities: {
22+
tools: {}
23+
}
24+
}
25+
);
26+
27+
server.setRequestHandler(ListToolsRequestSchema, async () => {
28+
return {
29+
tools: [
30+
{
31+
name: 'add_numbers',
32+
description: 'Add two numbers together',
33+
inputSchema: {
34+
type: 'object',
35+
properties: {
36+
a: {
37+
type: 'number',
38+
description: 'First number'
39+
},
40+
b: {
41+
type: 'number',
42+
description: 'Second number'
43+
}
44+
},
45+
required: ['a', 'b']
46+
}
47+
}
48+
]
49+
};
50+
});
51+
52+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
53+
if (request.params.name === 'add_numbers') {
54+
const { a, b } = request.params.arguments as {
55+
a: number;
56+
b: number;
57+
};
58+
const result = a + b;
59+
60+
checks.push({
61+
id: 'stateless-tools-call',
62+
name: 'StatelessToolsCall',
63+
description:
64+
'Validates that the client can call a tool on a stateless server',
65+
status: 'SUCCESS',
66+
timestamp: new Date().toISOString(),
67+
specReferences: [
68+
{
69+
id: 'MCP-Tools',
70+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
71+
}
72+
],
73+
details: {
74+
a,
75+
b,
76+
result
77+
}
78+
});
79+
80+
return {
81+
content: [
82+
{
83+
type: 'text',
84+
text: `The sum of ${a} and ${b} is ${result}`
85+
}
86+
]
87+
};
88+
}
89+
90+
throw new Error(`Unknown tool: ${request.params.name}`);
91+
});
92+
93+
return server;
94+
}
95+
96+
const app = express();
97+
app.use(express.json());
98+
99+
app.use(
100+
createRequestLogger(checks, {
101+
incomingId: 'incoming-request',
102+
outgoingId: 'outgoing-response',
103+
mcpRoute: '/mcp'
104+
})
105+
);
106+
107+
let isFirstPost = true;
108+
109+
app.post('/mcp', async (req: Request, res: Response) => {
110+
if (!isFirstPost) {
111+
const clientSessionHeader = req.headers['mcp-session-id'];
112+
if (clientSessionHeader) {
113+
checks.push({
114+
id: 'stateless-no-session-header-sent',
115+
name: 'StatelessNoSessionHeaderSent',
116+
description:
117+
'Client omits mcp-session-id when server did not provide one',
118+
status: 'FAILURE',
119+
timestamp: new Date().toISOString(),
120+
errorMessage: `Client sent mcp-session-id: ${clientSessionHeader}`,
121+
specReferences: [
122+
{
123+
id: 'MCP-Session',
124+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
125+
}
126+
]
127+
});
128+
} else if (
129+
!checks.find((c) => c.id === 'stateless-no-session-header-sent')
130+
) {
131+
checks.push({
132+
id: 'stateless-no-session-header-sent',
133+
name: 'StatelessNoSessionHeaderSent',
134+
description:
135+
'Client omits mcp-session-id when server did not provide one',
136+
status: 'SUCCESS',
137+
timestamp: new Date().toISOString(),
138+
specReferences: [
139+
{
140+
id: 'MCP-Session',
141+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
142+
}
143+
]
144+
});
145+
}
146+
}
147+
isFirstPost = false;
148+
149+
const server = getServer();
150+
const transport = new StreamableHTTPServerTransport({
151+
sessionIdGenerator: undefined
152+
});
153+
await server.connect(transport);
154+
await transport.handleRequest(req, res, req.body);
155+
res.on('close', () => {
156+
transport.close();
157+
server.close();
158+
});
159+
});
160+
161+
app.get('/mcp', async (_req: Request, res: Response) => {
162+
checks.push({
163+
id: 'stateless-get-405',
164+
name: 'StatelessGet405',
165+
description:
166+
'Stateless server returns 405 for GET (no SSE stream without sessions)',
167+
status: 'SUCCESS',
168+
timestamp: new Date().toISOString(),
169+
specReferences: [
170+
{
171+
id: 'MCP-Session',
172+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
173+
}
174+
]
175+
});
176+
177+
res.writeHead(405).end(
178+
JSON.stringify({
179+
jsonrpc: '2.0',
180+
error: {
181+
code: -32000,
182+
message: 'Method not allowed.'
183+
},
184+
id: null
185+
})
186+
);
187+
});
188+
189+
app.delete('/mcp', async (_req: Request, res: Response) => {
190+
checks.push({
191+
id: 'stateless-delete-405',
192+
name: 'StatelessDelete405',
193+
description:
194+
'Stateless server returns 405 for DELETE (no session to terminate)',
195+
status: 'SUCCESS',
196+
timestamp: new Date().toISOString(),
197+
specReferences: [
198+
{
199+
id: 'MCP-Session',
200+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
201+
}
202+
]
203+
});
204+
205+
res.writeHead(405).end(
206+
JSON.stringify({
207+
jsonrpc: '2.0',
208+
error: {
209+
code: -32000,
210+
message: 'Method not allowed.'
211+
},
212+
id: null
213+
})
214+
);
215+
});
216+
217+
return app;
218+
}
219+
220+
export class StatelessServerScenario implements Scenario {
221+
name = 'stateless_server';
222+
specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25'];
223+
description = 'Tests that clients handle a stateless server (no session ID)';
224+
private app: express.Application | null = null;
225+
private httpServer: any = null;
226+
private checks: ConformanceCheck[] = [];
227+
228+
async start(): Promise<ScenarioUrls> {
229+
this.checks = [];
230+
this.app = createServer(this.checks);
231+
this.httpServer = this.app.listen(0);
232+
const port = this.httpServer.address().port;
233+
return { serverUrl: `http://localhost:${port}/mcp` };
234+
}
235+
236+
async stop() {
237+
if (this.httpServer) {
238+
await new Promise((resolve) => this.httpServer.close(resolve));
239+
this.httpServer = null;
240+
}
241+
this.app = null;
242+
}
243+
244+
getChecks(): ConformanceCheck[] {
245+
// Server never sends mcp-session-id with sessionIdGenerator: undefined
246+
if (!this.checks.find((c) => c.id === 'stateless-init-no-session')) {
247+
this.checks.push({
248+
id: 'stateless-init-no-session',
249+
name: 'StatelessInitNoSession',
250+
description:
251+
'Server response contains no mcp-session-id header (stateless)',
252+
status: 'SUCCESS',
253+
timestamp: new Date().toISOString(),
254+
specReferences: [
255+
{
256+
id: 'MCP-Session',
257+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
258+
}
259+
]
260+
});
261+
}
262+
263+
if (!this.checks.find((c) => c.id === 'stateless-no-session-header-sent')) {
264+
this.checks.push({
265+
id: 'stateless-no-session-header-sent',
266+
name: 'StatelessNoSessionHeaderSent',
267+
description:
268+
'Client omits mcp-session-id when server did not provide one',
269+
status: 'SUCCESS',
270+
timestamp: new Date().toISOString(),
271+
specReferences: [
272+
{
273+
id: 'MCP-Session',
274+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
275+
}
276+
]
277+
});
278+
}
279+
280+
if (!this.checks.find((c) => c.id === 'stateless-get-405')) {
281+
this.checks.push({
282+
id: 'stateless-get-405',
283+
name: 'StatelessGet405',
284+
description:
285+
'Stateless server returns 405 for GET (client did not attempt GET)',
286+
status: 'SKIPPED',
287+
timestamp: new Date().toISOString(),
288+
specReferences: [
289+
{
290+
id: 'MCP-Session',
291+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
292+
}
293+
]
294+
});
295+
}
296+
297+
if (!this.checks.find((c) => c.id === 'stateless-delete-405')) {
298+
this.checks.push({
299+
id: 'stateless-delete-405',
300+
name: 'StatelessDelete405',
301+
description:
302+
'Stateless server returns 405 for DELETE (client did not attempt DELETE)',
303+
status: 'SKIPPED',
304+
timestamp: new Date().toISOString(),
305+
specReferences: [
306+
{
307+
id: 'MCP-Session',
308+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management'
309+
}
310+
]
311+
});
312+
}
313+
314+
if (!this.checks.find((c) => c.id === 'stateless-tools-call')) {
315+
this.checks.push({
316+
id: 'stateless-tools-call',
317+
name: 'StatelessToolsCall',
318+
description:
319+
'Validates that the client can call a tool on a stateless server',
320+
status: 'FAILURE',
321+
timestamp: new Date().toISOString(),
322+
details: { message: 'Tool was not called by client' },
323+
specReferences: [
324+
{
325+
id: 'MCP-Tools',
326+
url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools'
327+
}
328+
]
329+
});
330+
}
331+
332+
return this.checks;
333+
}
334+
}

src/scenarios/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ import {
5858

5959
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
6060

61+
import { StatelessServerScenario } from './client/stateless_server';
62+
63+
import { StatelessServerCheckScenario } from './server/stateless';
64+
6165
import {
6266
authScenariosList,
6367
backcompatScenariosList,
@@ -76,7 +80,10 @@ const pendingClientScenariosList: ClientScenario[] = [
7680

7781
// On hold until server-side SSE improvements are made
7882
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1129
79-
new ServerSSEPollingScenario()
83+
new ServerSSEPollingScenario(),
84+
85+
// Only for stateless servers - not testable against everything-server
86+
new StatelessServerCheckScenario()
8087
];
8188

8289
// All client scenarios
@@ -115,6 +122,8 @@ const allClientScenariosList: ClientScenario[] = [
115122
// Elicitation scenarios (SEP-1330) - pending
116123
new ElicitationEnumsScenario(),
117124

125+
new StatelessServerCheckScenario(),
126+
118127
// Resources scenarios
119128
new ResourcesListScenario(),
120129
new ResourcesReadTextScenario(),
@@ -171,6 +180,7 @@ const scenariosList: Scenario[] = [
171180
new ToolsCallScenario(),
172181
new ElicitationClientDefaultsScenario(),
173182
new SSERetryScenario(),
183+
new StatelessServerScenario(),
174184
...authScenariosList,
175185
...backcompatScenariosList,
176186
...draftScenariosList,

0 commit comments

Comments
 (0)