Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js
import {
ElicitResultSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ReadResourceRequestSchema,
type ListToolsResult,
type Tool
} from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -1023,11 +1027,138 @@ function createMcpServer() {
annotations: tool.annotations,
_meta: tool._meta
};
}),
// SEP-2549: Caching hints
ttlMs: 300000,
cacheScope: 'public' as const
};
}
);

// ===== SEP-2549: Override list/read handlers to include caching hints =====

mcpServer.server.setRequestHandler(ListPromptsRequestSchema, async () => {
const registeredPrompts = (mcpServer as any)._registeredPrompts as Record<
string,
{
enabled: boolean;
title?: string;
description?: string;
argsSchema?: any;
}
>;

return {
prompts: Object.entries(registeredPrompts)
.filter(([, prompt]) => prompt.enabled)
.map(([name, prompt]) => ({
name,
title: prompt.title,
description: prompt.description
})),
ttlMs: 300000,
cacheScope: 'public' as const
};
});

mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => {
const registeredResources = (mcpServer as any)
._registeredResources as Record<
string,
{ enabled: boolean; name: string; metadata?: any }
>;

return {
resources: Object.entries(registeredResources)
.filter(([, res]) => res.enabled)
.map(([uri, res]) => ({
uri,
name: res.name,
...res.metadata
})),
ttlMs: 300000,
cacheScope: 'public' as const
};
});

mcpServer.server.setRequestHandler(
ListResourceTemplatesRequestSchema,
async () => {
const registeredResourceTemplates = (mcpServer as any)
._registeredResourceTemplates as Record<
string,
{ resourceTemplate: any; metadata?: any }
>;

return {
resourceTemplates: Object.entries(registeredResourceTemplates).map(
([name, template]) => ({
name,
uriTemplate: template.resourceTemplate.uriTemplate.toString(),
...template.metadata
})
),
ttlMs: 300000,
cacheScope: 'public' as const
};
}
);

mcpServer.server.setRequestHandler(
ReadResourceRequestSchema,
async (request: any) => {
const uri = new URL(request.params.uri);
const registeredResources = (mcpServer as any)
._registeredResources as Record<
string,
{
enabled: boolean;
readCallback: (uri: URL, extra?: any) => Promise<any>;
}
>;
const registeredResourceTemplates = (mcpServer as any)
._registeredResourceTemplates as Record<
string,
{
resourceTemplate: any;
readCallback: (
uri: URL,
variables: Record<string, string>,
extra?: any
) => Promise<any>;
}
>;

// Exact resource match
const resource = registeredResources[uri.toString()];
if (resource && resource.enabled) {
const result = await resource.readCallback(uri);
return {
...result,
ttlMs: 300000,
cacheScope: 'private' as const
};
}

// Template match
for (const template of Object.values(registeredResourceTemplates)) {
const variables = template.resourceTemplate.uriTemplate.match(
uri.toString()
);
if (variables) {
const result = await template.readCallback(uri, variables);
return {
...result,
ttlMs: 300000,
cacheScope: 'private' as const
};
}
}

throw new Error(`Resource not found: ${uri}`);
}
);

return mcpServer;
}

Expand Down
159 changes: 159 additions & 0 deletions examples/servers/typescript/sep-2549-no-caching-hints.ts
Original file line number Diff line number Diff line change
@@ -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`
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { request } from 'undici';

type Status = 'SUCCESS' | 'FAILURE';

export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer {
export class AuthorizationServerMetadataEndpointScenario
implements ClientScenarioForAuthorizationServer
{
name = 'authorization-server-metadata-endpoint';
readonly source = { introducedIn: '2025-03-26' } as const;
description = `Test authorization server metadata endpoint.
Expand Down
6 changes: 5 additions & 1 deletion src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from './server/prompts';

import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
import { CachingScenario } from './server/caching';

import {
authScenariosList,
Expand Down Expand Up @@ -140,7 +141,10 @@ const allClientScenariosList: ClientScenario[] = [
new PromptsGetWithImageScenario(),

// Security scenarios
new DNSRebindingProtectionScenario()
new DNSRebindingProtectionScenario(),

// Caching scenarios (SEP-2549)
new CachingScenario()
];

// Active client scenarios (excludes pending)
Expand Down
Loading
Loading