Skip to content

Commit 7c6377d

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 14905af commit 7c6377d

3 files changed

Lines changed: 595 additions & 1 deletion

File tree

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

src/scenarios/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ import {
4747
PromptsGetWithImageScenario
4848
} from './server/prompts';
4949

50+
import { StatelessServerScenario } from './client/stateless_server';
51+
52+
import { StatelessServerCheckScenario } from './server/stateless';
53+
5054
import { authScenariosList } from './client/auth/index';
5155
import { listMetadataScenarios } from './client/auth/discovery-metadata';
5256

@@ -63,7 +67,10 @@ const pendingClientScenariosList: ClientScenario[] = [
6367
// On hold until elicitation schema types are fixed
6468
// https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863
6569
new ToolsCallElicitationScenario(),
66-
new ElicitationDefaultsScenario()
70+
new ElicitationDefaultsScenario(),
71+
72+
// Only for stateless servers - not testable against everything-server
73+
new StatelessServerCheckScenario()
6774
];
6875

6976
// All client scenarios
@@ -97,6 +104,8 @@ const allClientScenariosList: ClientScenario[] = [
97104
// Elicitation scenarios (SEP-1330) - pending
98105
new ElicitationEnumsScenario(),
99106

107+
new StatelessServerCheckScenario(),
108+
100109
// Resources scenarios
101110
new ResourcesListScenario(),
102111
new ResourcesReadTextScenario(),
@@ -132,6 +141,7 @@ const scenariosList: Scenario[] = [
132141
new InitializeScenario(),
133142
new ToolsCallScenario(),
134143
new ElicitationClientDefaultsScenario(),
144+
new StatelessServerScenario(),
135145
...authScenariosList
136146
];
137147

0 commit comments

Comments
 (0)