Skip to content

Commit 7f1b871

Browse files
authored
feat: add conformance Tests for SEP-2549: TTL for List Results (#275)
1 parent 583d42d commit 7f1b871

6 files changed

Lines changed: 649 additions & 0 deletions

File tree

examples/servers/typescript/everything-server.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js
2222
import {
2323
ElicitResultSchema,
2424
ListToolsRequestSchema,
25+
ListPromptsRequestSchema,
26+
ListResourcesRequestSchema,
27+
ListResourceTemplatesRequestSchema,
2528
type ListToolsResult,
2629
type Tool
2730
} from '@modelcontextprotocol/sdk/types.js';
@@ -128,6 +131,46 @@ function createMcpServer() {
128131
}
129132
);
130133

134+
// SEP-2549: Wrap setRequestHandler so the SDK's own list handlers
135+
// automatically get caching hints appended to their responses.
136+
const originalSetRequestHandler = mcpServer.server.setRequestHandler.bind(
137+
mcpServer.server
138+
);
139+
const listSchemasForCaching = new Set([
140+
ListToolsRequestSchema,
141+
ListPromptsRequestSchema,
142+
ListResourcesRequestSchema,
143+
ListResourceTemplatesRequestSchema
144+
]);
145+
mcpServer.server.setRequestHandler = ((schema: any, handler: any) => {
146+
if (listSchemasForCaching.has(schema)) {
147+
return originalSetRequestHandler(schema, async (...args: any[]) => {
148+
const result = await handler(...args);
149+
return { ...result, ttlMs: 300000, cacheScope: 'public' as const };
150+
});
151+
}
152+
return originalSetRequestHandler(schema, handler);
153+
}) as typeof mcpServer.server.setRequestHandler;
154+
155+
const registerResourceWithCacheHints =
156+
mcpServer.registerResource.bind(mcpServer);
157+
mcpServer.registerResource = ((
158+
name: string,
159+
uriOrTemplate: string | ResourceTemplate,
160+
config: any,
161+
readCallback: any
162+
) =>
163+
registerResourceWithCacheHints(
164+
name,
165+
uriOrTemplate as any,
166+
config,
167+
async (...args: any[]) => ({
168+
...(await readCallback(...args)),
169+
ttlMs: 300000,
170+
cacheScope: 'private' as const
171+
})
172+
)) as typeof mcpServer.registerResource;
173+
131174
// Helper to send log messages using the underlying server
132175
function sendLog(
133176
level:
@@ -1024,6 +1067,8 @@ function createMcpServer() {
10241067
_meta: tool._meta
10251068
};
10261069
})
1070+
// Note: SEP-2549 caching hints are added automatically by the
1071+
// setRequestHandler wrapper above
10271072
};
10281073
}
10291074
);
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* SEP-2549 Negative Test Server
5+
*
6+
* Returns list and read results WITHOUT ttlMs and cacheScope fields,
7+
* violating the SEP-2549 MUST. The caching scenario should emit FAILURE
8+
* for presence checks against this server.
9+
*/
10+
11+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
13+
import {
14+
ListToolsRequestSchema,
15+
ListPromptsRequestSchema,
16+
ListResourcesRequestSchema,
17+
ListResourceTemplatesRequestSchema,
18+
ReadResourceRequestSchema
19+
} from '@modelcontextprotocol/sdk/types.js';
20+
import express from 'express';
21+
import { randomUUID } from 'crypto';
22+
23+
const transports: Record<string, StreamableHTTPServerTransport> = {};
24+
25+
function isInitializeRequest(body: any): boolean {
26+
return body?.method === 'initialize';
27+
}
28+
29+
function createServer() {
30+
const server = new Server(
31+
{ name: 'sep-2549-no-caching-hints', version: '1.0.0' },
32+
{
33+
capabilities: {
34+
tools: {},
35+
resources: {},
36+
prompts: {}
37+
}
38+
}
39+
);
40+
41+
// Deliberately omit ttlMs and cacheScope from all responses
42+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
43+
tools: [
44+
{
45+
name: 'test_tool',
46+
description: 'A test tool',
47+
inputSchema: { type: 'object' as const }
48+
}
49+
]
50+
}));
51+
52+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
53+
prompts: [
54+
{
55+
name: 'test_prompt',
56+
description: 'A test prompt'
57+
}
58+
]
59+
}));
60+
61+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
62+
resources: [
63+
{
64+
uri: 'test://static-text',
65+
name: 'Static Text',
66+
description: 'A static text resource'
67+
}
68+
]
69+
}));
70+
71+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
72+
resourceTemplates: []
73+
}));
74+
75+
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
76+
contents: [
77+
{
78+
uri: 'test://static-text',
79+
mimeType: 'text/plain',
80+
text: 'Static text content.'
81+
}
82+
]
83+
}));
84+
85+
return server;
86+
}
87+
88+
const app = express();
89+
app.use(express.json());
90+
91+
app.post('/mcp', async (req, res) => {
92+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
93+
94+
try {
95+
if (sessionId && transports[sessionId]) {
96+
await transports[sessionId].handleRequest(req, res, req.body);
97+
return;
98+
}
99+
100+
if (!sessionId && isInitializeRequest(req.body)) {
101+
const server = createServer();
102+
const transport = new StreamableHTTPServerTransport({
103+
sessionIdGenerator: () => randomUUID(),
104+
onsessioninitialized: (newSessionId) => {
105+
transports[newSessionId] = transport;
106+
}
107+
});
108+
transport.onclose = () => {
109+
const sid = transport.sessionId;
110+
if (sid) delete transports[sid];
111+
};
112+
await server.connect(transport);
113+
await transport.handleRequest(req, res, req.body);
114+
return;
115+
}
116+
117+
res.status(400).json({
118+
jsonrpc: '2.0',
119+
error: { code: -32000, message: 'Invalid or missing session ID' },
120+
id: null
121+
});
122+
} catch (error) {
123+
if (!res.headersSent) {
124+
res.status(500).json({
125+
jsonrpc: '2.0',
126+
error: {
127+
code: -32603,
128+
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
129+
},
130+
id: null
131+
});
132+
}
133+
}
134+
});
135+
136+
app.get('/mcp', async (req, res) => {
137+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
138+
if (sessionId && transports[sessionId]) {
139+
await transports[sessionId].handleRequest(req, res);
140+
} else {
141+
res.status(400).json({ error: 'Invalid or missing session ID' });
142+
}
143+
});
144+
145+
app.delete('/mcp', async (req, res) => {
146+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
147+
if (sessionId && transports[sessionId]) {
148+
await transports[sessionId].handleRequest(req, res);
149+
} else {
150+
res.status(400).json({ error: 'Invalid or missing session ID' });
151+
}
152+
});
153+
154+
const PORT = parseInt(process.env.PORT || '3006', 10);
155+
app.listen(PORT, () => {
156+
console.log(
157+
`SEP-2549 negative test server running on http://localhost:${PORT}/mcp`
158+
);
159+
});

src/scenarios/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from './server/prompts';
6666

6767
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
68+
import { CachingScenario } from './server/caching';
6869

6970
import {
7071
HttpHeaderValidationScenario,
@@ -162,6 +163,8 @@ const allClientScenariosList: ClientScenario[] = [
162163
// Security scenarios
163164
new DNSRebindingProtectionScenario(),
164165

166+
// Caching scenarios (SEP-2549)
167+
new CachingScenario(),
165168
// HTTP Standardization scenarios (SEP-2243)
166169
new HttpHeaderValidationScenario(),
167170
new HttpCustomHeaderServerValidationScenario()

0 commit comments

Comments
 (0)