Skip to content

Commit def567b

Browse files
committed
initial conformance tests for SEP-2549
1 parent acc70de commit def567b

6 files changed

Lines changed: 718 additions & 1 deletion

File tree

examples/servers/typescript/everything-server.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js
2222
import {
2323
ElicitResultSchema,
2424
ListToolsRequestSchema,
25+
ListPromptsRequestSchema,
26+
ListResourcesRequestSchema,
27+
ListResourceTemplatesRequestSchema,
28+
ReadResourceRequestSchema,
2529
type ListToolsResult,
2630
type Tool
2731
} from '@modelcontextprotocol/sdk/types.js';
@@ -1023,11 +1027,135 @@ function createMcpServer() {
10231027
annotations: tool.annotations,
10241028
_meta: tool._meta
10251029
};
1030+
}),
1031+
// SEP-2549: Caching hints
1032+
ttlMs: 300000,
1033+
cacheScope: 'public' as const
1034+
};
1035+
}
1036+
);
1037+
1038+
// ===== SEP-2549: Override list/read handlers to include caching hints =====
1039+
1040+
mcpServer.server.setRequestHandler(
1041+
ListPromptsRequestSchema,
1042+
async () => {
1043+
const registeredPrompts = (mcpServer as any)._registeredPrompts as Record<
1044+
string,
1045+
{
1046+
enabled: boolean;
1047+
title?: string;
1048+
description?: string;
1049+
argsSchema?: any;
1050+
}
1051+
>;
1052+
1053+
return {
1054+
prompts: Object.entries(registeredPrompts)
1055+
.filter(([, prompt]) => prompt.enabled)
1056+
.map(([name, prompt]) => ({
1057+
name,
1058+
title: prompt.title,
1059+
description: prompt.description
1060+
})),
1061+
ttlMs: 300000,
1062+
cacheScope: 'public' as const
1063+
};
1064+
}
1065+
);
1066+
1067+
mcpServer.server.setRequestHandler(
1068+
ListResourcesRequestSchema,
1069+
async () => {
1070+
const registeredResources = (mcpServer as any)._registeredResources as Record<
1071+
string,
1072+
{ enabled: boolean; name: string; metadata?: any }
1073+
>;
1074+
1075+
return {
1076+
resources: Object.entries(registeredResources)
1077+
.filter(([, res]) => res.enabled)
1078+
.map(([uri, res]) => ({
1079+
uri,
1080+
name: res.name,
1081+
...res.metadata
1082+
})),
1083+
ttlMs: 300000,
1084+
cacheScope: 'public' as const
1085+
};
1086+
}
1087+
);
1088+
1089+
mcpServer.server.setRequestHandler(
1090+
ListResourceTemplatesRequestSchema,
1091+
async () => {
1092+
const registeredResourceTemplates = (mcpServer as any)
1093+
._registeredResourceTemplates as Record<
1094+
string,
1095+
{ resourceTemplate: any; metadata?: any }
1096+
>;
1097+
1098+
return {
1099+
resourceTemplates: Object.entries(registeredResourceTemplates).map(
1100+
([name, template]) => ({
1101+
name,
1102+
uriTemplate: template.resourceTemplate.uriTemplate.toString(),
1103+
...template.metadata
10261104
})
1105+
),
1106+
ttlMs: 300000,
1107+
cacheScope: 'public' as const
10271108
};
10281109
}
10291110
);
10301111

1112+
mcpServer.server.setRequestHandler(
1113+
ReadResourceRequestSchema,
1114+
async (request: any) => {
1115+
const uri = new URL(request.params.uri);
1116+
const registeredResources = (mcpServer as any)._registeredResources as Record<
1117+
string,
1118+
{ enabled: boolean; readCallback: (uri: URL, extra?: any) => Promise<any> }
1119+
>;
1120+
const registeredResourceTemplates = (mcpServer as any)
1121+
._registeredResourceTemplates as Record<
1122+
string,
1123+
{
1124+
resourceTemplate: any;
1125+
readCallback: (uri: URL, variables: Record<string, string>, extra?: any) => Promise<any>;
1126+
}
1127+
>;
1128+
1129+
// Exact resource match
1130+
const resource = registeredResources[uri.toString()];
1131+
if (resource && resource.enabled) {
1132+
const result = await resource.readCallback(uri);
1133+
return {
1134+
...result,
1135+
ttlMs: 300000,
1136+
cacheScope: 'private' as const
1137+
};
1138+
}
1139+
1140+
// Template match
1141+
for (const template of Object.values(registeredResourceTemplates)) {
1142+
const variables = template.resourceTemplate.uriTemplate.match(
1143+
uri.toString()
1144+
);
1145+
if (variables) {
1146+
const result = await template.readCallback(uri, variables);
1147+
return {
1148+
...result,
1149+
ttlMs: 300000,
1150+
cacheScope: 'private' as const
1151+
};
1152+
}
1153+
}
1154+
1155+
throw new Error(`Resource not found: ${uri}`);
1156+
}
1157+
);
1158+
10311159
return mcpServer;
10321160
}
10331161

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: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
} from './server/prompts';
6464

6565
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
66+
import { CachingScenario } from './server/caching';
6667

6768
import {
6869
authScenariosList,
@@ -140,7 +141,10 @@ const allClientScenariosList: ClientScenario[] = [
140141
new PromptsGetWithImageScenario(),
141142

142143
// Security scenarios
143-
new DNSRebindingProtectionScenario()
144+
new DNSRebindingProtectionScenario(),
145+
146+
// Caching scenarios (SEP-2549)
147+
new CachingScenario()
144148
];
145149

146150
// Active client scenarios (excludes pending)

0 commit comments

Comments
 (0)