feat: add conformance Tests for SEP-2549: TTL for List Results (#275)
This commit is contained in:
parent
583d42d2d2
commit
7f1b8712ab
2 changed files with 649 additions and 0 deletions
|
|
@ -22,6 +22,9 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js
|
|||
import {
|
||||
ElicitResultSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
type ListToolsResult,
|
||||
type Tool
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
|
@ -128,6 +131,46 @@ function createMcpServer() {
|
|||
}
|
||||
);
|
||||
|
||||
// SEP-2549: Wrap setRequestHandler so the SDK's own list handlers
|
||||
// automatically get caching hints appended to their responses.
|
||||
const originalSetRequestHandler = mcpServer.server.setRequestHandler.bind(
|
||||
mcpServer.server
|
||||
);
|
||||
const listSchemasForCaching = new Set([
|
||||
ListToolsRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema
|
||||
]);
|
||||
mcpServer.server.setRequestHandler = ((schema: any, handler: any) => {
|
||||
if (listSchemasForCaching.has(schema)) {
|
||||
return originalSetRequestHandler(schema, async (...args: any[]) => {
|
||||
const result = await handler(...args);
|
||||
return { ...result, ttlMs: 300000, cacheScope: 'public' as const };
|
||||
});
|
||||
}
|
||||
return originalSetRequestHandler(schema, handler);
|
||||
}) as typeof mcpServer.server.setRequestHandler;
|
||||
|
||||
const registerResourceWithCacheHints =
|
||||
mcpServer.registerResource.bind(mcpServer);
|
||||
mcpServer.registerResource = ((
|
||||
name: string,
|
||||
uriOrTemplate: string | ResourceTemplate,
|
||||
config: any,
|
||||
readCallback: any
|
||||
) =>
|
||||
registerResourceWithCacheHints(
|
||||
name,
|
||||
uriOrTemplate as any,
|
||||
config,
|
||||
async (...args: any[]) => ({
|
||||
...(await readCallback(...args)),
|
||||
ttlMs: 300000,
|
||||
cacheScope: 'private' as const
|
||||
})
|
||||
)) as typeof mcpServer.registerResource;
|
||||
|
||||
// Helper to send log messages using the underlying server
|
||||
function sendLog(
|
||||
level:
|
||||
|
|
@ -1024,6 +1067,8 @@ function createMcpServer() {
|
|||
_meta: tool._meta
|
||||
};
|
||||
})
|
||||
// Note: SEP-2549 caching hints are added automatically by the
|
||||
// setRequestHandler wrapper above
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
|||
159
examples/servers/typescript/sep-2549-no-caching-hints.ts
Normal file
159
examples/servers/typescript/sep-2549-no-caching-hints.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* SEP-2549 Negative Test Server
|
||||
*
|
||||
* Returns list and read results WITHOUT ttlMs and cacheScope fields,
|
||||
* violating the SEP-2549 MUST. The caching scenario should emit FAILURE
|
||||
* for presence checks against this server.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ReadResourceRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import express from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const transports: Record<string, StreamableHTTPServerTransport> = {};
|
||||
|
||||
function isInitializeRequest(body: any): boolean {
|
||||
return body?.method === 'initialize';
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
const server = new Server(
|
||||
{ name: 'sep-2549-no-caching-hints', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Deliberately omit ttlMs and cacheScope from all responses
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object' as const }
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
||||
prompts: [
|
||||
{
|
||||
name: 'test_prompt',
|
||||
description: 'A test prompt'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
name: 'Static Text',
|
||||
description: 'A static text resource'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
||||
resourceTemplates: []
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'test://static-text',
|
||||
mimeType: 'text/plain',
|
||||
text: 'Static text content.'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
try {
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId && isInitializeRequest(req.body)) {
|
||||
const server = createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (newSessionId) => {
|
||||
transports[newSessionId] = transport;
|
||||
}
|
||||
});
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) delete transports[sid];
|
||||
};
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Invalid or missing session ID' },
|
||||
id: null
|
||||
});
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or missing session ID' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid or missing session ID' });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3006', 10);
|
||||
app.listen(PORT, () => {
|
||||
console.log(
|
||||
`SEP-2549 negative test server running on http://localhost:${PORT}/mcp`
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue