From af025e26a75547a69629141b42dc932fc3b7177c Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Fri, 28 Feb 2025 15:20:55 -0800 Subject: [PATCH 001/103] Include AuthInfo from bearer validation in transport request handler --- src/server/sse.ts | 12 +++++++----- src/shared/protocol.ts | 14 ++++++++++---- src/shared/transport.ts | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 84c1cbb9..e990fafd 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -4,6 +4,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { AuthInfo } from "./auth/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -18,7 +19,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -66,7 +67,7 @@ export class SSEServerTransport implements Transport { * This should be called when a POST request is made to send a message to the server. */ async handlePostMessage( - req: IncomingMessage, + req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown, ): Promise { @@ -75,6 +76,7 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + const authInfo: AuthInfo | undefined = req.auth; let body: string | unknown; try { @@ -94,7 +96,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, authInfo); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -106,7 +108,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown): Promise { + async handleMessage(message: unknown, authInfo?: AuthInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -115,7 +117,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage); + this.onmessage?.(parsedMessage, authInfo); } async close(): Promise { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 97213bf0..46b4c96e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -19,6 +19,7 @@ import { ServerCapabilities, } from "../types.js"; import { Transport } from "./transport.js"; +import { AuthInfo } from "../server/auth/types.js"; /** * Callback for progress notifications. @@ -88,6 +89,11 @@ export type RequestHandlerExtra = { * An abort signal used to communicate if the request was cancelled from the sender's side. */ signal: AbortSignal; + + /** + * Information about a validated access token, provided to request handlers. + */ + authInfo?: AuthInfo; }; /** @@ -232,11 +238,11 @@ export abstract class Protocol< this._onerror(error); }; - this._transport.onmessage = (message) => { + this._transport.onmessage = (message, authInfo) => { if (!("method" in message)) { this._onresponse(message); } else if ("id" in message) { - this._onrequest(message); + this._onrequest(message, authInfo); } else { this._onnotification(message); } @@ -282,7 +288,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest): void { + private _onrequest(request: JSONRPCRequest, authInfo?: AuthInfo): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -309,7 +315,7 @@ export abstract class Protocol< // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(request, { signal: abortController.signal })) + .then(() => handler(request, { signal: abortController.signal, authInfo })) .then( (result) => { if (abortController.signal.aborted) { diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 5843cf00..de7e11d4 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,3 +1,4 @@ +import { AuthInfo } from "../server/auth/types.js"; import { JSONRPCMessage } from "../types.js"; /** @@ -39,6 +40,9 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. + * + * Includes the authInfo if the transport is authenticated. + * */ - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; } From 7b20fd7a50deeb7416cb99635c782e747b52c5f5 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 7 Apr 2025 11:29:21 -0700 Subject: [PATCH 002/103] Add AuthInfo test --- src/inMemory.test.ts | 27 +++++++++++++++++++++++++++ src/inMemory.ts | 28 ++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index f7e9e979..b6906cc8 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -1,5 +1,6 @@ import { InMemoryTransport } from "./inMemory.js"; import { JSONRPCMessage } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; describe("InMemoryTransport", () => { let clientTransport: InMemoryTransport; @@ -35,6 +36,32 @@ describe("InMemoryTransport", () => { expect(receivedMessage).toEqual(message); }); + test("should send message with auth info from client to server", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + id: 1, + }; + + const authInfo: AuthInfo = { + token: "test-token", + clientId: "test-client", + scopes: ["read", "write"], + expiresAt: Date.now() / 1000 + 3600, + }; + + let receivedMessage: JSONRPCMessage | undefined; + let receivedAuthInfo: AuthInfo | undefined; + serverTransport.onmessage = (msg, auth) => { + receivedMessage = msg; + receivedAuthInfo = auth; + }; + + await clientTransport.sendWithAuth(message, authInfo); + expect(receivedMessage).toEqual(message); + expect(receivedAuthInfo).toEqual(authInfo); + }); + test("should send message from server to client", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/inMemory.ts b/src/inMemory.ts index 106a9e7e..9de100c2 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -1,16 +1,22 @@ import { Transport } from "./shared/transport.js"; import { JSONRPCMessage } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; + +interface QueuedMessage { + message: JSONRPCMessage; + authInfo?: AuthInfo; +} /** * In-memory transport for creating clients and servers that talk to each other within the same process. */ export class InMemoryTransport implements Transport { private _otherTransport?: InMemoryTransport; - private _messageQueue: JSONRPCMessage[] = []; + private _messageQueue: QueuedMessage[] = []; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; sessionId?: string; /** @@ -27,10 +33,8 @@ export class InMemoryTransport implements Transport { async start(): Promise { // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { - const message = this._messageQueue.shift(); - if (message) { - this.onmessage?.(message); - } + const queuedMessage = this._messageQueue.shift()!; + this.onmessage?.(queuedMessage.message, queuedMessage.authInfo); } } @@ -42,14 +46,22 @@ export class InMemoryTransport implements Transport { } async send(message: JSONRPCMessage): Promise { + return this.sendWithAuth(message); + } + + /** + * Sends a message with optional auth info. + * This is useful for testing authentication scenarios. + */ + async sendWithAuth(message: JSONRPCMessage, authInfo?: AuthInfo): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message); + this._otherTransport.onmessage(message, authInfo); } else { - this._otherTransport._messageQueue.push(message); + this._otherTransport._messageQueue.push({ message, authInfo }); } } } From 72f46d44c27e4b1eae393a20933c1ac4d8279904 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:49:31 -0500 Subject: [PATCH 003/103] Update src/shared/transport.ts Co-authored-by: Justin Spahr-Summers --- src/shared/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 68b7149d..ffc77d0b 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -44,7 +44,7 @@ export interface Transport { * Includes the authInfo if the transport is authenticated. * */ - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * The session ID generated for this connection. From bc38bb29c7e988ea58d56b533c7fa589e4fa485f Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:49:58 -0500 Subject: [PATCH 004/103] Update src/inMemory.ts Co-authored-by: Justin Spahr-Summers --- src/inMemory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inMemory.ts b/src/inMemory.ts index 9de100c2..8889fdd8 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -53,7 +53,7 @@ export class InMemoryTransport implements Transport { * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async sendWithAuth(message: JSONRPCMessage, authInfo?: AuthInfo): Promise { + async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise if (!this._otherTransport) { throw new Error("Not connected"); } From 111586c523389ed72a0f80a37232e55d35c78d04 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:23:34 +0100 Subject: [PATCH 005/103] support sse though get --- src/examples/client/simpleStreamableHttp.ts | 112 +++++++++------ .../standaloneSseWithGetStreamableHttp.ts | 136 ++++++++++++++++++ src/server/streamableHttp.test.ts | 113 ++++++++++++++- src/server/streamableHttp.ts | 97 ++++++++++++- 4 files changed, 407 insertions(+), 51 deletions(-) create mode 100644 src/examples/server/standaloneSseWithGetStreamableHttp.ts diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index b17add14..0321bea4 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -11,7 +11,8 @@ import { GetPromptResultSchema, ListResourcesRequest, ListResourcesResultSchema, - LoggingMessageNotificationSchema + LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema } from '../../types.js'; async function main(): Promise { @@ -24,51 +25,80 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); + const supportsStandaloneSse = false; // Connect the client using the transport and initialize the server await client.connect(transport); + console.log('Connected to MCP server'); + // Open a standalone SSE stream to receive server-initiated messages + console.log('Opening SSE stream to receive server notifications...'); + try { + await transport.openSseStream(); + const supportsStandaloneSse = false; + console.log('SSE stream established successfully. Waiting for notifications...'); + } + catch (error) { + console.error('Failed to open SSE stream:', error); + } + + // Set up notification handlers for server-initiated messages client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); }); - - - console.log('Connected to MCP server'); - // List available tools - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); - - // Call the 'greet' tool - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); - - // Call the new 'multi-greet' tool - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } - } - }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { - if (item.type === 'text') { - console.log(`- ${item.text}`); - } + client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { + console.log(`Resource list changed notification received!`); + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); + console.log('Available resources count:', resourcesResult.resources.length); }); + // List available tools + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + console.log('Available tools:', toolsResult.tools); + + if (toolsResult.tools.length === 0) { + console.log('No tools available from the server'); + } else { + // Call the 'greet' tool + const greetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } + }; + const greetResult = await client.request(greetRequest, CallToolResultSchema); + console.log('Greeting result:', greetResult.content[0].text); + + // Call the new 'multi-greet' tool + console.log('\nCalling multi-greet tool (with notifications)...'); + const multiGreetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'multi-greet', + arguments: { name: 'MCP User' } + } + }; + const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); + console.log('Multi-greet results:'); + multiGreetResult.content.forEach(item => { + if (item.type === 'text') { + console.log(`- ${item.text}`); + } + }); + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } + // List available prompts try { const promptsRequest: ListPromptsRequest = { @@ -107,9 +137,11 @@ async function main(): Promise { } catch (error) { console.log(`Resources not supported by this server (${error})`); } + if (supportsStandaloneSse) { + // Instead of closing immediately, keep the connection open to receive notifications + console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); + } - // Close the connection - await client.close(); } main().catch((error: unknown) => { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts new file mode 100644 index 00000000..bfe5b2af --- /dev/null +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -0,0 +1,136 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { ReadResourceResult } from '../../types.js'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0', +}, { + capabilities: { + resources: { + listChanged: true, // Support notifications for resource list changes + }, + } +}); + +// Store transports by session ID to send notifications +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const addResource = (name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.resource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }], + }; + } + ); + +}; + +addResource('example-resource', 'Initial content for example-resource'); + +const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + addResource(name, `Content for ${name}`); +}, 5000); // Change resources every 5 seconds for testing + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + // Connect the transport to the MCP server + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + + // Store the transport by session ID for future requests + if (transport.sessionId) { + transports[transport.sessionId] = transport; + } + return; // Already handled + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Helper function to detect initialize requests +function isInitializeRequest(body: unknown): boolean { + if (Array.isArray(body)) { + return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); + } + return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; +} + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + clearInterval(resourceChangeInterval); + await server.close(); + process.exit(0); +}); \ No newline at end of file diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index ad80ea62..69a77f44 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -564,22 +564,123 @@ describe("StreamableHTTPServerTransport", () => { mockResponse.writeHead.mockClear(); }); - it("should reject GET requests for SSE with 405 Method Not Allowed", async () => { + it("should accept GET requests for SSE with proper Accept header", async () => { const req = createMockRequest({ method: "GET", headers: { - "accept": "application/json, text/event-stream", + "accept": "text/event-stream", "mcp-session-id": transport.sessionId, }, }); await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(405, expect.objectContaining({ - "Allow": "POST, DELETE" + expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "mcp-session-id": transport.sessionId, })); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Method not allowed')); + }); + + it("should reject GET requests without Accept: text/event-stream header", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "application/json", + "mcp-session-id": transport.sessionId, + }, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(406); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Not Acceptable: Client must accept text/event-stream"')); + }); + + it("should send server-initiated requests to GET SSE stream", async () => { + // Open a standalone SSE stream with GET + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse = createMockResponse(); + await transport.handleRequest(req, sseResponse); + + // Send a notification without a related request ID + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/resources/updated", + params: { uri: "someuri" } + }; + + await transport.send(notification); + + // Verify notification was sent on SSE stream + expect(sseResponse.write).toHaveBeenCalledWith( + expect.stringContaining(`event: message\ndata: ${JSON.stringify(notification)}\n\n`) + ); + }); + + it("should not close GET SSE stream after sending server requests or notifications", async () => { + // Open a standalone SSE stream + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse = createMockResponse(); + await transport.handleRequest(req, sseResponse); + + // Send multiple notifications + const notification1: JSONRPCMessage = { jsonrpc: "2.0", method: "event1", params: {} }; + const notification2: JSONRPCMessage = { jsonrpc: "2.0", method: "event2", params: {} }; + + await transport.send(notification1); + await transport.send(notification2); + + // Stream should remain open + expect(sseResponse.end).not.toHaveBeenCalled(); + }); + + it("should reject second GET SSE stream for the same session", async () => { + // Open first SSE stream - should succeed + const req1 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse1 = createMockResponse(); + await transport.handleRequest(req1, sseResponse1); + + // Try to open a second SSE stream - should be rejected + const req2 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse2 = createMockResponse(); + await transport.handleRequest(req2, sseResponse2); + + // First stream should be good + expect(sseResponse1.writeHead).toHaveBeenCalledWith(200, expect.anything()); + + // Second stream should get 409 Conflict + expect(sseResponse2.writeHead).toHaveBeenCalledWith(409); + expect(sseResponse2.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Conflict: Only one SSE stream is allowed per session"')); }); it("should reject POST requests without proper Accept header", async () => { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index e8844529..58fc79c0 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -68,6 +68,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; + private _sseStreamKey = "standalone-sse"; sessionId?: string | undefined; @@ -97,6 +98,8 @@ export class StreamableHTTPServerTransport implements Transport { async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); + } else if (req.method === "GET") { + await this.handleGetRequest(req, res); } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { @@ -105,12 +108,80 @@ export class StreamableHTTPServerTransport implements Transport { } /** - * Handles unsupported requests (GET, PUT, PATCH, etc.) - * For now we support only POST and DELETE requests. Support for GET for SSE connections will be added later. + * Handles GET requests for SSE stream + */ + private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { + // The client MUST include an Accept header, listing text/event-stream as a supported content type. + const acceptHeader = req.headers.accept; + if (!acceptHeader?.includes("text/event-stream")) { + res.writeHead(406).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Not Acceptable: Client must accept text/event-stream" + }, + id: null + })); + return; + } + + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (!this.validateSession(req, res)) { + return; + } + + // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, + // or else return HTTP 405 Method Not Allowed + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; + + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + // The server MAY include a Last-Event-ID header in the response to this HTTP GET. + // Resumability will be supported in the future + + // Check if there's already an active standalone SSE stream for this session + const existingStream = this._responseMapping.get(this._sseStreamKey); + + if (existingStream !== undefined) { + // Only one GET SSE stream is allowed per session + res.writeHead(409).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Conflict: Only one SSE stream is allowed per session" + }, + id: null + })); + return; + } + + res.writeHead(200, headers); + + // Store the response for this request so we can use it for standalone server notifications + // This response doesn't have an associated request ID, so we'll use a special string to track it + this._responseMapping.set(this._sseStreamKey, res); + + // Set up close handler for client disconnects + res.on("close", () => { + // Clean up resources associated with this connection + this._responseMapping.delete(this._sseStreamKey); + }); + } + + /** + * Handles unsupported requests (PUT, PATCH, etc.) */ private async handleUnsupportedRequest(res: ServerResponse): Promise { res.writeHead(405, { - "Allow": "POST, DELETE" + "Allow": "GET, POST, DELETE" }).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -379,8 +450,25 @@ export class StreamableHTTPServerTransport implements Transport { // If the message is a response, use the request ID from the message requestId = message.id; } + + // Check if this message should be sent on the standalone SSE stream (no request ID) + // Ignore notifications from tools (which have relatedRequestId set) + // Those will be sent via dedicated response SSE streams if (requestId === undefined) { - throw new Error("No request ID provided for the message"); + // For standalone SSE streams, we can only send requests and notifications + if ('result' in message || 'error' in message) { + throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); + } + + const standaloneStream = this._responseMapping.get(this._sseStreamKey); + if (standaloneStream === undefined) { + // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream + return; + } + + // Send the message to the standalone SSE stream + standaloneStream.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + return; } // Get the response for this request @@ -389,7 +477,6 @@ export class StreamableHTTPServerTransport implements Transport { throw new Error(`No connection established for request ID: ${String(requestId)}`); } - if (!this._enableJsonResponse) { response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); } From 0c1a4f22be712b787275b422d604c2817765e084 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:34:28 +0100 Subject: [PATCH 006/103] add get sse to README --- src/examples/README.md | 119 +++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index cc6af51c..6ef35ba4 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -2,12 +2,79 @@ This directory contains example implementations of MCP clients and servers using the TypeScript SDK. +## Table of Contents + +- [Streamable HTTP Servers - Single Node Deployment](#streamable-http---single-node-deployment-with-basic-session-state-management) + - [Simple Server with Streamable HTTP](#simple-server-with-streamable-http-transport-serversimplestreamablehttpts) + - [Server Supporting SSE via GET](#server-supporting-with-sse-via-get-serverstandalonessewithgetstreamablehttpts) + - [Server with JSON Response Mode](#server-with-json-response-mode-serverjsonresponsestreamablehttpts) +- [Client Example - Streamable HTTP](#client-clientsimplestreamablehttpts) + ## Streamable HTTP - single node deployment with basic session state management -Multi node with stete management example will be added soon after we add support. +Multi node with state management example will be added soon after we add support. + + +### Simple Server with Streamable Http transport (`server/simpleStreamableHttp.ts`) + +A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: + +- A simple `greet` tool that returns a greeting for a name +- A `greeting-template` prompt that generates a greeting template +- A static `greeting-resource` resource + +#### Running the server + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts +``` + +The server will start on port 3000. You can test the initialization and tool listing: + +```bash +# First initialize the server and save the session ID to a variable +SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') +echo "Session ID: $SESSION_ID + +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ + http://localhost:3000/mcp +``` + +### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) + +An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). + +#### Running the server + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +The server will start on port 3000 and automatically create new resources every 5 seconds. ### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) +This is not recommented way to use the transport, as its quite limiting and not supporting features like logging and progress notifications on tool execution. A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. #### Running the server @@ -60,52 +127,6 @@ curl -X POST \ http://localhost:3000/mcp ``` -Note that in this example, we're using plain JSON response mode by setting `Accept: application/json` header. - -### Server (`server/simpleStreamableHttp.ts`) - -A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: - -- A simple `greet` tool that returns a greeting for a name -- A `greeting-template` prompt that generates a greeting template -- A static `greeting-resource` resource - -#### Running the server - -```bash -npx tsx src/examples/server/simpleStreamableHttp.ts -``` - -The server will start on port 3000. You can test the initialization and tool listing: - -```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID - -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp -``` ### Client (`client/simpleStreamableHttp.ts`) @@ -123,8 +144,4 @@ npx tsx src/examples/client/simpleStreamableHttp.ts Make sure the server is running before starting the client. -## Notes -- These examples demonstrate the basic usage of the Streamable HTTP transport -- The server manages sessions between the calls -- The client handles both direct HTTP responses and SSE streaming responses From 755534ee1ae685c963d96306431990cb67a89844 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:41:48 +0100 Subject: [PATCH 007/103] clean up examples README --- src/examples/README.md | 92 +++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 51 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 6ef35ba4..22e36de9 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -9,6 +9,7 @@ This directory contains example implementations of MCP clients and servers using - [Server Supporting SSE via GET](#server-supporting-with-sse-via-get-serverstandalonessewithgetstreamablehttpts) - [Server with JSON Response Mode](#server-with-json-response-mode-serverjsonresponsestreamablehttpts) - [Client Example - Streamable HTTP](#client-clientsimplestreamablehttpts) +- [Useful bash commands for testing](#useful-commands-for-testing) ## Streamable HTTP - single node deployment with basic session state management @@ -31,35 +32,6 @@ npx tsx src/examples/server/simpleStreamableHttp.ts The server will start on port 3000. You can test the initialization and tool listing: -```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID - -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp -``` - ### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). @@ -83,10 +55,31 @@ A simple MCP server that uses the Streamable HTTP transport with JSON response m npx tsx src/examples/server/jsonResponseStreamableHttp.ts ``` -The server will start on port 3000. You can test the initialization and tool calling: + +### Client (`client/simpleStreamableHttp.ts`) + +A client that connects to the server, initializes it, and demonstrates how to: + +- List available tools and call the `greet` tool +- List available prompts and get the `greeting-template` prompt +- List available resources + +#### Running the client ```bash -# Initialize the server and get the session ID from headers +npx tsx src/examples/client/simpleStreamableHttp.ts +``` + +Make sure the server is running before starting the client. + + +### Useful commands for testing + +#### Initialize +Streamable HTTP transport requires to do the initialization first. + +```bash +# First initialize the server and save the session ID to a variable SESSION_ID=$(curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ @@ -105,8 +98,24 @@ SESSION_ID=$(curl -X POST \ "id": "1" }' \ -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID" +echo "Session ID: $SESSION_ID +``` + +Once thre is a session we can send POST requests + +#### List tools +```bash +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ + http://localhost:3000/mcp +``` + +#### Call tool + +```bash # Call the greet tool using the saved session ID curl -X POST \ -H "Content-Type: application/json" \ @@ -126,22 +135,3 @@ curl -X POST \ }' \ http://localhost:3000/mcp ``` - - -### Client (`client/simpleStreamableHttp.ts`) - -A client that connects to the server, initializes it, and demonstrates how to: - -- List available tools and call the `greet` tool -- List available prompts and get the `greeting-template` prompt -- List available resources - -#### Running the client - -```bash -npx tsx src/examples/client/simpleStreamableHttp.ts -``` - -Make sure the server is running before starting the client. - - From 5c5a33021cf3e09123895744882fcf2948ad331b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 15:28:39 +0100 Subject: [PATCH 008/103] add GET to examples servers --- src/client/streamableHttp.ts | 1 - src/examples/client/simpleStreamableHttp.ts | 4 ++-- src/examples/server/jsonResponseStreamableHttp.ts | 7 +++++++ src/examples/server/simpleStreamableHttp.ts | 13 +++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 5ea537c7..ae54bb26 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -131,7 +131,6 @@ export class StreamableHTTPClientTransport implements Transport { `Failed to open SSE stream: ${response.statusText}`, ); } - // Successful connection, handle the SSE stream as a standalone listener this._handleSseStream(response.body); } catch (error) { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0321bea4..3eb2485d 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -25,7 +25,7 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - const supportsStandaloneSse = false; + let supportsStandaloneSse = false; // Connect the client using the transport and initialize the server await client.connect(transport); @@ -34,7 +34,7 @@ async function main(): Promise { console.log('Opening SSE stream to receive server notifications...'); try { await transport.openSseStream(); - const supportsStandaloneSse = false; + supportsStandaloneSse = true; console.log('SSE stream established successfully. Waiting for notifications...'); } catch (error) { diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 1d322112..34ab65d1 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -138,6 +138,13 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// Handle GET requests for SSE streams according to spec +app.get('/mcp', async (req: Request, res: Response) => { + // Since this is a very simple example, we don't support GET requests for this server + // The spec requires returning 405 Method Not Allowed in this case + res.status(405).set('Allow', 'POST').send('Method Not Allowed'); +}); + // Helper function to detect initialize requests function isInitializeRequest(body: unknown): boolean { if (Array.isArray(body)) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 5b228cbd..6e4b2548 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -174,6 +174,19 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + // Helper function to detect initialize requests function isInitializeRequest(body: unknown): boolean { if (Array.isArray(body)) { From 3dd4ff1542cf2a42332a18b4b6432b2433717770 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 15:53:05 +0100 Subject: [PATCH 009/103] fix hanging issue --- src/server/streamableHttp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 58fc79c0..081e1982 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -162,8 +162,9 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - - res.writeHead(200, headers); + // We need to send headers immediately as message will arrive much later, + // otherwise the client will just wait for the first message + res.writeHead(200, headers).flushHeaders(); // Store the response for this request so we can use it for standalone server notifications // This response doesn't have an associated request ID, so we'll use a special string to track it From 3adca3f3bc19c03b7e625d3dec273d2d7c0280b6 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 16:01:21 +0100 Subject: [PATCH 010/103] fix tests --- src/server/streamableHttp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 69a77f44..d6f7cb8a 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -32,6 +32,7 @@ function createMockResponse(): jest.Mocked { emit: jest.fn().mockReturnThis(), getHeader: jest.fn(), setHeader: jest.fn(), + flushHeaders: jest.fn(), } as unknown as jest.Mocked; return response; } From 5730f3e92af60f727e90529d407b30e9814cd8c4 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 10:19:09 +0100 Subject: [PATCH 011/103] fixes in README --- src/examples/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 22e36de9..2ea1a596 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -16,7 +16,7 @@ This directory contains example implementations of MCP clients and servers using Multi node with state management example will be added soon after we add support. -### Simple Server with Streamable Http transport (`server/simpleStreamableHttp.ts`) +### Simple server with Streamable HTTP transport (`server/simpleStreamableHttp.ts`) A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: @@ -32,7 +32,7 @@ npx tsx src/examples/server/simpleStreamableHttp.ts The server will start on port 3000. You can test the initialization and tool listing: -### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) +### Server supporting SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). @@ -46,9 +46,10 @@ The server will start on port 3000 and automatically create new resources every ### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) -This is not recommented way to use the transport, as its quite limiting and not supporting features like logging and progress notifications on tool execution. A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. +_NOTE: This demonstrates a server that does not use SSE at all. Note that this limits its support for MCP features; for example, it cannot provide logging and progress notifications for tool execution._ + #### Running the server ```bash @@ -101,8 +102,7 @@ SESSION_ID=$(curl -X POST \ echo "Session ID: $SESSION_ID ``` - -Once thre is a session we can send POST requests +Once a sessionĀ is established, we can send POST requests: #### List tools ```bash From e3b4496579dc8406e4e72265d6d1b03985fa164e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 10:42:41 +0100 Subject: [PATCH 012/103] clean up and small fixes to comments --- src/examples/client/simpleStreamableHttp.ts | 1 - src/examples/server/simpleStreamableHttp.ts | 2 +- src/examples/server/standaloneSseWithGetStreamableHttp.ts | 6 ------ src/server/streamableHttp.ts | 2 +- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 3eb2485d..027db881 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -30,7 +30,6 @@ async function main(): Promise { // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); - // Open a standalone SSE stream to receive server-initiated messages console.log('Opening SSE stream to receive server notifications...'); try { await transport.openSseStream(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6e4b2548..f0f74439 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -174,7 +174,7 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); -// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) app.get('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index bfe5b2af..f9d3696b 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -8,12 +8,6 @@ import { ReadResourceResult } from '../../types.js'; const server = new McpServer({ name: 'resource-list-changed-notification-server', version: '1.0.0', -}, { - capabilities: { - resources: { - listChanged: true, // Support notifications for resource list changes - }, - } }); // Store transports by session ID to send notifications diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 081e1982..05ba6146 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -136,7 +136,7 @@ export class StreamableHTTPServerTransport implements Transport { // or else return HTTP 405 Method Not Allowed const headers: Record = { "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }; From 18e4981bd3d0fde152659b95d4250828872718f8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:06:51 +0100 Subject: [PATCH 013/103] move get sse stream from a separate method to automatic connection when notifications/initialized received --- src/client/streamableHttp.test.ts | 11 ++++---- src/client/streamableHttp.ts | 30 +++++++++------------ src/examples/client/simpleStreamableHttp.ts | 21 +++------------ src/server/streamableHttp.test.ts | 2 +- 4 files changed, 23 insertions(+), 41 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 40f22139..1572e912 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,8 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport.openSseStream()).rejects.toThrow("Failed to open SSE stream: Method Not Allowed"); - + await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -209,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -295,7 +294,7 @@ describe("StreamableHTTPClientTransport", () => { }); await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); await new Promise(resolve => setTimeout(resolve, 50)); // Now simulate attempting to reconnect @@ -306,7 +305,7 @@ describe("StreamableHTTPClientTransport", () => { body: null }); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); // Check that Last-Event-ID was included const calls = (global.fetch as jest.Mock).mock.calls; @@ -366,7 +365,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ae54bb26..ea69ee77 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -126,6 +126,12 @@ export class StreamableHTTPClientTransport implements Transport { return await this._authThenStart(); } + // 405 indicates that the server does not offer an SSE stream at GET endpoint + // This is an expected case that should not trigger an error + if (response.status === 405) { + return; + } + throw new StreamableHTTPError( response.status, `Failed to open SSE stream: ${response.statusText}`, @@ -243,6 +249,12 @@ export class StreamableHTTPClientTransport implements Transport { // If the response is 202 Accepted, there's no body to process if (response.status === 202) { + // if the accepted notification is initialized, we start the SSE stream + // if it's supported by the server + if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { + // We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE + this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + } return; } @@ -279,20 +291,4 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } - - /** - * Opens SSE stream to receive messages from the server. - * - * This allows the server to push messages to the client without requiring the client - * to first send a request via HTTP POST. Some servers may not support this feature. - * If authentication is required but fails, this method will throw an UnauthorizedError. - */ - async openSseStream(): Promise { - if (!this._abortController) { - throw new Error( - "StreamableHTTPClientTransport not started! Call connect() before openSseStream().", - ); - } - await this._startOrAuthStandaloneSSE(); - } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 027db881..c23c17f8 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -25,20 +25,10 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - let supportsStandaloneSse = false; - - // Connect the client using the transport and initialize the server + + // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); - console.log('Opening SSE stream to receive server notifications...'); - try { - await transport.openSseStream(); - supportsStandaloneSse = true; - console.log('SSE stream established successfully. Waiting for notifications...'); - } - catch (error) { - console.error('Failed to open SSE stream:', error); - } // Set up notification handlers for server-initiated messages client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { @@ -136,11 +126,8 @@ async function main(): Promise { } catch (error) { console.log(`Resources not supported by this server (${error})`); } - if (supportsStandaloneSse) { - // Instead of closing immediately, keep the connection open to receive notifications - console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); - } - + // Keep the connection open to receive notifications + console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); } main().catch((error: unknown) => { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index d6f7cb8a..85fcae2f 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -578,7 +578,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "mcp-session-id": transport.sessionId, })); From 2c2cf5b4a2c09c336558cee3078320044e875c16 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:15:43 +0100 Subject: [PATCH 014/103] cleam up example client --- src/examples/client/simpleStreamableHttp.ts | 104 ++++++++++++-------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c23c17f8..739e1164 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -12,7 +12,7 @@ import { ListResourcesRequest, ListResourcesResultSchema, LoggingMessageNotificationSchema, - ResourceListChangedNotificationSchema + ResourceListChangedNotificationSchema, } from '../../types.js'; async function main(): Promise { @@ -25,7 +25,7 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - + // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); @@ -44,49 +44,12 @@ async function main(): Promise { console.log('Available resources count:', resourcesResult.resources.length); }); - // List available tools - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); + // List and call tools + await listTools(client); - if (toolsResult.tools.length === 0) { - console.log('No tools available from the server'); - } else { - // Call the 'greet' tool - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); + await callGreetTool(client); + await callMultiGreetTool(client); - // Call the new 'multi-greet' tool - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } - } - }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { - if (item.type === 'text') { - console.log(`- ${item.text}`); - } - }); - } - } catch (error) { - console.log(`Tools not supported by this server (${error})`); - } // List available prompts try { @@ -130,6 +93,61 @@ async function main(): Promise { console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); } +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + console.log('Available tools:', toolsResult.tools); + if (toolsResult.tools.length === 0) { + console.log('No tools available from the server'); + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + return + } +} + +async function callGreetTool(client: Client): Promise { + try { + const greetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } + }; + const greetResult = await client.request(greetRequest, CallToolResultSchema); + console.log('Greeting result:', greetResult.content[0].text); + } catch (error) { + console.log(`Error calling greet tool: ${error}`); + } +} + +async function callMultiGreetTool(client: Client): Promise { + try { + console.log('\nCalling multi-greet tool (with notifications)...'); + const multiGreetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'multi-greet', + arguments: { name: 'MCP User' } + } + }; + const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); + console.log('Multi-greet results:'); + multiGreetResult.content.forEach(item => { + if (item.type === 'text') { + console.log(`- ${item.text}`); + } + }); + } catch (error) { + console.log(`Error calling multi-greet tool: ${error}`); + } +} + main().catch((error: unknown) => { console.error('Error running MCP client:', error); process.exit(1); From 44330448da66295857172d0587ac3d33f1a94276 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:21:12 +0100 Subject: [PATCH 015/103] separate field on the class for standaloneSSE --- src/server/streamableHttp.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 05ba6146..ec8d2aa7 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -68,7 +68,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _sseStreamKey = "standalone-sse"; + private _standaloneSSE: ServerResponse | undefined; sessionId?: string | undefined; @@ -148,9 +148,8 @@ export class StreamableHTTPServerTransport implements Transport { // Resumability will be supported in the future // Check if there's already an active standalone SSE stream for this session - const existingStream = this._responseMapping.get(this._sseStreamKey); - if (existingStream !== undefined) { + if (this._standaloneSSE !== undefined) { // Only one GET SSE stream is allowed per session res.writeHead(409).end(JSON.stringify({ jsonrpc: "2.0", @@ -166,14 +165,12 @@ export class StreamableHTTPServerTransport implements Transport { // otherwise the client will just wait for the first message res.writeHead(200, headers).flushHeaders(); - // Store the response for this request so we can use it for standalone server notifications - // This response doesn't have an associated request ID, so we'll use a special string to track it - this._responseMapping.set(this._sseStreamKey, res); + // Assing the response to the standalone SSE stream + this._standaloneSSE = res; // Set up close handler for client disconnects res.on("close", () => { - // Clean up resources associated with this connection - this._responseMapping.delete(this._sseStreamKey); + this._standaloneSSE = undefined; }); } @@ -461,14 +458,13 @@ export class StreamableHTTPServerTransport implements Transport { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } - const standaloneStream = this._responseMapping.get(this._sseStreamKey); - if (standaloneStream === undefined) { + if (this._standaloneSSE === undefined) { // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream return; } // Send the message to the standalone SSE stream - standaloneStream.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + this._standaloneSSE.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); return; } From 03de5b6853ec2f214848931f02764710b9da59e8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 18:27:12 +0100 Subject: [PATCH 016/103] Use server and transport classes instead of mocks in streamable http transport --- src/server/streamableHttp.test.ts | 2401 +++++++++++------------------ src/server/streamableHttp.ts | 2 + 2 files changed, 902 insertions(+), 1501 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 85fcae2f..dda69331 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,1590 +1,989 @@ -import { IncomingMessage, ServerResponse } from "node:http"; -import { StreamableHTTPServerTransport } from "./streamableHttp.js"; -import { JSONRPCMessage } from "../types.js"; -import { Readable } from "node:stream"; +import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -// Mock IncomingMessage -function createMockRequest(options: { - method: string; - headers: Record; - body?: string; -}): IncomingMessage { - const readable = new Readable(); - readable._read = () => { }; - if (options.body) { - readable.push(options.body); - readable.push(null); - } +import { StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { McpServer } from "./mcp.js"; +import { CallToolResult, JSONRPCMessage } from "../types.js"; +import { z } from "zod"; - return Object.assign(readable, { - method: options.method, - headers: options.headers, - }) as IncomingMessage; +/** + * Test server configuration for StreamableHTTPServerTransport tests + */ +interface TestServerConfig { + sessionIdGenerator?: () => string | undefined; + enableJsonResponse?: boolean; + customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; } -// Mock ServerResponse -function createMockResponse(): jest.Mocked { - const response = { - writeHead: jest.fn().mockReturnThis(), - write: jest.fn().mockReturnThis(), - end: jest.fn().mockReturnThis(), - on: jest.fn().mockReturnThis(), - emit: jest.fn().mockReturnThis(), - getHeader: jest.fn(), - setHeader: jest.fn(), - flushHeaders: jest.fn(), - } as unknown as jest.Mocked; - return response; +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServer(config: TestServerConfig = {}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator ?? (() => randomUUID()), + enableJsonResponse: config.enableJsonResponse ?? false + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, transport, mcpServer, baseUrl }; +} + +/** + * Helper to stop test server + */ +async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + await transport.close(); + await new Promise((resolve) => server.close(() => resolve())); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + }, + id: "init-1", + } as JSONRPCMessage, + + toolsList: { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "tools-1", + } as JSONRPCMessage +}; + +/** + * Helper to extract text from SSE response + * Note: Can only be called once per response stream. For multiple reads, + * get the reader manually and read multiple times. + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to send JSON-RPC request + */ +async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + + if (sessionId) { + headers["mcp-session-id"] = sessionId; + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + +function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { + expect(data).toMatchObject({ + jsonrpc: "2.0", + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern), + }), + }); } describe("StreamableHTTPServerTransport", () => { + let server: Server; let transport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - let mockRequest: string; + let baseUrl: URL; + let sessionId: string; - beforeEach(() => { - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - mockResponse = createMockResponse(); - mockRequest = JSON.stringify({ + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get("mcp-session-id"); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it("should initialize server and generate session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("mcp-session-id")).toBeDefined(); + }); + + it("should reject second initialization request", async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); + + // Try second initialize + const secondInitMessage: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }); + method: "initialize", + params: { + clientInfo: { name: "test-client-2", version: "1.0" }, + protocolVersion: "2025-03-26", + }, + id: "init-2", + }; + const response = await sendPostRequest(baseUrl, secondInitMessage); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("Session Management", () => { - it("should generate session ID during initialization", async () => { - const initializeMessage: JSONRPCMessage = { + it("should reject batch initialize request", async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { jsonrpc: "2.0", method: "initialize", params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - expect(transport.sessionId).toBeUndefined(); - expect(transport["_initialized"]).toBe(false); - - await transport.handleRequest(req, mockResponse); - - expect(transport.sessionId).toBeDefined(); - expect(transport["_initialized"]).toBe(true); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "mcp-session-id": transport.sessionId, - }) - ); - }); - - it("should reject second initialization request", async () => { - // First initialize - const initMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage1), - }); - - await transport.handleRequest(req1, mockResponse); - expect(transport["_initialized"]).toBe(true); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - mockResponse.end.mockClear(); - - // Try second initialize - const initMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + clientInfo: { name: "test-client-2", version: "1.0" }, + protocolVersion: "2025-03-26", }, id: "init-2", - }; + } + ]; - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage2), - }); + const response = await sendPostRequest(baseUrl, batchInitMessages); - await transport.handleRequest(req2, mockResponse); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); + }); - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Server already initialized"')); + it("should pandle post requests via sse response correctly", async () => { + sessionId = await initializeServer(); + + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + + // Read the SSE stream for the response + const text = await readSSEEvent(response); + + // Parse the SSE event + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "greet", + description: "A simple greeting tool", + }), + ]), + }), + id: "tools-1", }); + }); - it("should reject batch initialize request", async () => { - const batchInitialize: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + it("should call a tool and return the result", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", }, - id: "init-1", - }, - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client-2", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-2", - } - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(batchInitialize), - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Only one initialization request is allowed"')); - }); - - it("should reject invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an invalid session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": "invalid-session-id", - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Session not found"')); - }); - - it("should reject non-initialization requests without session ID with 400 Bad Request", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try without session ID - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - // No mcp-session-id header - }, - body: mockRequest - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header is required"')); - }); - - it("should reject requests to uninitialized server", async () => { - // Create a new transport that hasn't been initialized - const uninitializedTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - "mcp-session-id": "any-session-id", - }, - body: mockRequest - }); - - await uninitializedTransport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Server not initialized"')); - }); - - it("should reject session ID as array", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an array session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": ["session1", "session2"], - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header must be a single value"')); - }); - }); - describe("Mode without state management", () => { - let transportWithoutState: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - - beforeEach(async () => { - transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - mockResponse = createMockResponse(); - - // Initialize the transport for each test - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transportWithoutState.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should not include session ID in response headers when in mode without state management", async () => { - // Use a non-initialization request - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(message), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalled(); - // Extract the headers from writeHead call - const headers = mockResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); - }); - - it("should not validate session ID in mode without state management", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "invalid-session-id", // This would cause a 404 in mode with state management - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1 - }), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - // Should still get 200 OK, not 404 Not Found - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.not.objectContaining({ - "mcp-session-id": expect.anything(), - }) - ); - }); - - it("should handle POST requests without session validation in mode without state management", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "non-existent-session-id", // This would be rejected in mode with state management - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transportWithoutState.onmessage = onMessageMock; - - await transportWithoutState.handleRequest(req, mockResponse); - - // Message should be processed despite invalid session ID - expect(onMessageMock).toHaveBeenCalledWith(message); - }); - - it("should work with a mix of requests with and without session IDs in mode without state management", async () => { - // First request without session ID - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-id" - }) - }); - - await transportWithoutState.handleRequest(req1, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - - // Second request with a session ID (which would be invalid in mode with state management) - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": "some-random-session-id", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "test-id-2" - }) - }); - - await transportWithoutState.handleRequest(req2, mockResponse); - - // Should still succeed - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle initialization in mode without state management", async () => { - const transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - - // Initialize message - const initializeMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(false); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - const newResponse = createMockResponse(); - await transportWithoutState.handleRequest(req, newResponse); - - // After initialization, the sessionId should still be undefined - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(true); - - // Headers should NOT include session ID in mode without state management - const headers = newResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); + ], + }, + id: "call-1", }); }); - describe("Request Handling", () => { - // Initialize the transport before tests that need initialization - beforeEach(async () => { - // For tests that need initialization, initialize here - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should reject requests without a valid session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request/); + expect(errorData.id).toBeNull(); + }); - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); + it("should reject invalid session ID", async () => { + // First initialize to be in valid state + await initializeServer(); + + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, "invalid-session-id"); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); + + it("should establish standalone SSE stream and receive server-initiated messages", async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should accept GET requests for SSE with proper Accept header", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); - await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "mcp-session-id": transport.sessionId, - })); - }); + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, + }; - it("should reject GET requests without Accept: text/event-stream header", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "application/json", - "mcp-session-id": transport.sessionId, - }, - }); + // Send the notification via transport + await transport.send(notification); - await transport.handleRequest(req, mockResponse); + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Not Acceptable: Client must accept text/event-stream"')); - }); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); - it("should send server-initiated requests to GET SSE stream", async () => { - // Open a standalone SSE stream with GET - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse = createMockResponse(); - await transport.handleRequest(req, sseResponse); - - // Send a notification without a related request ID - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "notifications/resources/updated", - params: { uri: "someuri" } - }; - - await transport.send(notification); - - // Verify notification was sent on SSE stream - expect(sseResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(notification)}\n\n`) - ); - }); - - it("should not close GET SSE stream after sending server requests or notifications", async () => { - // Open a standalone SSE stream - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse = createMockResponse(); - await transport.handleRequest(req, sseResponse); - - // Send multiple notifications - const notification1: JSONRPCMessage = { jsonrpc: "2.0", method: "event1", params: {} }; - const notification2: JSONRPCMessage = { jsonrpc: "2.0", method: "event2", params: {} }; - - await transport.send(notification1); - await transport.send(notification2); - - // Stream should remain open - expect(sseResponse.end).not.toHaveBeenCalled(); - }); - - it("should reject second GET SSE stream for the same session", async () => { - // Open first SSE stream - should succeed - const req1 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse1 = createMockResponse(); - await transport.handleRequest(req1, sseResponse1); - - // Try to open a second SSE stream - should be rejected - const req2 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse2 = createMockResponse(); - await transport.handleRequest(req2, sseResponse2); - - // First stream should be good - expect(sseResponse1.writeHead).toHaveBeenCalledWith(200, expect.anything()); - - // Second stream should get 409 Conflict - expect(sseResponse2.writeHead).toHaveBeenCalledWith(409); - expect(sseResponse2.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Conflict: Only one SSE stream is allowed per session"')); - }); - - it("should reject POST requests without proper Accept header", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); - }); - - it("should properly handle JSON-RPC request messages in POST requests", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should properly handle JSON-RPC notification or response messages in POST requests", async () => { - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(notification), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(notification); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch notification messages properly with 202 response", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {} }, - { jsonrpc: "2.0", method: "test2", params: {} }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch request messages with SSE when Accept header includes text/event-stream", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "test2", params: {}, id: "req2" }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "text/event-stream, application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - mockResponse = createMockResponse(); // Create fresh mock - await transport.handleRequest(req, mockResponse); - - // Should establish SSE connection - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream" - }) - ); - expect(onMessageMock).toHaveBeenCalledTimes(2); - // Stream should remain open until responses are sent - expect(mockResponse.end).not.toHaveBeenCalled(); - }); - - it("should reject unsupported Content-Type", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "text/plain", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: "test", - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(415); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - }); - - it("should properly handle DELETE requests and close session", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with proper session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": transport.sessionId, - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(200); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("should reject DELETE requests with invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with invalid session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": "invalid-session-id", - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(onCloseMock).not.toHaveBeenCalled(); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, }); }); - describe("SSE Response Handling", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should not close GET SSE stream after sending multiple server notifications", async () => { + sessionId = await initializeServer(); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - const initResponse = createMockResponse(); - await transport.handleRequest(initReq, initResponse); - mockResponse.writeHead.mockClear(); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should send response messages as SSE events", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" } + }; - await transport.handleRequest(req, mockResponse); + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); - // Send a response to the request - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("First notification"); + expect(done).toBe(false); // Stream should still be open + }); - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); + it("should reject second SSE stream for the same session", async () => { + sessionId = await initializeServer(); - // Verify response was sent as SSE event - expect(mockResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(responseMessage)}\n\n`) - ); - - // Stream should be closed after sending response - expect(mockResponse.end).toHaveBeenCalled(); + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when sending intermediate notifications and requests", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(firstStream.status).toBe(200); - // Create fresh response for this test - mockResponse = createMockResponse(); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); - - await transport.handleRequest(req, mockResponse); - - // Send an intermediate notification - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "progress", - params: { progress: "50%" } - }; - - await transport.send(notification, { relatedRequestId: "test-req-id" }); - - // Stream should remain open - expect(mockResponse.end).not.toHaveBeenCalled(); - - // Send the final response - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; - - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); - - // Now stream should be closed - expect(mockResponse.end).toHaveBeenCalled(); + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when multiple requests share the same connection", async () => { - // Create a fresh response for this test - const sharedResponse = createMockResponse(); + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); + }); - // Send two requests in a batch that will share the same connection - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "method1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "method2", params: {}, id: "req2" } - ]; + it("should reject GET requests without Accept: text/event-stream header", async () => { + sessionId = await initializeServer(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(batchRequests) - }); - - await transport.handleRequest(req, sharedResponse); - - // Respond to first request - const response1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result1" }, - id: "req1" - }; - - await transport.send(response1); - - // Connection should remain open because req2 is still pending - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response1)}\n\n`) - ); - expect(sharedResponse.end).not.toHaveBeenCalled(); - - // Respond to second request - const response2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result2" }, - id: "req2" - }; - - await transport.send(response2); - - // Now connection should close as all requests are complete - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response2)}\n\n`) - ); - expect(sharedResponse.end).toHaveBeenCalled(); + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "application/json", + "mcp-session-id": sessionId, + }, }); - it("should clean up connection tracking when a response is sent", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "cleanup-test" - }) - }); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); + }); - const response = createMockResponse(); - await transport.handleRequest(req, response); + it("should reject POST requests without proper Accept header", async () => { + sessionId = await initializeServer(); - // Verify that the request is tracked in the SSE map - expect(transport["_responseMapping"].size).toBe(2); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(true); - - // Send a response - await transport.send({ - jsonrpc: "2.0", - result: {}, - id: "cleanup-test" - }); - - // Verify that the mapping was cleaned up - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(false); + // Try POST without Accept: text/event-stream + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", // Missing text/event-stream + "mcp-session-id": sessionId, + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), }); - it("should clean up connection tracking when client disconnects", async () => { - // Setup two requests that share a connection - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify([ - { jsonrpc: "2.0", method: "longRunning1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "longRunning2", params: {}, id: "req2" } + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); + }); + + it("should reject unsupported Content-Type", async () => { + sessionId = await initializeServer(); + + // Try POST with text/plain Content-Type + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is plain text", + }); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); + }); + + it("should handle JSON-RPC batch notification messages with 202 response", async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "someNotification1", params: {} }, + { jsonrpc: "2.0", method: "someNotification2", params: {} }, + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); + }); + + it("should handle batch request messages with SSE stream for responses", async () => { + sessionId = await initializeServer(); + + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "BatchUser" } }, id: "req-2" }, + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it("should properly handle invalid JSON data", async () => { + sessionId = await initializeServer(); + + // Send invalid JSON + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is not valid JSON", + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32700, /Parse error/); + }); + + it("should return 400 error for invalid JSON-RPC messages", async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: "tools/list", params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: "2.0", + error: expect.anything(), + }); + }); + + it("should reject requests to uninitialized server", async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "uninitialized-test", + }; + + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, "any-session-id"); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); + }); + + it("should send response messages to the connection that sent the request", async () => { + sessionId = await initializeServer(); + + const message1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "req-1" + }; + + const message2: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { name: "Connection2" } + }, + id: "req-2" + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it("should keep stream open after sending server notifications", async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + // Send several server-initiated notifications + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" }, + }); + + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Second notification" }, + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it("should properly handle DELETE requests and close session", async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { "mcp-session-id": tempSessionId || "" }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + await new Promise((resolve) => tempServer.close(() => resolve())); + }); + + it("should reject DELETE requests with invalid session ID", async () => { + // Initialize the server first to activate it + sessionId = await initializeServer(); + + // Try to delete with invalid session ID + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { "mcp-session-id": "invalid-session-id" }, + }); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); +}); + +// Test JSON Response Mode +describe("StreamableHTTPServerTransport with JSON Response Mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer({ enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + sessionId = initResponse.headers.get("mcp-session-id") as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should return JSON response for a single request", async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "json-req-1", + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) ]) - }); + }), + id: "json-req-1" + }); + }); - const response = createMockResponse(); + it("should return JSON response for batch requests", async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "JSON" } }, id: "batch-2" } + ]; - // We need to manually store the callback to trigger it later - let closeCallback: (() => void) | undefined; - response.on.mockImplementation((event, callback: () => void) => { - if (typeof event === "string" && event === "close") { - closeCallback = callback; + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const results = await response.json(); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: string }) => r.id === "batch-1"); + const callResponse = results.find((r: { id?: string }) => r.id === "batch-2"); + + expect(listResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-1", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) + ]) + }) + })); + + expect(callResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-2", + result: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "Hello, JSON!" }) + ]) + }) + })); + }); +}); + +// Test pre-parsed body handling +describe("StreamableHTTPServerTransport with pre-parsed body", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody !== null) { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); } - return response; - }); - - await transport.handleRequest(req, response); - - // Both requests should be mapped to the same response - expect(transport["_responseMapping"].size).toBe(3); - expect(transport["_responseMapping"].get("req1")).toBe(response); - expect(transport["_responseMapping"].get("req2")).toBe(response); - - // Simulate client disconnect by triggering the stored callback - if (closeCallback) closeCallback(); - - // All entries using this response should be removed - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("req1")).toBe(false); - expect(transport["_responseMapping"].has("req2")).toBe(false); + } }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; }); - describe("Message Targeting", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should send response messages to the connection that sent the request", async () => { - // Create request with two separate connections - const requestMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test1", - params: {}, - id: "req-id-1", - }; - - const mockResponse1 = createMockResponse(); - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage1), - }); - await transport.handleRequest(req1, mockResponse1); - - const requestMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "req-id-2", - }; - - const mockResponse2 = createMockResponse(); - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage2), - }); - await transport.handleRequest(req2, mockResponse2); - - // Send responses with matching IDs - const responseMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-1", - }; - - await transport.send(responseMessage1, { relatedRequestId: "req-id-1" }); - - const responseMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-2", - }; - - await transport.send(responseMessage2, { relatedRequestId: "req-id-2" }); - - // Verify responses were sent to the right connections - expect(mockResponse1.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage1)) - ); - - expect(mockResponse2.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage2)) - ); - - // Verify responses were not sent to the wrong connections - const resp1HasResp2 = mockResponse1.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage2)) - ); - expect(resp1HasResp2).toBe(false); - - const resp2HasResp1 = mockResponse2.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage1)) - ); - expect(resp2HasResp1).toBe(false); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); }); - describe("Error Handling", () => { - it("should return 400 error for invalid JSON data", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: "invalid json", - }); + it("should accept pre-parsed request body", async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-1", + }; - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"code":-32700')); - expect(onErrorMock).toHaveBeenCalled(); + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + // Empty body - we're testing pre-parsed body + body: "" }); - it("should return 400 error for invalid JSON-RPC messages", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify({ invalid: "message" }), - }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(onErrorMock).toHaveBeenCalled(); - }); + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); }); - describe("JSON Response Mode", () => { - let jsonResponseTransport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; + it("should handle pre-parsed batch messages", async () => { + parsedBody = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "PreParsed" } }, id: "batch-2" } + ]; - beforeEach(async () => { - jsonResponseTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - }); - - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - mockResponse = createMockResponse(); - await jsonResponseTransport.handleRequest(initReq, mockResponse); - mockResponse = createMockResponse(); // Reset for tests + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "" // Empty as we're using pre-parsed }); - it("should return JSON response for a single request", async () => { - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "tools/list", - params: {}, - id: "test-req-id", - }; + expect(response.status).toBe(200); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(requestMessage), - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Mock immediate response - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `test-result` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); - } - }; - - await jsonResponseTransport.handleRequest(req, mockResponse); - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Should return the response as JSON - const expectedResponse = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id", - }; - - expect(mockResponse.end).toHaveBeenCalledWith(JSON.stringify(expectedResponse)); - }); - - it("should return JSON response for batch requests", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "tools/call", params: {}, id: "req2" }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - // Mock responses without enforcing specific order - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `result-for-${message.id}` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); - } - }; - - await jsonResponseTransport.handleRequest(req, mockResponse); - - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Verify response was sent but don't assume specific order - expect(mockResponse.end).toHaveBeenCalled(); - const responseJson = JSON.parse(mockResponse.end.mock.calls[0][0] as string); - expect(Array.isArray(responseJson)).toBe(true); - expect(responseJson).toHaveLength(2); - - // Check each response exists separately without assuming order - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req1", result: { value: "result-for-req1" } })); - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req2", result: { value: "result-for-req2" } })); - }); + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); }); - describe("Handling Pre-Parsed Body", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { + it("should prefer pre-parsed body over request body", async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-wins", + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); + method: "tools/call", + params: { name: "greet", arguments: { name: "Ignored" } }, + id: "ignored-id" + }) }); - it("should accept pre-parsed request body", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "pre-parsed-test", - }; + expect(response.status).toBe(200); - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, message); - - // Verify the message was processed correctly - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle pre-parsed batch messages", async () => { - const batchMessages: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "method1", - params: { data: "test1" }, - id: "batch1" - }, - { - jsonrpc: "2.0", - method: "method2", - params: { data: "test2" }, - id: "batch2" - }, - ]; - - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, batchMessages); - - // Should be called for each message in the batch - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[0]); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[1]); - }); - - it("should prefer pre-parsed body over request body", async () => { - const requestBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromRequestBody", - params: {}, - id: "request-body", - }; - - const parsedBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromParsedBody", - params: {}, - id: "parsed-body", - }; - - // Create a request with actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(requestBodyMessage), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, parsedBodyMessage); - - // Should use the parsed body instead of the request body - expect(onMessageMock).toHaveBeenCalledWith(parsedBodyMessage); - expect(onMessageMock).not.toHaveBeenCalledWith(requestBodyMessage); - }); + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); }); -}); \ No newline at end of file +}); + +// Test stateless mode +describe("StreamableHTTPServerTransport in stateless mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: () => undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should operate without session ID validation", async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get("mcp-session-id")).toBeNull(); + + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(200); + }); + + it("should handle POST requests with various session IDs in stateless mode", async () => { + // Initialize the server first + await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" + }), + }); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "random-id-1", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t1" }), + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "different-id-2", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t2" }), + }); + expect(response2.status).toBe(200); + }); + + it("should reject second SSE stream even in stateless mode", async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" + }), + }); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream1.status).toBe(200); + + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed + }); +}); \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index ec8d2aa7..0eaaa673 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -438,6 +438,8 @@ export class StreamableHTTPServerTransport implements Transport { // Clear any pending responses this._requestResponseMap.clear(); + this._standaloneSSE?.end(); + this._standaloneSSE = undefined; this.onclose?.(); } From 055fe61d9381594fdfec32d49040cccd00c11f8d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 20:52:27 +0100 Subject: [PATCH 017/103] close server --- src/server/streamableHttp.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index dda69331..949cafc7 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -72,8 +72,11 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ * Helper to stop test server */ async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + // First close the transport to ensure all SSE streams are closed await transport.close(); - await new Promise((resolve) => server.close(() => resolve())); + + // Close the server without waiting indefinitely + server.close(); } /** @@ -649,8 +652,8 @@ describe("StreamableHTTPServerTransport", () => { expect(deleteResponse.status).toBe(200); - // Clean up - await new Promise((resolve) => tempServer.close(() => resolve())); + // Clean up - don't wait indefinitely for server close + tempServer.close(); }); it("should reject DELETE requests with invalid session ID", async () => { From c4381b857be73e7f5f87771ff2daf6e2e47b0ff9 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:38:55 -0700 Subject: [PATCH 018/103] Pass extra object instead of authInfo directly --- src/inMemory.test.ts | 6 +++--- src/inMemory.ts | 16 ++++++---------- src/server/sse.ts | 8 ++++---- src/shared/protocol.ts | 15 ++++++++------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index b6906cc8..baf43446 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -52,12 +52,12 @@ describe("InMemoryTransport", () => { let receivedMessage: JSONRPCMessage | undefined; let receivedAuthInfo: AuthInfo | undefined; - serverTransport.onmessage = (msg, auth) => { + serverTransport.onmessage = (msg, extra) => { receivedMessage = msg; - receivedAuthInfo = auth; + receivedAuthInfo = extra?.authInfo; }; - await clientTransport.sendWithAuth(message, authInfo); + await clientTransport.send(message, { authInfo }); expect(receivedMessage).toEqual(message); expect(receivedAuthInfo).toEqual(authInfo); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 8889fdd8..558ca7ad 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -4,7 +4,7 @@ import { AuthInfo } from "./server/auth/types.js"; interface QueuedMessage { message: JSONRPCMessage; - authInfo?: AuthInfo; + extra?: { authInfo?: AuthInfo }; } /** @@ -16,7 +16,7 @@ export class InMemoryTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; sessionId?: string; /** @@ -34,7 +34,7 @@ export class InMemoryTransport implements Transport { // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { const queuedMessage = this._messageQueue.shift()!; - this.onmessage?.(queuedMessage.message, queuedMessage.authInfo); + this.onmessage?.(queuedMessage.message, queuedMessage.extra); } } @@ -45,23 +45,19 @@ export class InMemoryTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage): Promise { - return this.sendWithAuth(message); - } - /** * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise + async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message, authInfo); + this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); } else { - this._otherTransport._messageQueue.push({ message, authInfo }); + this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); } } } diff --git a/src/server/sse.ts b/src/server/sse.ts index 848043ba..ae993dd7 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -19,7 +19,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -96,7 +96,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, authInfo); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -108,7 +108,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, authInfo?: AuthInfo): Promise { + async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -117,7 +117,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage, authInfo); + this.onmessage?.(parsedMessage, extra); } async close(): Promise { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 789f5a7e..a39eba68 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -246,11 +246,11 @@ export abstract class Protocol< this._onerror(error); }; - this._transport.onmessage = (message, authInfo) => { + this._transport.onmessage = (message, extra) => { if (!("method" in message)) { this._onresponse(message); } else if ("id" in message) { - this._onrequest(message, authInfo); + this._onrequest(message, extra); } else { this._onnotification(message); } @@ -296,7 +296,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest, authInfo?: AuthInfo): void { + private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -321,16 +321,17 @@ export abstract class Protocol< const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); - // Create extra object with both abort signal and sessionId from transport - const extra: RequestHandlerExtra = { + // Create fullExtra object with both abort signal and sessionId from transport + // in addition to any authInfo provided + const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, - authInfo, + authInfo: extra?.authInfo, }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(request, extra)) + .then(() => handler(request, fullExtra)) .then( (result) => { if (abortController.signal.aborted) { From b0fdf0030bf224dd449766291b5ae1456a56f2d9 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:45:05 -0700 Subject: [PATCH 019/103] Update inMemory.ts --- src/inMemory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/inMemory.ts b/src/inMemory.ts index 558ca7ad..5dd6e81e 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -1,5 +1,5 @@ import { Transport } from "./shared/transport.js"; -import { JSONRPCMessage } from "./types.js"; +import { JSONRPCMessage, RequestId } from "./types.js"; import { AuthInfo } from "./server/auth/types.js"; interface QueuedMessage { @@ -49,7 +49,7 @@ export class InMemoryTransport implements Transport { * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise { + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } From 85e6457b842cb28c122e80bd4c7ecbc35a2a018f Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:47:18 -0700 Subject: [PATCH 020/103] Update protocol.ts --- src/shared/protocol.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 39483b77..91fa8366 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -357,8 +357,6 @@ export abstract class Protocol< const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); - // Create `fullExtra` object with both abort signal and sessionId from transport - // in addition to any authInfo provided const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, From 23188ec44650007e4ddc716ba218daafb2afebb5 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:06:46 -0700 Subject: [PATCH 021/103] Add custom claims to authinfo --- src/server/auth/types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 93c5a493..cd1430f1 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -21,4 +21,9 @@ export interface AuthInfo { * When the token expires (in seconds since epoch). */ expiresAt?: number; + + /** + * Additional arbitrary properties that may be included. + */ + [key: string]: unknown; } \ No newline at end of file From 64fadf3cf21b1fadb8c28d94213a0bcbe0027040 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 08:26:49 +0100 Subject: [PATCH 022/103] resume streams on error - initial implementation --- src/client/streamableHttp.test.ts | 32 ++++-- src/client/streamableHttp.ts | 72 ++++++++---- src/server/streamableHttp.test.ts | 176 ++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 180 ++++++++++++++++++++++++------ 4 files changed, 392 insertions(+), 68 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 1572e912..8d9a6a9c 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -275,17 +275,21 @@ describe("StreamableHTTPClientTransport", () => { })).toBe(true); }); - it("should include last-event-id header when resuming a broken connection", async () => { - // First make a successful connection that provides an event ID + it("should handle reconnection with last-event-id for resumability", async () => { + // Set up a stream that will send an event with ID then error const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; controller.enqueue(encoder.encode(event)); - controller.close(); + // Simulate network error + setTimeout(() => { + controller.error(new Error("Network error")); + }, 10); } }); + // Mock the initial connection (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, @@ -293,23 +297,33 @@ describe("StreamableHTTPClientTransport", () => { body: stream }); + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + await transport.start(); await transport["_startOrAuthStandaloneSSE"](); + + // Let the stream process and error await new Promise(resolve => setTimeout(resolve, 50)); - - // Now simulate attempting to reconnect + + // Verify error was caught + expect(errorSpy).toHaveBeenCalled(); + + // Mock the reconnection (the transport should try to reconnect after error) (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: null }); - - await transport["_startOrAuthStandaloneSSE"](); - - // Check that Last-Event-ID was included + + // Allow time for automatic reconnection + await new Promise(resolve => setTimeout(resolve, 1100)); // > 1 second delay + + // Verify that the client attempted to reconnect with the last-event-id const calls = (global.fetch as jest.Mock).mock.calls; const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("GET"); expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ea69ee77..9b3c7c20 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -49,7 +49,6 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; - private _lastEventId?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -102,16 +101,16 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(): Promise { + private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it const headers = await this._commonHeaders(); headers.set("Accept", "text/event-stream"); - // Include Last-Event-ID header for resumable streams - if (this._lastEventId) { - headers.set("last-event-id", this._lastEventId); + // Include Last-Event-ID header for resumable streams if provided + if (lastEventId) { + headers.set("last-event-id", lastEventId); } const response = await fetch(this._url, { @@ -150,31 +149,61 @@ export class StreamableHTTPClientTransport implements Transport { return; } + let lastEventId: string | undefined; + const processStream = async () => { // Create a pipeline: binary stream -> text decoder -> SSE parser const eventStream = stream .pipeThrough(new TextDecoderStream()) .pipeThrough(new EventSourceParserStream()); - for await (const event of eventStream) { - // Update last event ID if provided - if (event.id) { - this._lastEventId = event.id; - } - // Handle message events (default event type is undefined per docs) - // or explicit 'message' event type - if (!event.event || event.event === "message") { - try { - const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); + try { + for await (const event of eventStream) { + // Update last event ID if provided + if (event.id) { + lastEventId = event.id; } + + // Handle message events (default event type is undefined per docs) + // or explicit 'message' event type + if (!event.event || event.event === "message") { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + } catch (error) { + // Handle stream errors - likely a network disconnect + this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); + + // Attempt to reconnect if the stream disconnects unexpectedly + // Wait a short time before reconnecting to avoid rapid reconnection loops + if (this._abortController && !this._abortController.signal.aborted) { + setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); + }); + }, 1000); // 1 second delay before reconnection attempt } } }; - processStream().catch(err => this.onerror?.(err)); + processStream().catch(err => { + this.onerror?.(err); + + // Try to reconnect on unexpected errors + if (this._abortController && !this._abortController.signal.aborted) { + setTimeout(() => { + this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); + }); + }, 1000); + } + }); } async start() { @@ -252,7 +281,7 @@ export class StreamableHTTPClientTransport implements Transport { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { - // We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE + // Start without a lastEventId since this is a fresh connection this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); } return; @@ -268,6 +297,9 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { + // Handle SSE stream responses for requests + // We use the same handler as standalone streams, which now supports + // reconnection with the last event ID this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 949cafc7..a03a457d 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,7 +1,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -import { StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { EventStore, StreamableHTTPServerTransport } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; @@ -13,6 +13,7 @@ interface TestServerConfig { sessionIdGenerator?: () => string | undefined; enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; + eventStore?: EventStore; } /** @@ -26,7 +27,7 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ }> { const mcpServer = new McpServer( { name: "test-server", version: "1.0.0" }, - { capabilities: {} } + { capabilities: { logging: {} } } ); mcpServer.tool( @@ -40,7 +41,8 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator ?? (() => randomUUID()), - enableJsonResponse: config.enableJsonResponse ?? false + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore }); await mcpServer.connect(transport); @@ -89,7 +91,10 @@ const TEST_MESSAGES = { params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26", + capabilities: { + }, }, + id: "init-1", } as JSONRPCMessage, @@ -896,6 +901,171 @@ describe("StreamableHTTPServerTransport with pre-parsed body", () => { }); }); +// Test resumability support +describe("StreamableHTTPServerTransport with resumability", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); + + // Simple implementation of EventStore + const eventStore: EventStore = { + generateEventId(streamId: string): string { + return `${streamId}_${randomUUID()}`; + }, + getStreamIdFromEventId(eventId: string): string { + return eventId.split('_')[0]; // Extract stream ID from the event ID + }, + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + async getEventsAfter(lastEventId: string): Promise> { + const streamId = lastEventId.split('_')[0]; // Extract stream ID from the event ID + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + // This avoids issues with event ordering in tests + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + result.push({ eventId, message }); + } + } + + return result; + }, + }; + + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Verify resumability is enabled on the transport + expect((transport)['_eventStore']).toBeDefined(); + + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); + + it("should store and include event IDs in server SSE messages", async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); + + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification with event ID" }, + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('standalonesse')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); + + + it("should store and replay MCP server tool notifications", async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + expect(sseResponse.status).toBe(200); // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: "info", data: "First notification from MCP server" }); + + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]; + + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: "info", data: "Second notification from MCP server" }); + + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); + + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "last-event-id": firstEventId + }, + }); + + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); + + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); + }); +}); + // Test stateless mode describe("StreamableHTTPServerTransport in stateless mode", () => { let server: Server; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0eaaa673..0474fc21 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,47 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { randomUUID } from "node:crypto"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +/** + * Interface for resumability support via event storage + */ +export interface EventStore { + /** + * Generates a unique event ID for a given stream ID + * @param streamId The stream ID to include in the event ID + * @returns A unique event ID that includes the stream ID + */ + generateEventId(streamId: string): string; + + /** + * Stores an event for later retrieval + * @param streamId ID of the stream the event belongs to + * @param message The JSON-RPC message to store + * @returns The generated event ID for the stored event + */ + storeEvent(streamId: string, message: JSONRPCMessage): Promise; + + /** + * Retrieves events for a stream starting from a given event ID + * @param lastEventId The event ID to start from + * @returns Array of stored events with their event IDs + */ + getEventsAfter(lastEventId: string): Promise>; + + /** + * Extracts the stream ID from an event ID + * This is necessary for resumability to identify which stream the event belongs to + * @param eventId The event ID to extract stream ID from + * @returns The stream ID portion of the event ID + */ + getStreamIdFromEventId(eventId: string): string; +} + /** * Configuration options for StreamableHTTPServerTransport */ @@ -24,6 +60,12 @@ export interface StreamableHTTPServerTransportOptions { * Default is false (SSE streams are preferred). */ enableJsonResponse?: boolean; + + /** + * Event store for resumability support + * If provided, resumability will be enabled, allowing clients to reconnect and resume messages + */ + eventStore?: EventStore; } /** @@ -64,12 +106,13 @@ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private sessionIdGenerator: () => string | undefined; private _started: boolean = false; - private _responseMapping: Map = new Map(); + private _streamMapping: Map = new Map(); + private _requestToStreamMapping: Map = new Map(); private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _standaloneSSE: ServerResponse | undefined; - + private _standaloneSseStreamId: string = 'standalonesse'; + private _eventStore?: EventStore; sessionId?: string | undefined; onclose?: () => void; @@ -79,6 +122,7 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; + this._eventStore = options.eventStore; } /** @@ -131,6 +175,14 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + // Handle resumability: check for Last-Event-ID header + if (this._eventStore) { + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + await this.replayEvents(lastEventId, res); + return; + } + } // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, // or else return HTTP 405 Method Not Allowed @@ -144,12 +196,9 @@ export class StreamableHTTPServerTransport implements Transport { if (this.sessionId !== undefined) { headers["mcp-session-id"] = this.sessionId; } - // The server MAY include a Last-Event-ID header in the response to this HTTP GET. - // Resumability will be supported in the future // Check if there's already an active standalone SSE stream for this session - - if (this._standaloneSSE !== undefined) { + if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { // Only one GET SSE stream is allowed per session res.writeHead(409).end(JSON.stringify({ jsonrpc: "2.0", @@ -161,19 +210,62 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - // We need to send headers immediately as message will arrive much later, + + // We need to send headers immediately as messages will arrive much later, // otherwise the client will just wait for the first message res.writeHead(200, headers).flushHeaders(); - // Assing the response to the standalone SSE stream - this._standaloneSSE = res; + // Assign the response to the standalone SSE stream + this._streamMapping.set(this._standaloneSseStreamId, res); // Set up close handler for client disconnects res.on("close", () => { - this._standaloneSSE = undefined; + this._streamMapping.delete(this._standaloneSseStreamId); }); } + /** + * Replays events that would have been sent after the specified event ID + * Only used when resumability is enabled + */ + private async replayEvents(lastEventId: string, res: ServerResponse): Promise { + if (!this._eventStore) { + return; + } + + try { + const events = await this._eventStore.getEventsAfter(lastEventId); + const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); + + const oldResStream = this._streamMapping.get(streamId); + this._streamMapping.set(streamId, res); + if (oldResStream) { + // If we have an old response stream, close it + oldResStream.end(); + } + + for (const { eventId, message } of events) { + this.writeSSEEvent(res, message, eventId); + } + } catch (error) { + this.onerror?.(error as Error); + } + } + + /** + * Writes an event to the SSE stream with proper formatting + */ + private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean { + let eventData = `event: message\n`; + // Include event ID if provided - this is important for resumability + if (eventId) { + eventData += `id: ${eventId}\n`; + } + eventData += `data: ${JSON.stringify(message)}\n\n`; + + return res.write(eventData); + } + /** * Handles unsupported requests (PUT, PATCH, etc.) */ @@ -285,9 +377,9 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests - const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); + const hasRequests = messages.some(isJSONRPCRequest); const hasOnlyNotificationsOrResponses = messages.every(msg => - ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); + isJSONRPCNotification(msg) || isJSONRPCResponse(msg)); if (hasOnlyNotificationsOrResponses) { // if it only contains notifications or responses, return 202 @@ -300,6 +392,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (hasRequests) { // The default behavior is to use SSE streaming // but in some cases server will return JSON responses + const streamId = randomUUID(); if (!this._enableJsonResponse) { const headers: Record = { "Content-Type": "text/event-stream", @@ -318,19 +411,22 @@ export class StreamableHTTPServerTransport implements Transport { // We need to track by request ID to maintain the connection for (const message of messages) { if ('method' in message && 'id' in message) { - this._responseMapping.set(message.id, res); + this._streamMapping.set(streamId, res); + this._requestToStreamMapping.set(message.id, streamId); } } // Set up close handler for client disconnects res.on("close", () => { + // find a stream ID for this response // Remove all entries that reference this response - for (const [id, storedRes] of this._responseMapping.entries()) { - if (storedRes === res) { - this._responseMapping.delete(id); + for (const [id, stream] of this._requestToStreamMapping.entries()) { + if (streamId === stream) { + this._requestToStreamMapping.delete(id); this._requestResponseMap.delete(id); } } + this._streamMapping.delete(streamId); }); // handle each message @@ -431,16 +527,13 @@ export class StreamableHTTPServerTransport implements Transport { async close(): Promise { // Close all SSE connections - this._responseMapping.forEach((response) => { + this._streamMapping.forEach((response) => { response.end(); }); - this._responseMapping.clear(); + this._streamMapping.clear(); // Clear any pending responses this._requestResponseMap.clear(); - this._standaloneSSE?.end(); - this._standaloneSSE = undefined; - this.onclose?.(); } @@ -459,32 +552,47 @@ export class StreamableHTTPServerTransport implements Transport { if ('result' in message || 'error' in message) { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } - - if (this._standaloneSSE === undefined) { + const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId) + if (standaloneSse === undefined) { // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream return; } + // Generate and store event ID if event store is provided + let eventId: string | undefined; + if (this._eventStore) { + // Stores the event and gets the generated event ID + eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); + } + // Send the message to the standalone SSE stream - this._standaloneSSE.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + this.writeSSEEvent(standaloneSse, message, eventId); return; } // Get the response for this request - const response = this._responseMapping.get(requestId); - if (!response) { + const streamId = this._requestToStreamMapping.get(requestId); + const response = this._streamMapping.get(streamId!); + if (!streamId || !response) { throw new Error(`No connection established for request ID: ${String(requestId)}`); } if (!this._enableJsonResponse) { - response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); - } - if ('result' in message || 'error' in message) { - this._requestResponseMap.set(requestId, message); + // For SSE responses, generate event ID if event store is provided + let eventId: string | undefined; - // Get all request IDs that share the same request response object - const relatedIds = Array.from(this._responseMapping.entries()) - .filter(([_, res]) => res === response) + if (this._eventStore) { + eventId = await this._eventStore.storeEvent(streamId, message); + } + + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } + + if (isJSONRPCResponse(message)) { + this._requestResponseMap.set(requestId, message); + const relatedIds = Array.from(this._requestToStreamMapping.entries()) + .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) .map(([id]) => id); // Check if we have responses for all requests using this connection @@ -516,7 +624,7 @@ export class StreamableHTTPServerTransport implements Transport { // Clean up for (const id of relatedIds) { this._requestResponseMap.delete(id); - this._responseMapping.delete(id); + this._requestToStreamMapping.delete(id); } } } From 3bd28829a5909cfe703827be3f6901cf4c19d34c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 08:28:09 +0100 Subject: [PATCH 023/103] examples --- src/examples/client/simpleStreamableHttp.ts | 498 ++++++++++++++++---- src/examples/server/simpleStreamableHttp.ts | 118 ++++- 2 files changed, 512 insertions(+), 104 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 739e1164..42ffa28e 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -1,5 +1,6 @@ import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; import { ListToolsRequest, ListToolsResultSchema, @@ -15,139 +16,434 @@ import { ResourceListChangedNotificationSchema, } from '../../types.js'; +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Track received notifications for debugging resumability +let notificationCount = 0; + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; + async function main(): Promise { - // Create a new client with streamable HTTP transport - const client = new Client({ - name: 'example-client', - version: '1.0.0' - }); + console.log('MCP Interactive Client'); + console.log('====================='); - const transport = new StreamableHTTPClientTransport( - new URL('http://localhost:3000/mcp') - ); + // Connect to server immediately with default settings + await connect(); - // Connect the client using the transport and initialize the server - await client.connect(transport); - console.log('Connected to MCP server'); - - // Set up notification handlers for server-initiated messages - client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { - console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); - }); - client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { - console.log(`Resource list changed notification received!`); - const resourcesRequest: ListResourcesRequest = { - method: 'resources/list', - params: {} - }; - const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources count:', resourcesResult.resources.length); - }); - - // List and call tools - await listTools(client); - - await callGreetTool(client); - await callMultiGreetTool(client); - - - // List available prompts - try { - const promptsRequest: ListPromptsRequest = { - method: 'prompts/list', - params: {} - }; - const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); - console.log('Available prompts:', promptsResult.prompts); - } catch (error) { - console.log(`Prompts not supported by this server (${error})`); - } - - // Get a prompt - try { - const promptRequest: GetPromptRequest = { - method: 'prompts/get', - params: { - name: 'greeting-template', - arguments: { name: 'MCP User' } - } - }; - const promptResult = await client.request(promptRequest, GetPromptResultSchema); - console.log('Prompt template:', promptResult.messages[0].content.text); - } catch (error) { - console.log(`Prompt retrieval not supported by this server (${error})`); - } - - // List available resources - try { - const resourcesRequest: ListResourcesRequest = { - method: 'resources/list', - params: {} - }; - const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources:', resourcesResult.resources); - } catch (error) { - console.log(`Resources not supported by this server (${error})`); - } - // Keep the connection open to receive notifications - console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); + // Print help and start the command loop + printHelp(); + commandLoop(); } -async function listTools(client: Client): Promise { +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' greet [name] - Call the greet tool'); + console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' start-notifications [interval] [count] - Start periodic notifications'); + console.log(' simulate-disconnect [delay] - Simulate network disconnection after delay seconds'); + console.log(' list-prompts - List available prompts'); + console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); + console.log(' list-resources - List available resources'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +function commandLoop(): void { + readline.question('\n> ', async (input) => { + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': + await connect(args[1]); + break; + + case 'disconnect': + await disconnect(); + break; + + case 'reconnect': + await reconnect(); + break; + + case 'list-tools': + await listTools(); + break; + + case 'call-tool': + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + + case 'greet': + await callGreetTool(args[1] || 'MCP User'); + break; + + case 'multi-greet': + await callMultiGreetTool(args[1] || 'MCP User'); + break; + + case 'start-notifications': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 0; + await startNotifications(interval, count); + break; + } + + case 'simulate-disconnect': { + const delay = args[1] ? parseInt(args[1], 10) : 5; + simulateDisconnect(delay); + break; + } + + case 'list-prompts': + await listPrompts(); + break; + + case 'get-prompt': + if (args.length < 2) { + console.log('Usage: get-prompt [args]'); + } else { + const promptName = args[1]; + let promptArgs = {}; + if (args.length > 2) { + try { + promptArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await getPrompt(promptName, promptArgs); + } + break; + + case 'list-resources': + await listResources(); + break; + + case 'help': + printHelp(); + break; + + case 'quit': + case 'exit': + await cleanup(); + return; + + default: + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } + + // Continue the command loop + commandLoop(); + }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`Connecting to ${serverUrl}...`); + + try { + // Create a new client + client = new Client({ + name: 'example-client', + version: '1.0.0' + }); + + transport = new StreamableHTTPClientTransport( + new URL(serverUrl) + ); + + // Set up notification handlers + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + notificationCount++; + console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); + // Re-display the prompt + process.stdout.write('> '); + }); + + client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { + console.log(`\nResource list changed notification received!`); + try { + if (!client) { + console.log('Client disconnected, cannot fetch resources'); + return; + } + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + console.log('Available resources count:', resourcesResult.resources.length); + } catch { + console.log('Failed to list resources after change notification'); + } + // Re-display the prompt + process.stdout.write('> '); + }); + + // Connect the client + await client.connect(transport); + console.log('Connected to MCP server'); + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + try { const toolsRequest: ListToolsRequest = { method: 'tools/list', params: {} }; const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); + + console.log('Available tools:'); if (toolsResult.tools.length === 0) { - console.log('No tools available from the server'); + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } } } catch (error) { console.log(`Tools not supported by this server (${error})`); - return } } -async function callGreetTool(client: Client): Promise { - try { - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); - } catch (error) { - console.log(`Error calling greet tool: ${error}`); +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; } -} -async function callMultiGreetTool(client: Client): Promise { try { - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { + const request: CallToolRequest = { method: 'tools/call', params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } + name, + arguments: args } }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { + + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { if (item.type === 'text') { - console.log(`- ${item.text}`); + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); } }); } catch (error) { - console.log(`Error calling multi-greet tool: ${error}`); + console.log(`Error calling tool ${name}: ${error}`); } } +async function callGreetTool(name: string): Promise { + await callTool('greet', { name }); +} + +async function callMultiGreetTool(name: string): Promise { + console.log('Calling multi-greet tool with notifications...'); + await callTool('multi-greet', { name }); +} + +async function startNotifications(interval: number, count: number): Promise { + console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); + await callTool('start-notification-stream', { interval, count }); +} + +function simulateDisconnect(delaySeconds: number): void { + if (!transport) { + console.log('Not connected.'); + return; + } + + console.log(`Will simulate network disconnect in ${delaySeconds} seconds...`); + setTimeout(async () => { + if (!transport) return; + + console.log('\nSimulating network disconnect...'); + await transport.close(); + console.log('Transport closed. Client should automatically attempt to reconnect...'); + + // Keep the client object but null the transport + // This simulates a network disconnection without explicit user disconnect + transport = null; + + // Re-display the prompt + process.stdout.write('> '); + }, delaySeconds * 1000); +} + +async function listPrompts(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptsRequest: ListPromptsRequest = { + method: 'prompts/list', + params: {} + }; + const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); + console.log('Available prompts:'); + if (promptsResult.prompts.length === 0) { + console.log(' No prompts available'); + } else { + for (const prompt of promptsResult.prompts) { + console.log(` - ${prompt.name}: ${prompt.description}`); + } + } + } catch (error) { + console.log(`Prompts not supported by this server (${error})`); + } +} + +async function getPrompt(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptRequest: GetPromptRequest = { + method: 'prompts/get', + params: { + name, + arguments: args as Record + } + }; + + const promptResult = await client.request(promptRequest, GetPromptResultSchema); + console.log('Prompt template:'); + promptResult.messages.forEach((msg, index) => { + console.log(` [${index + 1}] ${msg.role}: ${msg.content.text}`); + }); + } catch (error) { + console.log(`Error getting prompt ${name}: ${error}`); + } +} + +async function listResources(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); + + console.log('Available resources:'); + if (resourcesResult.resources.length === 0) { + console.log(' No resources available'); + } else { + for (const resource of resourcesResult.resources) { + console.log(` - ${resource.name}: ${resource.uri}`); + } + } + } catch (error) { + console.log(`Resources not supported by this server (${error})`); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + readline.close(); + console.log('\nGoodbye!'); + process.exit(0); +} + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client main().catch((error: unknown) => { console.error('Error running MCP client:', error); process.exit(1); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index f0f74439..a45b3158 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,9 +1,75 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, JSONRPCMessage, ReadResourceResult } from '../../types.js'; + +// Create a simple in-memory EventStore for resumability +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + + /** + * Stores an event with a generated event ID + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + console.log(`Storing event ${eventId} for stream ${streamId}`); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Retrieves events that occurred after a specific event + */ + async getEventsAfter(lastEventId: string): Promise> { + if (!lastEventId || !this.events.has(lastEventId)) { + return []; + } + + // Extract the stream ID from the event ID + const streamId = lastEventId.split('_')[0]; + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start collecting events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + result.push({ eventId, message }); + } + } + + console.log(`Found ${result.length} events after ${lastEventId} for replay`); + return result; + } + +} // Create an MCP server with implementation details const server = new McpServer({ @@ -92,6 +158,43 @@ server.prompt( } ); +// Register a tool specifically for testing resumability +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } +); + // Create a simple resource at a fixed URI server.resource( 'greeting-resource', @@ -127,8 +230,10 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request + const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability }); // Connect the transport to the MCP server BEFORE handling the request @@ -182,7 +287,14 @@ app.get('/mcp', async (req: Request, res: Response) => { return; } - console.log(`Establishing SSE stream for session ${sessionId}`); + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + const transport = transports[sessionId]; await transport.handleRequest(req, res); }); From 6374f433909d9b0e78f33fdc021158943c1acf04 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 15:18:52 +0100 Subject: [PATCH 024/103] limit on retries --- src/client/streamableHttp.test.ts | 123 ++++++++++------ src/client/streamableHttp.ts | 155 ++++++++++++++++---- src/examples/client/simpleStreamableHttp.ts | 33 +---- src/server/streamableHttp.ts | 14 +- 4 files changed, 216 insertions(+), 109 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 8d9a6a9c..a73927ac 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,4 +1,4 @@ -import { StreamableHTTPClientTransport } from "./streamableHttp.js"; +import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; import { JSONRPCMessage } from "../types.js"; @@ -275,59 +275,62 @@ describe("StreamableHTTPClientTransport", () => { })).toBe(true); }); - it("should handle reconnection with last-event-id for resumability", async () => { - // Set up a stream that will send an event with ID then error - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; - controller.enqueue(encoder.encode(event)); - // Simulate network error - setTimeout(() => { - controller.error(new Error("Network error")); - }, 10); + it("should support custom reconnection options", () => { + // Create a transport with custom reconnection options + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 500, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 2, + maxRetries: 5, } }); - // Mock the initial connection - (global.fetch as jest.Mock).mockResolvedValueOnce({ + // Verify options were set correctly (checking implementation details) + // Access private properties for testing + const transportInstance = transport as unknown as { + _defaultReconnectionOptions: StreamableHTTPReconnectionOptions; + }; + expect(transportInstance._defaultReconnectionOptions.initialReconnectionDelay).toBe(1000); + expect(transportInstance._defaultReconnectionOptions.maxRetries).toBe(2); + }); + + it("should pass lastEventId when reconnecting", async () => { + // Create a fresh transport + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + + // Mock fetch to verify headers sent + const fetchSpy = global.fetch as jest.Mock; + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), - body: stream + body: new ReadableStream() }); - const errorSpy = jest.fn(); - transport.onerror = errorSpy; - + // Call the reconnect method directly with a lastEventId await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); - - // Let the stream process and error - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify error was caught - expect(errorSpy).toHaveBeenCalled(); - - // Mock the reconnection (the transport should try to reconnect after error) - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers({ "content-type": "text/event-stream" }), - body: null - }); - - // Allow time for automatic reconnection - await new Promise(resolve => setTimeout(resolve, 1100)); // > 1 second delay - - // Verify that the client attempted to reconnect with the last-event-id - const calls = (global.fetch as jest.Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].method).toBe("GET"); - expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); + // Type assertion to access private method + const transportWithPrivateMethods = transport as unknown as { + _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + }; + await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + + // Verify fetch was called with the lastEventId header + expect(fetchSpy).toHaveBeenCalled(); + const fetchCall = fetchSpy.mock.calls[0]; + const headers = fetchCall[1].headers; + expect(headers.get("last-event-id")).toBe("test-event-id"); }); it("should throw error when invalid content-type is received", async () => { + // Clear any previous state from other tests + jest.clearAllMocks(); + + // Create a fresh transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + const message: JSONRPCMessage = { jsonrpc: "2.0", method: "test", @@ -337,7 +340,7 @@ describe("StreamableHTTPClientTransport", () => { const stream = new ReadableStream({ start(controller) { - controller.enqueue("invalid text response"); + controller.enqueue(new TextEncoder().encode("invalid text response")); controller.close(); } }); @@ -389,4 +392,38 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + + + it("should have exponential backoff with configurable maxRetries", () => { + // This test verifies the maxRetries and backoff calculation directly + + // Create transport with specific options for testing + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3, + } + }); + + // Get access to the internal implementation + const getDelay = transport["_getNextReconnectionDelay"].bind(transport); + + // First retry - should use initial delay + expect(getDelay(0)).toBe(100); + + // Second retry - should double (2^1 * 100 = 200) + expect(getDelay(1)).toBe(200); + + // Third retry - should double again (2^2 * 100 = 400) + expect(getDelay(2)).toBe(400); + + // Fourth retry - should double again (2^3 * 100 = 800) + expect(getDelay(3)).toBe(800); + + // Tenth retry - should be capped at maxReconnectionDelay + expect(getDelay(10)).toBe(5000); + }); + }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9b3c7c20..9451e8d9 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,3 +1,4 @@ +import { log } from "node:console"; import { Transport } from "../shared/transport.js"; import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; @@ -12,6 +13,35 @@ export class StreamableHTTPError extends Error { } } +/** + * Configuration options for reconnection behavior of the StreamableHTTPClientTransport. + */ +export interface StreamableHTTPReconnectionOptions { + /** + * Maximum backoff time between reconnection attempts in milliseconds. + * Default is 30000 (30 seconds). + */ + maxReconnectionDelay: number; + + /** + * Initial backoff time between reconnection attempts in milliseconds. + * Default is 1000 (1 second). + */ + initialReconnectionDelay: number; + + /** + * The factor by which the reconnection delay increases after each attempt. + * Default is 1.5. + */ + reconnectionDelayGrowFactor: number; + + /** + * Maximum number of reconnection attempts before giving up. + * Default is 0 (unlimited). + */ + maxRetries: number; +} + /** * Configuration options for the `StreamableHTTPClientTransport`. */ @@ -36,6 +66,11 @@ export type StreamableHTTPClientTransportOptions = { * Customizes HTTP requests to the server. */ requestInit?: RequestInit; + + /** + * Options to configure the reconnection behavior. + */ + reconnectionOptions?: StreamableHTTPReconnectionOptions; }; /** @@ -49,6 +84,7 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -61,6 +97,7 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; } private async _authThenStart(): Promise { @@ -136,7 +173,7 @@ export class StreamableHTTPClientTransport implements Transport { `Failed to open SSE stream: ${response.statusText}`, ); } - // Successful connection, handle the SSE stream as a standalone listener + this._handleSseStream(response.body); } catch (error) { this.onerror?.(error as Error); @@ -144,28 +181,93 @@ export class StreamableHTTPClientTransport implements Transport { } } + // Default reconnection options + private readonly _defaultReconnectionOptions: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }; + + // We no longer need global reconnection state as it will be maintained per stream + + /** + * Calculates the next reconnection delay using exponential backoff algorithm + * with jitter for more effective reconnections in high load scenarios. + * + * @param attempt Current reconnection attempt count for the specific stream + * @returns Time to wait in milliseconds before next reconnection attempt + */ + private _getNextReconnectionDelay(attempt: number): number { + // Access default values directly, ensuring they're never undefined + const initialDelay = this._reconnectionOptions.initialReconnectionDelay; + const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor; + const maxDelay = this._reconnectionOptions.maxReconnectionDelay; + + // Cap at maximum delay + return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); + + } + + /** + * Schedule a reconnection attempt with exponential backoff + * + * @param lastEventId The ID of the last received event for resumability + * @param attemptCount Current reconnection attempt count for this specific stream + */ + private _scheduleReconnection(lastEventId: string, attemptCount = 0): void { + // Use provided options or default options + const maxRetries = this._reconnectionOptions.maxRetries; + + // Check if we've exceeded maximum retry attempts + if (maxRetries > 0 && attemptCount >= maxRetries) { + this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + return; + } + + // Calculate next delay based on current attempt count + const delay = this._getNextReconnectionDelay(attemptCount); + log(`Reconnection attempt ${attemptCount + 1} in ${delay}ms...`); + + // Schedule the reconnection + setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); + // Schedule another attempt if this one failed, incrementing the attempt counter + this._scheduleReconnection(lastEventId, attemptCount + 1); + }); + }, delay); + } + private _handleSseStream(stream: ReadableStream | null): void { if (!stream) { return; } let lastEventId: string | undefined; - const processStream = async () => { - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); - + // this is the closest we can get to trying to cath network errors + // if something happens reader will throw try { - for await (const event of eventStream) { + // Create a pipeline: binary stream -> text decoder -> SSE parser + const reader = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + + while (true) { + const { value: event, done } = await reader.read(); + if (done) { + break; + } + // Update last event ID if provided if (event.id) { lastEventId = event.id; } - // Handle message events (default event type is undefined per docs) - // or explicit 'message' event type if (!event.event || event.event === "message") { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); @@ -179,31 +281,22 @@ export class StreamableHTTPClientTransport implements Transport { // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); - // Attempt to reconnect if the stream disconnects unexpectedly - // Wait a short time before reconnecting to avoid rapid reconnection loops + // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing if (this._abortController && !this._abortController.signal.aborted) { - setTimeout(() => { - // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { - this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); - }); - }, 1000); // 1 second delay before reconnection attempt + // Use the exponential backoff reconnection strategy + if (lastEventId !== undefined) { + try { + this._scheduleReconnection(lastEventId, 0); + } + catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + + } + } } } }; - - processStream().catch(err => { - this.onerror?.(err); - - // Try to reconnect on unexpected errors - if (this._abortController && !this._abortController.signal.aborted) { - setTimeout(() => { - this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { - this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); - }); - }, 1000); - } - }); + processStream(); } async start() { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 42ffa28e..a687eccd 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -52,7 +52,6 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); - console.log(' simulate-disconnect [delay] - Simulate network disconnection after delay seconds'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); @@ -115,12 +114,6 @@ function commandLoop(): void { break; } - case 'simulate-disconnect': { - const delay = args[1] ? parseInt(args[1], 10) : 5; - simulateDisconnect(delay); - break; - } - case 'list-prompts': await listPrompts(); break; @@ -188,6 +181,9 @@ async function connect(url?: string): Promise { name: 'example-client', version: '1.0.0' }); + client.onerror = (error) => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + } transport = new StreamableHTTPClientTransport( new URL(serverUrl) @@ -324,29 +320,6 @@ async function startNotifications(interval: number, count: number): Promise { - if (!transport) return; - - console.log('\nSimulating network disconnect...'); - await transport.close(); - console.log('Transport closed. Client should automatically attempt to reconnect...'); - - // Keep the client object but null the transport - // This simulates a network disconnection without explicit user disconnect - transport = null; - - // Re-display the prompt - process.stdout.write('> '); - }, delaySeconds * 1000); -} - async function listPrompts(): Promise { if (!client) { console.log('Not connected to server.'); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0474fc21..b8be6d15 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -237,13 +237,17 @@ export class StreamableHTTPServerTransport implements Transport { const events = await this._eventStore.getEventsAfter(lastEventId); const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); - const oldResStream = this._streamMapping.get(streamId); this._streamMapping.set(streamId, res); - if (oldResStream) { - // If we have an old response stream, close it - oldResStream.end(); - } + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }; + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + res.writeHead(200, headers).flushHeaders(); for (const { eventId, message } of events) { this.writeSSEEvent(res, message, eventId); } From 663b68a7335ec1104cd15fd44bc22c3e53dd17d2 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 16:16:14 +0100 Subject: [PATCH 025/103] exc to disconnect in example client --- src/examples/client/simpleStreamableHttp.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index a687eccd..923ffbc2 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -405,11 +405,33 @@ async function cleanup(): Promise { } } + + process.stdin.setRawMode(false); readline.close(); console.log('\nGoodbye!'); process.exit(0); } +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async (data) => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + // Handle Ctrl+C process.on('SIGINT', async () => { console.log('\nReceived SIGINT. Cleaning up...'); From 43cb015844a92c6a08bb5914aee78c2615d4a9cd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 21:40:15 +0100 Subject: [PATCH 026/103] resume long running requst from new client --- src/client/streamableHttp.ts | 25 +++++++++++++++--- src/examples/client/simpleStreamableHttp.ts | 16 ++++++++++-- src/examples/server/simpleStreamableHttp.ts | 20 +++++++++------ src/server/streamableHttp.ts | 28 +++++++++------------ src/shared/protocol.ts | 18 ++++++++++--- src/shared/transport.ts | 2 +- 6 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9451e8d9..77acc9c4 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -71,6 +71,11 @@ export type StreamableHTTPClientTransportOptions = { * Options to configure the reconnection behavior. */ reconnectionOptions?: StreamableHTTPReconnectionOptions; + /** + * Session ID for the connection. This is used to identify the session on the server. + * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. + */ + sessionId?: string; }; /** @@ -98,6 +103,7 @@ export class StreamableHTTPClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; + this._sessionId = opts?.sessionId; } private async _authThenStart(): Promise { @@ -240,7 +246,7 @@ export class StreamableHTTPClientTransport implements Transport { }, delay); } - private _handleSseStream(stream: ReadableStream | null): void { + private _handleSseStream(stream: ReadableStream | null, onLastEventIdUpdate?: (event: string) => void): void { if (!stream) { return; } @@ -266,6 +272,7 @@ export class StreamableHTTPClientTransport implements Transport { // Update last event ID if provided if (event.id) { lastEventId = event.id; + onLastEventIdUpdate?.(lastEventId); } if (!event.event || event.event === "message") { @@ -330,8 +337,16 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise { try { + // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream + const { lastEventId, onLastEventIdUpdate } = options ?? {}; + if (lastEventId) { + // If we have at last event ID, we need to reconnect the SSE stream + this._startOrAuthStandaloneSSE(lastEventId).catch(err => this.onerror?.(err)); + return; + } + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); headers.set("accept", "application/json, text/event-stream"); @@ -393,7 +408,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body); + this._handleSseStream(response.body, onLastEventIdUpdate); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -416,4 +431,8 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + get sessionId(): string | undefined { + return this._sessionId; + } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 923ffbc2..922aa0ce 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -29,6 +29,8 @@ let notificationCount = 0; let client: Client | null = null; let transport: StreamableHTTPClientTransport | null = null; let serverUrl = 'http://localhost:3000/mcp'; +let notificationsToolLastEventId: string | undefined = undefined; +let sessionId: string | undefined = undefined; async function main(): Promise { console.log('MCP Interactive Client'); @@ -186,7 +188,10 @@ async function connect(url?: string): Promise { } transport = new StreamableHTTPClientTransport( - new URL(serverUrl) + new URL(serverUrl), + { + sessionId: sessionId + } ); // Set up notification handlers @@ -218,6 +223,8 @@ async function connect(url?: string): Promise { // Connect the client await client.connect(transport); + sessionId = transport.sessionId + console.log('Transport created with session ID:', sessionId); console.log('Connected to MCP server'); } catch (error) { console.error('Failed to connect:', error); @@ -291,7 +298,12 @@ async function callTool(name: string, args: Record): Promise { + notificationsToolLastEventId = event; + }; + const result = await client.request(request, CallToolResultSchema, { + lastEventId: notificationsToolLastEventId, onLastEventIdUpdate + }); console.log('Tool result:'); result.content.forEach(item => { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a45b3158..dc03f1db 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -172,14 +172,18 @@ server.tool( while (count === 0 || counter < count) { counter++; - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); - + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } // Wait for the specified interval await sleep(interval); } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b8be6d15..7740a08f 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -217,7 +217,6 @@ export class StreamableHTTPServerTransport implements Transport { // Assign the response to the standalone SSE stream this._streamMapping.set(this._standaloneSseStreamId, res); - // Set up close handler for client disconnects res.on("close", () => { this._streamMapping.delete(this._standaloneSseStreamId); @@ -345,8 +344,10 @@ export class StreamableHTTPServerTransport implements Transport { const isInitializationRequest = messages.some( msg => 'method' in msg && msg.method === 'initialize' ); + const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { - if (this._initialized) { + // if generateSessionId is not set, the server does not support session management + if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -368,7 +369,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = this.sessionIdGenerator(); + this.sessionId = mcpSessionId ?? this.sessionIdGenerator(); this._initialized = true; } @@ -419,17 +420,8 @@ export class StreamableHTTPServerTransport implements Transport { this._requestToStreamMapping.set(message.id, streamId); } } - // Set up close handler for client disconnects res.on("close", () => { - // find a stream ID for this response - // Remove all entries that reference this response - for (const [id, stream] of this._requestToStreamMapping.entries()) { - if (streamId === stream) { - this._requestToStreamMapping.delete(id); - this._requestResponseMap.delete(id); - } - } this._streamMapping.delete(streamId); }); @@ -577,7 +569,7 @@ export class StreamableHTTPServerTransport implements Transport { // Get the response for this request const streamId = this._requestToStreamMapping.get(requestId); const response = this._streamMapping.get(streamId!); - if (!streamId || !response) { + if (!streamId) { throw new Error(`No connection established for request ID: ${String(requestId)}`); } @@ -588,9 +580,10 @@ export class StreamableHTTPServerTransport implements Transport { if (this._eventStore) { eventId = await this._eventStore.storeEvent(streamId, message); } - - // Write the event to the response stream - this.writeSSEEvent(response, message, eventId); + if (response) { + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } } if (isJSONRPCResponse(message)) { @@ -603,6 +596,9 @@ export class StreamableHTTPServerTransport implements Transport { const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); if (allResponsesReady) { + if (!response) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } if (this._enableJsonResponse) { // All responses ready, send as JSON const headers: Record = { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 07bfc02c..6193f5f1 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,6 +87,18 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; + + /** + * May be used to indicate to the transport which last event ID to associate this outgoing request with. + * This is used to resume a long-running requests that may have been interrupted and a new instance of a client is being created. + */ + lastEventId?: string; + + /** + * A callback that is invoked when the last event ID is updated. + * This is used to notidy the client that the last event ID has changed, so that client can update its state accordingly. + */ + onLastEventIdUpdate?: (event: string) => void; }; /** @@ -501,7 +513,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId } = options ?? {}; + const { relatedRequestId, lastEventId, onLastEventIdUpdate } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -543,7 +555,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId }) + }, { relatedRequestId, lastEventId, onLastEventIdUpdate }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -581,7 +593,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId }).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId, lastEventId, onLastEventIdUpdate }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index e464653b..cfbe8461 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -18,7 +18,7 @@ export interface Transport { * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; + send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise; /** * Closes the connection. From c49769b97a8420008d9f53923bd5652f602eda24 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 23:46:38 +0100 Subject: [PATCH 027/103] integration tests --- .../taskResumability.test.ts | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 src/integration-tests/taskResumability.test.ts diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts new file mode 100644 index 00000000..9397d4a0 --- /dev/null +++ b/src/integration-tests/taskResumability.test.ts @@ -0,0 +1,329 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResult, CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; +import { z } from 'zod'; + +/** + * Simple in-memory event store implementation for resumability + */ +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + async getEventsAfter(lastEventId: string): Promise> { + if (!lastEventId || !this.events.has(lastEventId)) { + return []; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start collecting events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + result.push({ eventId, message }); + } + } + + return result; + } +} + + +describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { capabilities: { logging: {} } } + ); + + // Add a simple notification tool that completes quickly + mcpServer.tool( + 'send-notification', + 'Sends a single notification', + { + message: z.string().describe('Message to send').default('Test notification') + }, + async ({ message }, { sendNotification }) => { + // Send notification immediately + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: message + } + }); + + return { + content: [{ type: 'text', text: 'Notification sent' }] + }; + } + ); + + // Add a long-running tool that sends multiple notifications + mcpServer.tool( + 'run-notifications', + 'Sends multiple notifications over time', + { + count: z.number().describe('Number of notifications to send').default(10), + interval: z.number().describe('Interval between notifications in ms').default(50) + }, + async ({ count, interval }, { sendNotification }) => { + // Send notifications at specified intervals + for (let i = 0; i < count; i++) { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification ${i + 1} of ${count}` + } + }); + + // Wait for the specified interval before sending next notification + if (i < count - 1) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); + + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); + + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should store session ID when client connects', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientId = 'test-client-long-running'; + const notifications: any[] = []; + let sessionId: string | undefined; + let lastEventId: string | undefined; + + // Create first client + let client1 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = jest.fn((eventId: string) => { + lastEventId = eventId; + }); + + // Start the notification tool with event tracking using request + const toolPromise = client1.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 5, + interval: 10 + } + } + }, CallToolResultSchema, { + lastEventId, + onLastEventIdUpdate + }); + + // Wait for some notifications to arrive (not all) + await new Promise(resolve => setTimeout(resolve, 20)); + + // Verify we received some notifications and lastEventId was updated + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.length).toBeLessThan(5); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + // Store original notification count for later comparison + const firstClientNotificationCount = notifications.length; + + // Disconnect first client without waiting for completion + // When we close the connection, it will cause a ConnectionClosed error for + // any in-progress requests, which is expected behavior + // We need to catch the error since closing the transport will + // cause the pending toolPromise to reject with a ConnectionClosed error + await transport1.close(); + + // Try to cancel the promise, but ignore errors since it's already being handled + toolPromise.catch(err => { + // This error is expected - the connection was intentionally closed + if (err?.code !== -32000) { // ConnectionClosed error code + console.error("Unexpected error type during transport close:", err); + } + }); + + + // Create second client with same client ID + const client2 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for second client + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect second client with same session ID + const transport2 = new StreamableHTTPClientTransport(baseUrl, { + sessionId + }); + await client2.connect(transport2); + + // Resume the notification stream using lastEventId + // This is the key part - we're resuming the same long-running tool using lastEventId + const resumedToolPromise = client2.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 5, + interval: 50 + } + } + }, CallToolResultSchema, { + lastEventId, // Pass the lastEventId from the previous session + onLastEventIdUpdate + }); + + // Wait for remaining notifications + await new Promise(resolve => setTimeout(resolve, 200)); + + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(2); + + // Verify the second client received notifications that the first client didn't + expect(notifications.length).toBeGreaterThan(firstClientNotificationCount); + + // Clean up + + await transport2.close(); + + }); +}); \ No newline at end of file From f0fbfc7fcbc1489177f5c81cad7f49ba88b8244e Mon Sep 17 00:00:00 2001 From: qikaigao <33371207+qikaigao@users.noreply.github.com> Date: Mon, 14 Apr 2025 00:46:13 -0700 Subject: [PATCH 028/103] StreamableHTTPServerTransport should only check init status when there is an sessionId When using stateless mode, the backend will create a new transport for each request. For new transport, the initialized filed is always false. So we should only check the initialized filed if there is session existing --- src/server/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index ec8d2aa7..fc13e762 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -371,7 +371,7 @@ export class StreamableHTTPServerTransport implements Transport { * Returns true if the session is valid, false otherwise */ private validateSession(req: IncomingMessage, res: ServerResponse): boolean { - if (!this._initialized) { + if (this.sessionId && !this._initialized) { // If the server has not been initialized yet, reject all requests res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", From c21b9a0ac2af49a353ee856fe60bc4a974cdf214 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 14 Apr 2025 09:08:01 +0100 Subject: [PATCH 029/103] fix lint --- .../taskResumability.test.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 9397d4a0..c63d3f04 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResult, CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; +import { CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; import { z } from 'zod'; /** @@ -204,19 +204,18 @@ describe('Transport resumability', () => { it('should resume long-running notifications with lastEventId', async () => { // Create unique client ID for this test const clientId = 'test-client-long-running'; - const notifications: any[] = []; - let sessionId: string | undefined; + const notifications = []; let lastEventId: string | undefined; // Create first client - let client1 = new Client({ + const client1 = new Client({ id: clientId, name: 'test-client', version: '1.0.0' }); // Set up notification handler for first client - client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { if (notification.method === 'notifications/message') { notifications.push(notification.params); } @@ -225,14 +224,14 @@ describe('Transport resumability', () => { // Connect first client const transport1 = new StreamableHTTPClientTransport(baseUrl); await client1.connect(transport1); - sessionId = transport1.sessionId; + const sessionId = transport1.sessionId; expect(sessionId).toBeDefined(); // Start a long-running notification stream with tracking of lastEventId const onLastEventIdUpdate = jest.fn((eventId: string) => { lastEventId = eventId; }); - + expect(lastEventId).toBeUndefined(); // Start the notification tool with event tracking using request const toolPromise = client1.request({ method: 'tools/call', @@ -284,7 +283,7 @@ describe('Transport resumability', () => { }); // Set up notification handler for second client - client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { if (notification.method === 'notifications/message') { notifications.push(notification.params); } @@ -298,12 +297,12 @@ describe('Transport resumability', () => { // Resume the notification stream using lastEventId // This is the key part - we're resuming the same long-running tool using lastEventId - const resumedToolPromise = client2.request({ + await client2.request({ method: 'tools/call', params: { name: 'run-notifications', arguments: { - count: 5, + count: 1, interval: 50 } } @@ -312,9 +311,6 @@ describe('Transport resumability', () => { onLastEventIdUpdate }); - // Wait for remaining notifications - await new Promise(resolve => setTimeout(resolve, 200)); - // Verify we eventually received at leaset a few motifications expect(notifications.length).toBeGreaterThan(2); From 7aeb67988a5d4d5d3d065087f0d5cefb2fdfe767 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 14 Apr 2025 09:37:19 +0100 Subject: [PATCH 030/103] improve comments --- src/client/streamableHttp.test.ts | 10 +++++----- src/client/streamableHttp.ts | 10 +++++----- src/server/streamableHttp.ts | 3 ++- src/shared/protocol.ts | 7 +++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index a73927ac..2dfa5869 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,7 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); + await expect(transport["_startOrAuthSse"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -208,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthSse"](); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + _startOrAuthSse: (lastEventId?: string) => Promise }; - await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + await transportWithPrivateMethods._startOrAuthSse("test-event-id"); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); @@ -382,7 +382,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthSse"](); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 77acc9c4..43030239 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -123,7 +123,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthSse(); } private async _commonHeaders(): Promise { @@ -144,7 +144,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { + private async _startOrAuthSse(lastEventId?: string): Promise { try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -238,7 +238,7 @@ export class StreamableHTTPClientTransport implements Transport { // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this._startOrAuthSse(lastEventId).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter this._scheduleReconnection(lastEventId, attemptCount + 1); @@ -343,7 +343,7 @@ export class StreamableHTTPClientTransport implements Transport { const { lastEventId, onLastEventIdUpdate } = options ?? {}; if (lastEventId) { // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthStandaloneSSE(lastEventId).catch(err => this.onerror?.(err)); + this._startOrAuthSse(lastEventId).catch(err => this.onerror?.(err)); return; } @@ -390,7 +390,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + this._startOrAuthSse().catch(err => this.onerror?.(err)); } return; } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 7740a08f..0487e1af 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -346,7 +346,8 @@ export class StreamableHTTPServerTransport implements Transport { ); const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { - // if generateSessionId is not set, the server does not support session management + // If it's a server with session management and the session ID is already set we should reject the request + // to avoid re-initialization. if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 6193f5f1..bc7882e4 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,16 +87,15 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; - /** - * May be used to indicate to the transport which last event ID to associate this outgoing request with. - * This is used to resume a long-running requests that may have been interrupted and a new instance of a client is being created. + * The last event ID to associate with the request. + * Used to resume long-running requests that were interrupted when creating a new client instance. */ lastEventId?: string; /** * A callback that is invoked when the last event ID is updated. - * This is used to notidy the client that the last event ID has changed, so that client can update its state accordingly. + * This is used to notify the client that the last event ID has changed, so the client can update its state accordingly. */ onLastEventIdUpdate?: (event: string) => void; }; From beec24259390be38522dbca4eb6860006097577a Mon Sep 17 00:00:00 2001 From: TomasRup Date: Mon, 14 Apr 2025 19:36:27 +0300 Subject: [PATCH 031/103] stateless had a wrong example --- src/server/streamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index e8844529..bb9ea4d5 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -35,12 +35,12 @@ export interface StreamableHTTPServerTransportOptions { * ```typescript * // Stateful mode - server sets the session ID * const statefulTransport = new StreamableHTTPServerTransport({ - * sessionId: randomUUID(), + * sessionIdGenerator: () => randomUUID(), * }); * * // Stateless mode - explicitly set session ID to undefined * const statelessTransport = new StreamableHTTPServerTransport({ - * sessionId: undefined, + * sessionIdGenerator: () => undefined, * }); * * // Using with pre-parsed request body From ab3bb94290e802753cf1217c383544cc697f0f16 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:08 -0700 Subject: [PATCH 032/103] Adjust arbitrary keys to custom claims field to prevent conflicts --- src/server/auth/types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index cd1430f1..5e6bc562 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -23,7 +23,9 @@ export interface AuthInfo { expiresAt?: number; /** - * Additional arbitrary properties that may be included. + * Additional custom claims associated with the token. + * This field should be used for any additional data that needs to be attached to the auth info. + * Using this field instead of arbitrary keys ensures compatibility with future interface changes. */ - [key: string]: unknown; + customClaims?: Record; } \ No newline at end of file From b30a77e1b924af1ba4ec57962c4733bda4488e88 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:57 -0700 Subject: [PATCH 033/103] Update types.ts --- src/server/auth/types.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 5e6bc562..c25c2b60 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -23,9 +23,8 @@ export interface AuthInfo { expiresAt?: number; /** - * Additional custom claims associated with the token. + * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. - * Using this field instead of arbitrary keys ensures compatibility with future interface changes. - */ - customClaims?: Record; + */ + extra?: Record; } \ No newline at end of file From f9f380738f7408a30151e405663e7914cca0f19b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 09:10:24 +0100 Subject: [PATCH 034/103] Merge branch 'main' into resumability From 583202f81aedfc0b39bc8d9ae82b7cea56bc7688 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 09:24:11 +0100 Subject: [PATCH 035/103] address client comments --- src/client/streamableHttp.ts | 53 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9451e8d9..7bb88c0d 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,9 +1,16 @@ -import { log } from "node:console"; import { Transport } from "../shared/transport.js"; import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +// Default reconnection options for StreamableHTTP connections +const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, +}; + export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -13,6 +20,16 @@ export class StreamableHTTPError extends Error { } } +/** + * Options for starting or authenticating an SSE connection + */ +export interface StartSSEOptions { + /** + * The ID of the last received event, used for resuming a disconnected stream + */ + lastEventId?: string; +} + /** * Configuration options for reconnection behavior of the StreamableHTTPClientTransport. */ @@ -37,7 +54,7 @@ export interface StreamableHTTPReconnectionOptions { /** * Maximum number of reconnection attempts before giving up. - * Default is 0 (unlimited). + * Default is 2. */ maxRetries: number; } @@ -97,7 +114,7 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; - this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } private async _authThenStart(): Promise { @@ -117,7 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthStandaloneSSE({ lastEventId: undefined }); } private async _commonHeaders(): Promise { @@ -138,7 +155,9 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { + + private async _startOrAuthStandaloneSSE(options: StartSSEOptions): Promise { + const { lastEventId } = options; try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -181,19 +200,9 @@ export class StreamableHTTPClientTransport implements Transport { } } - // Default reconnection options - private readonly _defaultReconnectionOptions: StreamableHTTPReconnectionOptions = { - initialReconnectionDelay: 1000, - maxReconnectionDelay: 30000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }; - - // We no longer need global reconnection state as it will be maintained per stream /** - * Calculates the next reconnection delay using exponential backoff algorithm - * with jitter for more effective reconnections in high load scenarios. + * Calculates the next reconnection delay using backoff algorithm * * @param attempt Current reconnection attempt count for the specific stream * @returns Time to wait in milliseconds before next reconnection attempt @@ -227,12 +236,11 @@ export class StreamableHTTPClientTransport implements Transport { // Calculate next delay based on current attempt count const delay = this._getNextReconnectionDelay(attemptCount); - log(`Reconnection attempt ${attemptCount + 1} in ${delay}ms...`); // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this._startOrAuthStandaloneSSE({ lastEventId }).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter this._scheduleReconnection(lastEventId, attemptCount + 1); @@ -247,7 +255,7 @@ export class StreamableHTTPClientTransport implements Transport { let lastEventId: string | undefined; const processStream = async () => { - // this is the closest we can get to trying to cath network errors + // this is the closest we can get to trying to catch network errors // if something happens reader will throw try { // Create a pipeline: binary stream -> text decoder -> SSE parser @@ -279,7 +287,7 @@ export class StreamableHTTPClientTransport implements Transport { } } catch (error) { // Handle stream errors - likely a network disconnect - this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); + this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing if (this._abortController && !this._abortController.signal.aborted) { @@ -375,7 +383,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + this._startOrAuthStandaloneSSE({ lastEventId: undefined }).catch(err => this.onerror?.(err)); } return; } @@ -390,9 +398,6 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { - // Handle SSE stream responses for requests - // We use the same handler as standalone streams, which now supports - // reconnection with the last event ID this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses From 643ef5c5e7f2970c6bde41db0f2000ee9adbd497 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 09:26:08 +0100 Subject: [PATCH 036/103] remove redundant check for onlyNotifications --- src/server/streamableHttp.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b8be6d15..37dc2479 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -382,10 +382,8 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests const hasRequests = messages.some(isJSONRPCRequest); - const hasOnlyNotificationsOrResponses = messages.every(msg => - isJSONRPCNotification(msg) || isJSONRPCResponse(msg)); - if (hasOnlyNotificationsOrResponses) { + if (!hasRequests) { // if it only contains notifications or responses, return 202 res.writeHead(202).end(); From 67a4595ae7bacd7a0a52891d0892d0a9e0feee69 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:03:44 +0100 Subject: [PATCH 037/103] improve event store interface --- src/client/streamableHttp.test.ts | 16 +++---- src/examples/server/simpleStreamableHttp.ts | 35 +++++++++------ src/server/streamableHttp.test.ts | 26 +++++------ src/server/streamableHttp.ts | 49 ++++++++------------- 4 files changed, 58 insertions(+), 68 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index a73927ac..073cc9ac 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,7 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); + await expect(transport["_startOrAuthStandaloneSSE"]({})).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -208,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthStandaloneSSE"]({}); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -289,10 +289,10 @@ describe("StreamableHTTPClientTransport", () => { // Verify options were set correctly (checking implementation details) // Access private properties for testing const transportInstance = transport as unknown as { - _defaultReconnectionOptions: StreamableHTTPReconnectionOptions; + _reconnectionOptions: StreamableHTTPReconnectionOptions; }; - expect(transportInstance._defaultReconnectionOptions.initialReconnectionDelay).toBe(1000); - expect(transportInstance._defaultReconnectionOptions.maxRetries).toBe(2); + expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500); + expect(transportInstance._reconnectionOptions.maxRetries).toBe(5); }); it("should pass lastEventId when reconnecting", async () => { @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + _startOrAuthStandaloneSSE: (options: { lastEventId?: string }) => Promise }; - await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + await transportWithPrivateMethods._startOrAuthStandaloneSSE({ lastEventId: "test-event-id" }); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); @@ -382,7 +382,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthStandaloneSSE"]({}); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a45b3158..153e35b7 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,18 +12,18 @@ class InMemoryEventStore implements EventStore { /** * Generates a unique event ID for a given stream ID */ - generateEventId(streamId: string): string { + private generateEventId(streamId: string): string { return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; } - getStreamIdFromEventId(eventId: string): string { + private getStreamIdFromEventId(eventId: string): string { const parts = eventId.split('_'); return parts.length > 0 ? parts[0] : ''; } - /** * Stores an event with a generated event ID + * Implements EventStore.storeEvent */ async storeEvent(streamId: string, message: JSONRPCMessage): Promise { const eventId = this.generateEventId(streamId); @@ -33,17 +33,26 @@ class InMemoryEventStore implements EventStore { } /** - * Retrieves events that occurred after a specific event + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter */ - async getEventsAfter(lastEventId: string): Promise> { + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { if (!lastEventId || !this.events.has(lastEventId)) { - return []; + console.log(`No events found for lastEventId: ${lastEventId}`); + return ''; } // Extract the stream ID from the event ID - const streamId = lastEventId.split('_')[0]; - const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); + return ''; + } + let foundLastEvent = false; + let eventCount = 0; // Sort events by eventId for chronological ordering const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); @@ -54,21 +63,21 @@ class InMemoryEventStore implements EventStore { continue; } - // Start collecting events after we find the lastEventId + // Start sending events after we find the lastEventId if (eventId === lastEventId) { foundLastEvent = true; continue; } if (foundLastEvent) { - result.push({ eventId, message }); + await send(eventId, message); + eventCount++; } } - console.log(`Found ${result.length} events after ${lastEventId} for replay`); - return result; + console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); + return streamId; } - } // Create an MCP server with implementation details diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index a03a457d..7cb062bc 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,7 +1,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -import { EventStore, StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; @@ -912,31 +912,25 @@ describe("StreamableHTTPServerTransport with resumability", () => { // Simple implementation of EventStore const eventStore: EventStore = { - generateEventId(streamId: string): string { - return `${streamId}_${randomUUID()}`; - }, - getStreamIdFromEventId(eventId: string): string { - return eventId.split('_')[0]; // Extract stream ID from the event ID - }, + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = this.generateEventId(streamId); + const eventId = `${streamId}_${randomUUID()}`; storedEvents.set(eventId, { eventId, message }); return eventId; }, - async getEventsAfter(lastEventId: string): Promise> { - const streamId = lastEventId.split('_')[0]; // Extract stream ID from the event ID - const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; - + async replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise { + const streamId = lastEventId.split('_')[0]; + // Extract stream ID from the event ID // For test simplicity, just return all events with matching streamId that aren't the lastEventId - // This avoids issues with event ordering in tests for (const [eventId, { message }] of storedEvents.entries()) { if (eventId.startsWith(streamId) && eventId !== lastEventId) { - result.push({ eventId, message }); + await send(eventId, message); } } - - return result; + return streamId; }, }; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 37dc2479..58d0fe14 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,45 +1,30 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +export type StreamId = string; +export type EventId = string; + /** * Interface for resumability support via event storage */ export interface EventStore { - /** - * Generates a unique event ID for a given stream ID - * @param streamId The stream ID to include in the event ID - * @returns A unique event ID that includes the stream ID - */ - generateEventId(streamId: string): string; - /** * Stores an event for later retrieval * @param streamId ID of the stream the event belongs to * @param message The JSON-RPC message to store * @returns The generated event ID for the stored event */ - storeEvent(streamId: string, message: JSONRPCMessage): Promise; + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; - /** - * Retrieves events for a stream starting from a given event ID - * @param lastEventId The event ID to start from - * @returns Array of stored events with their event IDs - */ - getEventsAfter(lastEventId: string): Promise>; - - /** - * Extracts the stream ID from an event ID - * This is necessary for resumability to identify which stream the event belongs to - * @param eventId The event ID to extract stream ID from - * @returns The stream ID portion of the event ID - */ - getStreamIdFromEventId(eventId: string): string; + replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise; } /** @@ -232,12 +217,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this._eventStore) { return; } - try { - const events = await this._eventStore.getEventsAfter(lastEventId); - const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); - - this._streamMapping.set(streamId, res); const headers: Record = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", @@ -248,9 +228,16 @@ export class StreamableHTTPServerTransport implements Transport { headers["mcp-session-id"] = this.sessionId; } res.writeHead(200, headers).flushHeaders(); - for (const { eventId, message } of events) { - this.writeSSEEvent(res, message, eventId); - } + + const streamId = await this._eventStore?.replayEventsAfter(lastEventId, { + send: async (eventId: string, message: JSONRPCMessage) => { + if (!this.writeSSEEvent(res, message, eventId)) { + this.onerror?.(new Error("Failed replay events")); + res.end(); + } + } + }); + this._streamMapping.set(streamId, res); } catch (error) { this.onerror?.(error as Error); } From 1359e9fe6a69913a450a5cc88ebb480af8551f51 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:42:16 +0100 Subject: [PATCH 038/103] pull transport send options into own named type --- src/client/streamableHttp.ts | 6 +++-- src/examples/client/simpleStreamableHttp.ts | 2 +- .../taskResumability.test.ts | 8 +++--- src/shared/protocol.ts | 25 +++++++++++-------- src/shared/transport.ts | 23 ++++++++++++++++- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 63677d55..c1c93ebe 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -88,6 +88,7 @@ export type StreamableHTTPClientTransportOptions = { * Options to configure the reconnection behavior. */ reconnectionOptions?: StreamableHTTPReconnectionOptions; + /** * Session ID for the connection. This is used to identify the session on the server. * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. @@ -345,10 +346,11 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (event: string) => void }): Promise { try { // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream - const { lastEventId, onLastEventIdUpdate } = options ?? {}; + const lastEventId = options?.resumptionToken + const onLastEventIdUpdate = options?.onresumptiontoken; if (lastEventId) { // If we have at last event ID, we need to reconnect the SSE stream this._startOrAuthSse({ lastEventId }).catch(err => this.onerror?.(err)); diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 922aa0ce..6cfad987 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -302,7 +302,7 @@ async function callTool(name: string, args: Record): Promise { } } }, CallToolResultSchema, { - lastEventId, - onLastEventIdUpdate + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate }); // Wait for some notifications to arrive (not all) @@ -311,8 +311,8 @@ describe('Transport resumability', () => { } } }, CallToolResultSchema, { - lastEventId, // Pass the lastEventId from the previous session - onLastEventIdUpdate + resumptionToken: lastEventId, // Pass the lastEventId from the previous session + onresumptiontoken: onLastEventIdUpdate }); // Verify we eventually received at leaset a few motifications diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index bc7882e4..3b098193 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,17 +87,20 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; - /** - * The last event ID to associate with the request. - * Used to resume long-running requests that were interrupted when creating a new client instance. - */ - lastEventId?: string; /** - * A callback that is invoked when the last event ID is updated. - * This is used to notify the client that the last event ID has changed, so the client can update its state accordingly. + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. */ - onLastEventIdUpdate?: (event: string) => void; + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; }; /** @@ -512,7 +515,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId, lastEventId, onLastEventIdUpdate } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -554,7 +557,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId, lastEventId, onLastEventIdUpdate }) + }, { relatedRequestId, resumptionToken, onresumptiontoken }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -592,7 +595,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId, lastEventId, onLastEventIdUpdate }).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index cfbe8461..601a86af 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,26 @@ import { JSONRPCMessage, RequestId } from "../types.js"; +/** + * Options for sending a JSON-RPC message. + */ +export interface TransportSendOptions { + /** + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + */ + relatedRequestId?: RequestId; + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. + */ + resumptionToken?: string; + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; +} /** * Describes the minimal contract for a MCP transport that a client or server can communicate over. */ @@ -18,7 +39,7 @@ export interface Transport { * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; /** * Closes the connection. From 54d6929149387f197c4554115b9242f31e6dbe7c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:58:43 +0100 Subject: [PATCH 039/103] don't need to re-initialize when we reconnect a session --- src/client/index.ts | 6 +++++- src/server/streamableHttp.ts | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 64ea62c4..a3edd0be 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -126,7 +126,11 @@ export class Client< override async connect(transport: Transport, options?: RequestOptions): Promise { await super.connect(transport); - + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } try { const result = await this.request( { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 012458f2..fed85ad9 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -331,11 +331,10 @@ export class StreamableHTTPServerTransport implements Transport { const isInitializationRequest = messages.some( msg => 'method' in msg && msg.method === 'initialize' ); - const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. - if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { + if (this._initialized) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -357,7 +356,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = mcpSessionId ?? this.sessionIdGenerator(); + this.sessionId = this.sessionIdGenerator(); this._initialized = true; } From f1e6acb17d35ccaaef2c156f24c786d0cc123de5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:37:31 +0100 Subject: [PATCH 040/103] resumed tool to return value --- src/client/streamableHttp.ts | 31 +++++++++++++------ src/examples/client/simpleStreamableHttp.ts | 2 +- .../taskResumability.test.ts | 29 ++++++++--------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index c1c93ebe..770b5d72 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -28,6 +28,14 @@ export interface StartSSEOptions { * The ID of the last received event, used for resuming a disconnected stream */ lastEventId?: string; + /** + * The callback function that is invoked when the last event ID changes + */ + onLastEventIdUpdate?: (event: string) => void + /** + * When reconnecting to a long-running SSE stream, we need to make sure that message id matches + */ + replayMessageId?: string | number; } /** @@ -200,7 +208,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - this._handleSseStream(response.body); + this._handleSseStream(response.body, options); } catch (error) { this.onerror?.(error as Error); throw error; @@ -231,7 +239,7 @@ export class StreamableHTTPClientTransport implements Transport { * @param lastEventId The ID of the last received event for resumability * @param attemptCount Current reconnection attempt count for this specific stream */ - private _scheduleReconnection(lastEventId: string, attemptCount = 0): void { + private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { // Use provided options or default options const maxRetries = this._reconnectionOptions.maxRetries; @@ -247,18 +255,19 @@ export class StreamableHTTPClientTransport implements Transport { // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthSse({ lastEventId }).catch(error => { + this._startOrAuthSse(options).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter - this._scheduleReconnection(lastEventId, attemptCount + 1); + this._scheduleReconnection(options, attemptCount + 1); }); }, delay); } - private _handleSseStream(stream: ReadableStream | null, onLastEventIdUpdate?: (event: string) => void): void { + private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions): void { if (!stream) { return; } + const { onLastEventIdUpdate, replayMessageId } = options; let lastEventId: string | undefined; const processStream = async () => { @@ -287,6 +296,9 @@ export class StreamableHTTPClientTransport implements Transport { if (!event.event || event.event === "message") { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + if (replayMessageId !== undefined && isJSONRPCResponse(message)) { + message.id = replayMessageId; + } this.onmessage?.(message); } catch (error) { this.onerror?.(error as Error); @@ -302,7 +314,7 @@ export class StreamableHTTPClientTransport implements Transport { // Use the exponential backoff reconnection strategy if (lastEventId !== undefined) { try { - this._scheduleReconnection(lastEventId, 0); + this._scheduleReconnection(options, 0); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); @@ -352,8 +364,9 @@ export class StreamableHTTPClientTransport implements Transport { const lastEventId = options?.resumptionToken const onLastEventIdUpdate = options?.onresumptiontoken; if (lastEventId) { + // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ lastEventId }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ lastEventId, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); return; } @@ -418,7 +431,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, onLastEventIdUpdate); + this._handleSseStream(response.body, { onLastEventIdUpdate }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 6cfad987..d0d5408b 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -111,7 +111,7 @@ function commandLoop(): void { case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; - const count = args[2] ? parseInt(args[2], 10) : 0; + const count = args[2] ? parseInt(args[2], 10) : 10; await startNotifications(interval, count); break; } diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 7a53ee01..7101925f 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -242,7 +242,7 @@ describe('Transport resumability', () => { params: { name: 'run-notifications', arguments: { - count: 5, + count: 3, interval: 10 } } @@ -251,27 +251,23 @@ describe('Transport resumability', () => { onresumptiontoken: onLastEventIdUpdate }); - // Wait for some notifications to arrive (not all) + // Wait for some notifications to arrive (not all) - shorter wait time await new Promise(resolve => setTimeout(resolve, 20)); // Verify we received some notifications and lastEventId was updated expect(notifications.length).toBeGreaterThan(0); - expect(notifications.length).toBeLessThan(5); + expect(notifications.length).toBeLessThan(4); expect(onLastEventIdUpdate).toHaveBeenCalled(); expect(lastEventId).toBeDefined(); // Store original notification count for later comparison const firstClientNotificationCount = notifications.length; - // Disconnect first client without waiting for completion // When we close the connection, it will cause a ConnectionClosed error for // any in-progress requests, which is expected behavior - // We need to catch the error since closing the transport will - // cause the pending toolPromise to reject with a ConnectionClosed error await transport1.close(); - - // Try to cancel the promise, but ignore errors since it's already being handled - toolPromise.catch(err => { + // Save the promise so we can catch it after closing + const catchPromise = toolPromise.catch(err => { // This error is expected - the connection was intentionally closed if (err?.code !== -32000) { // ConnectionClosed error code console.error("Unexpected error type during transport close:", err); @@ -279,6 +275,14 @@ describe('Transport resumability', () => { }); + + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); + + // Wait for the rejection to be handled + await catchPromise; + + // Create second client with same client ID const client2 = new Client({ id: clientId, @@ -307,7 +311,7 @@ describe('Transport resumability', () => { name: 'run-notifications', arguments: { count: 1, - interval: 50 + interval: 5 } } }, CallToolResultSchema, { @@ -316,13 +320,10 @@ describe('Transport resumability', () => { }); // Verify we eventually received at leaset a few motifications - expect(notifications.length).toBeGreaterThan(2); + expect(notifications.length).toBeGreaterThan(1); - // Verify the second client received notifications that the first client didn't - expect(notifications.length).toBeGreaterThan(firstClientNotificationCount); // Clean up - await transport2.close(); }); From b062d75a73e8a56c898b1525a2d3f651e05a2410 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:41:33 +0100 Subject: [PATCH 041/103] change standalone sse key --- src/server/streamableHttp.test.ts | 2 +- src/server/streamableHttp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 7cb062bc..efd5de1c 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1000,7 +1000,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { const eventId = idMatch![1]; expect(storedEvents.has(eventId)).toBe(true); const storedEvent = storedEvents.get(eventId); - expect(eventId.startsWith('standalonesse')).toBe(true); + expect(eventId.startsWith('_GET_stream')).toBe(true); expect(storedEvent?.message).toMatchObject(notification); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 58d0fe14..7ddfa3ab 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -96,7 +96,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _standaloneSseStreamId: string = 'standalonesse'; + private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; sessionId?: string | undefined; From f900592a2f7634555c56fd9307f567fc10c0160d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:43:24 +0100 Subject: [PATCH 042/103] lint --- src/integration-tests/taskResumability.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 7101925f..3bd23498 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -260,8 +260,7 @@ describe('Transport resumability', () => { expect(onLastEventIdUpdate).toHaveBeenCalled(); expect(lastEventId).toBeDefined(); - // Store original notification count for later comparison - const firstClientNotificationCount = notifications.length; + // Disconnect first client without waiting for completion // When we close the connection, it will cause a ConnectionClosed error for // any in-progress requests, which is expected behavior From f1c5ef1a8d9f5cec8e465f1d8f7571b21048ed2e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 20:40:26 +0100 Subject: [PATCH 043/103] apply suggested changes --- src/client/streamableHttp.ts | 9 ++++++--- src/shared/protocol.ts | 23 ++--------------------- src/shared/transport.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 770b5d72..44bda015 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -23,17 +23,20 @@ export class StreamableHTTPError extends Error { /** * Options for starting or authenticating an SSE connection */ -export interface StartSSEOptions { +interface StartSSEOptions { /** * The ID of the last received event, used for resuming a disconnected stream */ lastEventId?: string; + /** * The callback function that is invoked when the last event ID changes */ onLastEventIdUpdate?: (event: string) => void + /** - * When reconnecting to a long-running SSE stream, we need to make sure that message id matches + * Override Message ID to associate with the replay message + * so that response can be associate with the new resumed request. */ replayMessageId?: string | number; } @@ -358,7 +361,7 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (event: string) => void }): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream const lastEventId = options?.resumptionToken diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 3b098193..289c68d3 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,7 +22,7 @@ import { Result, ServerCapabilities, } from "../types.js"; -import { Transport } from "./transport.js"; +import { Transport, TransportSendOptions } from "./transport.js"; /** * Callback for progress notifications. @@ -82,26 +82,7 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; - - /** - * May be used to indicate to the transport which incoming request to associate this outgoing request with. - */ - relatedRequestId?: RequestId; - - /** - * The resumption token used to continue long-running requests that were interrupted. - * - * This allows clients to reconnect and continue from where they left off, if supported by the transport. - */ - resumptionToken?: string; - - /** - * A callback that is invoked when the resumption token changes, if supported by the transport. - * - * This allows clients to persist the latest token for potential reconnection. - */ - onresumptiontoken?: (token: string) => void; -}; +} & TransportSendOptions; /** * Options that can be given per notification. diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 601a86af..5d559674 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,19 +1,21 @@ import { JSONRPCMessage, RequestId } from "../types.js"; /** - * Options for sending a JSON-RPC message. - */ -export interface TransportSendOptions { + * Options for sending a JSON-RPC message. + */ +export type TransportSendOptions = { /** * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. - */ + */ relatedRequestId?: RequestId; + /** * The resumption token used to continue long-running requests that were interrupted. * * This allows clients to reconnect and continue from where they left off, if supported by the transport. */ resumptionToken?: string; + /** * A callback that is invoked when the resumption token changes, if supported by the transport. * From 8bc49f9f347e99a7c2dc0b8b69b3d02982bd9481 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 22:06:10 +0100 Subject: [PATCH 044/103] change names to resumptionToken instead of last event id --- src/client/streamableHttp.test.ts | 4 +-- src/client/streamableHttp.ts | 42 ++++++++++++++++++------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 22958582..93e44150 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthSse: (options: { lastEventId?: string }) => Promise + _startOrAuthSse: (options: { resumptionToken?: string }) => Promise }; - await transportWithPrivateMethods._startOrAuthSse({ lastEventId: "test-event-id" }); + await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" }); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 44bda015..077b0f15 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -25,14 +25,18 @@ export class StreamableHTTPError extends Error { */ interface StartSSEOptions { /** - * The ID of the last received event, used for resuming a disconnected stream + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off. */ - lastEventId?: string; + resumptionToken?: string; /** - * The callback function that is invoked when the last event ID changes + * A callback that is invoked when the resumption token changes. + * + * This allows clients to persist the latest token for potential reconnection. */ - onLastEventIdUpdate?: (event: string) => void + onresumptiontoken?: (token: string) => void; /** * Override Message ID to associate with the replay message @@ -152,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthSse({ lastEventId: undefined }); + return await this._startOrAuthSse({ resumptionToken: undefined }); } private async _commonHeaders(): Promise { @@ -175,7 +179,7 @@ export class StreamableHTTPClientTransport implements Transport { private async _startOrAuthSse(options: StartSSEOptions): Promise { - const { lastEventId } = options; + const { resumptionToken } = options; try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -183,8 +187,8 @@ export class StreamableHTTPClientTransport implements Transport { headers.set("Accept", "text/event-stream"); // Include Last-Event-ID header for resumable streams if provided - if (lastEventId) { - headers.set("last-event-id", lastEventId); + if (resumptionToken) { + headers.set("last-event-id", resumptionToken); } const response = await fetch(this._url, { @@ -270,7 +274,7 @@ export class StreamableHTTPClientTransport implements Transport { if (!stream) { return; } - const { onLastEventIdUpdate, replayMessageId } = options; + const { onresumptiontoken, replayMessageId } = options; let lastEventId: string | undefined; const processStream = async () => { @@ -293,7 +297,7 @@ export class StreamableHTTPClientTransport implements Transport { // Update last event ID if provided if (event.id) { lastEventId = event.id; - onLastEventIdUpdate?.(lastEventId); + onresumptiontoken?.(event.id); } if (!event.event || event.event === "message") { @@ -317,7 +321,11 @@ export class StreamableHTTPClientTransport implements Transport { // Use the exponential backoff reconnection strategy if (lastEventId !== undefined) { try { - this._scheduleReconnection(options, 0); + this._scheduleReconnection({ + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, 0); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); @@ -363,13 +371,11 @@ export class StreamableHTTPClientTransport implements Transport { async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { - // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream - const lastEventId = options?.resumptionToken - const onLastEventIdUpdate = options?.onresumptiontoken; - if (lastEventId) { + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ lastEventId, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); return; } @@ -416,7 +422,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthSse({ lastEventId: undefined }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); } return; } @@ -434,7 +440,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onLastEventIdUpdate }); + this._handleSseStream(response.body, { onresumptiontoken }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); From 31f44e0b68c17f10496f4ffc4bfc94f4243bb746 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 10:19:46 +0100 Subject: [PATCH 045/103] add isInitializeRequest and isInitializedNotification utility --- src/client/streamableHttp.ts | 4 ++-- src/examples/server/jsonResponseStreamableHttp.ts | 10 +--------- src/examples/server/simpleStreamableHttp.ts | 12 ++---------- .../server/standaloneSseWithGetStreamableHttp.ts | 9 +-------- src/types.ts | 7 +++++++ 5 files changed, 13 insertions(+), 29 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 077b0f15..0510fe84 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isInitializedNotification, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -420,7 +420,7 @@ export class StreamableHTTPClientTransport implements Transport { if (response.status === 202) { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server - if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { + if (isInitializedNotification(message)) { // Start without a lastEventId since this is a fresh connection this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); } diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 34ab65d1..9be63f52 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult } from '../../types.js'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -145,14 +145,6 @@ app.get('/mcp', async (req: Request, res: Response) => { res.status(405).set('Allow', 'POST').send('Method Not Allowed'); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} - // Start the server const PORT = 3000; app.listen(PORT, () => { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 98333730..eb28bf4c 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult, GetPromptResult, JSONRPCMessage, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, JSONRPCMessage, ReadResourceResult } from '../../types.js'; // Create a simple in-memory EventStore for resumability class InMemoryEventStore implements EventStore { @@ -36,7 +36,7 @@ class InMemoryEventStore implements EventStore { * Replays events that occurred after a specific event ID * Implements EventStore.replayEventsAfter */ - async replayEventsAfter(lastEventId: string, + async replayEventsAfter(lastEventId: string, { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } ): Promise { if (!lastEventId || !this.events.has(lastEventId)) { @@ -312,14 +312,6 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} - // Start the server const PORT = 3000; app.listen(PORT, () => { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index f9d3696b..5f96ca82 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { ReadResourceResult } from '../../types.js'; +import { isInitializeRequest, ReadResourceResult } from '../../types.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -107,13 +107,6 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} // Start the server const PORT = 3000; diff --git a/src/types.ts b/src/types.ts index db6bf125..8ac41372 100644 --- a/src/types.ts +++ b/src/types.ts @@ -248,6 +248,10 @@ export const InitializeRequestSchema = RequestSchema.extend({ }), }); +export const isInitializeRequest = (value: unknown): value is InitializeRequest => + InitializeRequestSchema.safeParse(value).success; + + /** * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. */ @@ -337,6 +341,9 @@ export const InitializedNotificationSchema = NotificationSchema.extend({ method: z.literal("notifications/initialized"), }); +export const isInitializedNotification = (value: unknown): value is InitializedNotification => + InitializedNotificationSchema.safeParse(value).success; + /* Ping */ /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. From 585dc06708a80920634d2ae7ca76f067c6024874 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:09:47 +0100 Subject: [PATCH 046/103] introduce onsessioninitialized to avoid race conditions --- src/client/streamableHttp.ts | 2 +- .../server/jsonResponseStreamableHttp.ts | 13 ++++----- src/examples/server/simpleStreamableHttp.ts | 12 ++++---- .../standaloneSseWithGetStreamableHttp.ts | 12 ++++---- src/server/streamableHttp.test.ts | 29 ++++--------------- src/server/streamableHttp.ts | 23 ++++++++++++--- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 0510fe84..9efd8536 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isInitializedNotification, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 9be63f52..101a581f 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -95,18 +95,17 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, // Enable JSON response mode + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server BEFORE handling the request await server.connect(transport); - - // After handling the request, if we get a session ID back, store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index eb28bf4c..a318b738 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -247,19 +247,19 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport await server.connect(transport); - // After handling the request, if we get a session ID back, store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 5f96ca82..8c8c3baa 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -52,17 +52,19 @@ app.post('/mcp', async (req: Request, res: Response) => { // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server await server.connect(transport); + // Handle the request - the onsessioninitialized callback will store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index efd5de1c..0794e4bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -187,15 +187,11 @@ describe("StreamableHTTPServerTransport", () => { expect(sessionId).toBeDefined(); // Try second initialize - const secondInitMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client-2", version: "1.0" }, - protocolVersion: "2025-03-26", - }, - id: "init-2", + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: "second-init" }; + const response = await sendPostRequest(baseUrl, secondInitMessage); expect(response.status).toBe(400); @@ -1092,14 +1088,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); it("should handle POST requests with various session IDs in stateless mode", async () => { - // Initialize the server first - await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, - body: JSON.stringify({ - jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" - }), - }); + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); // Try with a random session ID - should be accepted const response1 = await fetch(baseUrl, { @@ -1131,13 +1120,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // one standalone SSE stream at a time // Initialize the server first - await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, - body: JSON.stringify({ - jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" - }), - }); + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); // Open first SSE stream const stream1 = await fetch(baseUrl, { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 31aad09c..92bbed37 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -39,6 +39,15 @@ export interface StreamableHTTPServerTransportOptions { */ sessionIdGenerator: () => string | undefined; + /** + * A callback for session initialization events + * This is called when the server initializes a new session. + * Usefult in cases when you need to register multiple mcp sessions + * and need to keep track of them. + * @param sessionId The generated session ID + */ + onsessioninitialized?: (sessionId: string) => void; + /** * If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. @@ -98,6 +107,7 @@ export class StreamableHTTPServerTransport implements Transport { private _enableJsonResponse: boolean = false; private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; + private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string | undefined; onclose?: () => void; @@ -108,6 +118,7 @@ export class StreamableHTTPServerTransport implements Transport { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; + this._onsessioninitialized = options.onsessioninitialized; } /** @@ -328,9 +339,7 @@ export class StreamableHTTPServerTransport implements Transport { // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ - const isInitializationRequest = messages.some( - msg => 'method' in msg && msg.method === 'initialize' - ); + const isInitializationRequest = messages.some(isInitializeRequest); if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. @@ -359,6 +368,12 @@ export class StreamableHTTPServerTransport implements Transport { this.sessionId = this.sessionIdGenerator(); this._initialized = true; + // If we have a session ID and an onsessioninitialized handler, call it immediately + // This is needed in cases where the server needs to keep track of multiple sessions + if (this.sessionId && this._onsessioninitialized) { + this._onsessioninitialized(this.sessionId); + } + } // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it From 8775c1fefc0d65688acb5dd81ada7d8bbb231c41 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:26:39 +0100 Subject: [PATCH 047/103] add integratoin test --- .../stateManagementStreamableHttp.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 src/integration-tests/stateManagementStreamableHttp.test.ts diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts new file mode 100644 index 00000000..1e80b7b8 --- /dev/null +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -0,0 +1,265 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { z } from 'zod'; + +describe('Streamable HTTP Transport Session Management', () => { + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { + logging: {}, + tools: {}, + resources: {}, + prompts: {} + } + } + ); + + // Add a simple resource + mcpServer.resource( + 'test-resource', + '/test', + { description: 'A test resource' }, + async () => ({ + contents: [{ + uri: '/test', + text: 'This is a test resource content' + }] + }) + ); + + mcpServer.prompt( + 'test-prompt', + 'A test prompt', + async () => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + }] + }) + ); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet').default('World'), + }, + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } + ); + + // Create transport with or without session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: withSessionManagement + ? () => randomUUID() // With session management, generate UUID + : () => undefined // Without session management, return undefined + }); + + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, mcpServer, serverTransport, baseUrl }; + } + + describe('Stateless Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(false); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should operate without session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that no session ID was set + expect(transport.sessionId).toBeUndefined(); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateless Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateless Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); + + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateful Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateful Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); +}); \ No newline at end of file From a5144dc022f8c5b121b6a1a35fcf37b6765b785d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:34:21 +0100 Subject: [PATCH 048/103] use utility to check for message types --- src/server/streamableHttp.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 92bbed37..82fcf321 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -415,7 +415,7 @@ export class StreamableHTTPServerTransport implements Transport { // Store the response for this request to send messages back through this connection // We need to track by request ID to maintain the connection for (const message of messages) { - if ('method' in message && 'id' in message) { + if (isJSONRPCRequest(message)) { this._streamMapping.set(streamId, res); this._requestToStreamMapping.set(message.id, streamId); } @@ -535,7 +535,7 @@ export class StreamableHTTPServerTransport implements Transport { async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; - if ('result' in message || 'error' in message) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { // If the message is a response, use the request ID from the message requestId = message.id; } @@ -545,7 +545,7 @@ export class StreamableHTTPServerTransport implements Transport { // Those will be sent via dedicated response SSE streams if (requestId === undefined) { // For standalone SSE streams, we can only send requests and notifications - if ('result' in message || 'error' in message) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId) From c053c2e97dcb0a1cf8be0583bd17a8b3d6af35ec Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:56:28 +0100 Subject: [PATCH 049/103] handle session termination --- src/client/streamableHttp.test.ts | 75 +++++++++++++++++++++ src/client/streamableHttp.ts | 47 +++++++++++++ src/examples/client/simpleStreamableHttp.ts | 48 ++++++++++++- src/examples/server/simpleStreamableHttp.ts | 42 ++++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 93e44150..ac129e29 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -101,6 +101,81 @@ describe("StreamableHTTPClientTransport", () => { expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); }); + it("should terminate session with DELETE request", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + expect(transport.sessionId).toBe("test-session-id"); + + // Now terminate the session + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers() + }); + + await transport.terminateSession(); + + // Verify the DELETE request was sent with the session ID + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("DELETE"); + expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); + + // The session ID should be cleared after successful termination + expect(transport.sessionId).toBeUndefined(); + }); + + it("should handle 405 response when server doesn't support session termination", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + + // Now terminate the session, but server responds with 405 + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: "Method Not Allowed", + headers: new Headers() + }); + + // This should not throw an error + await transport.terminateSession(); + + // The session ID should still be preserved since termination wasn't accepted + expect(transport.sessionId).toBe("test-session-id"); + }); + it("should handle 404 response when session expires", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9efd8536..72559f35 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -467,4 +467,51 @@ export class StreamableHTTPClientTransport implements Transport { get sessionId(): string | undefined { return this._sessionId; } + + /** + * Terminates the current session by sending a DELETE request to the server. + * + * Clients that no longer need a particular session + * (e.g., because the user is leaving the client application) SHOULD send an + * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly + * terminate the session. + * + * The server MAY respond with HTTP 405 Method Not Allowed, indicating that + * the server does not allow clients to terminate sessions. + */ + async terminateSession(): Promise { + if (!this._sessionId) { + return; // No session to terminate + } + + try { + const headers = await this._commonHeaders(); + + const init = { + ...this._requestInit, + method: "DELETE", + headers, + signal: this._abortController?.signal, + }; + + const response = await fetch(this._url, init); + + // We specifically handle 405 as a valid response according to the spec, + // meaning the server does not support explicit session termination + if (!response.ok && response.status !== 405) { + throw new StreamableHTTPError( + response.status, + `Failed to terminate session: ${response.statusText}` + ); + } + + // If session termination was successful, clear our session ID + if (response.ok) { + this._sessionId = undefined; + } + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index d0d5408b..c1501a57 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -48,6 +48,7 @@ function printHelp(): void { console.log('\nAvailable commands:'); console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); console.log(' reconnect - Reconnect to the server'); console.log(' list-tools - List available tools'); console.log(' call-tool [args] - Call a tool with optional JSON arguments'); @@ -76,6 +77,10 @@ function commandLoop(): void { await disconnect(); break; + case 'terminate-session': + await terminateSession(); + break; + case 'reconnect': await reconnect(); break; @@ -249,6 +254,36 @@ async function disconnect(): Promise { } } +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (!transport.sessionId) { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + async function reconnect(): Promise { if (client) { await disconnect(); @@ -411,13 +446,24 @@ async function listResources(): Promise { async function cleanup(): Promise { if (client && transport) { try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport await transport.close(); } catch (error) { console.error('Error closing transport:', error); } } - process.stdin.setRawMode(false); readline.close(); console.log('\nGoodbye!'); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a318b738..0ae0f910 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -255,6 +255,15 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport await server.connect(transport); @@ -312,6 +321,27 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); +// Handle DELETE requests for session termination (according to MCP spec) +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + // Start the server const PORT = 3000; app.listen(PORT, () => { @@ -343,6 +373,18 @@ app.listen(PORT, () => { // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } await server.close(); + console.log('Server shutdown complete'); process.exit(0); }); \ No newline at end of file From 4d2968cec39f781c8d52c2a298c2031f91f89117 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 13:30:00 +0100 Subject: [PATCH 050/103] backward compatible server example --- .../sseAndStreamableHttpCompatibleServer.ts | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/examples/server/sseAndStreamableHttpCompatibleServer.ts diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts new file mode 100644 index 00000000..5a79a4e5 --- /dev/null +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -0,0 +1,316 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from "node:crypto"; +import { McpServer } from '../../server/mcp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types.js'; + +/** + * This example server demonstrates backwards compatibility with both: + * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) + * 2. The Streamable HTTP transport (protocol version 2025-03-26) + * + * It maintains a single MCP server instance but exposes two transport options: + * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) + * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) + * - /request: The deprecated POST endpoint for older clients (POST to send messages) + */ + +// Simple in-memory event store for resumability +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + console.log(`Storing event ${eventId} for stream ${streamId}`); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + console.log(`No events found for lastEventId: ${lastEventId}`); + return ''; + } + + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); + return ''; + } + + let foundLastEvent = false; + let eventCount = 0; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + eventCount++; + } + } + + console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); + return streamId; + } +} + +// Create a shared MCP server instance +const server = new McpServer({ + name: 'backwards-compatible-server', + version: '1.0.0', +}, { capabilities: { logging: {} } }); + +// Register a simple tool that sends notifications over time +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } +); + +// Create Express application +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +//============================================================================= +// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) +//============================================================================= + +// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint +app.all('/mcp', async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Check if the transport is of the correct type + const existingTransport = transports[sessionId]; + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with the transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +//============================================================================= +// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) +//============================================================================= + +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (deprecated SSE transport)'); + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on("close", () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +app.post("/messages", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + let transport: SSEServerTransport; + const existingTransport = transports[sessionId]; + if (existingTransport instanceof SSEServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + if (transport) { + await transport.handlePostMessage(req, res); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Backwards compatible MCP server listening on port ${PORT}`); + console.log(` +============================================== +SUPPORTED TRANSPORT OPTIONS: + +1. Streamable Http(Protocol version: 2025-03-26) + Endpoint: /mcp + Methods: GET, POST, DELETE + Usage: + - Initialize with POST to /mcp + - Establish SSE stream with GET to /mcp + - Send requests with POST to /mcp + - Terminate session with DELETE to /mcp + +2. Http + SSE (Protocol version: 2024-11-05) + Endpoints: /sse (GET) and /request (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /message?sessionId= +============================================== +`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + await server.close(); + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file From 8328cfe3270800db8fc326ce60e194a8674f4c50 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 13:36:37 +0100 Subject: [PATCH 051/103] example of sse server --- src/examples/server/simpleSseServer.ts | 170 +++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/examples/server/simpleSseServer.ts diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts new file mode 100644 index 00000000..7b040955 --- /dev/null +++ b/src/examples/server/simpleSseServer.ts @@ -0,0 +1,170 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from "node:crypto"; +import { McpServer } from '../../server/mcp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult } from '../../types.js'; + +/** + * This example server demonstrates the deprecated HTTP+SSE transport + * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. + * + * The server exposes two endpoints: + * - /sse: For establishing the SSE stream (GET) + * - /messages: For receiving client messages (POST) + * + */ + +// Create an MCP server instance +const server = new McpServer({ + name: 'simple-sse-server', + version: '1.0.0', +}, { capabilities: { logging: {} } }); + +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + // Send the initial notification + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Starting notification stream with ${count} messages every ${interval}ms` + } + }); + + // Send periodic notifications + while (counter < count) { + counter++; + await sleep(interval); + + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + } + + return { + content: [ + { + type: 'text', + text: `Completed sending ${count} notifications every ${interval}ms`, + } + ], + }; + } +); + +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +// SSE endpoint for establishing the stream +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (establishing SSE stream)'); + + try { + // Create a new SSE transport for the client + // The endpoint for POST messages is '/messages' + const transport = new SSEServerTransport('/messages', res); + + // Store the transport by session ID + const sessionId = transport.sessionId; + transports[sessionId] = transport; + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + console.log(`SSE transport closed for session ${sessionId}`); + delete transports[sessionId]; + }; + + // Connect the transport to the MCP server + await server.connect(transport); + + // Start the SSE transport to begin streaming + // This sends an initial 'endpoint' event with the session ID in the URL + await transport.start(); + + console.log(`Established SSE stream with session ID: ${sessionId}`); + } catch (error) { + console.error('Error establishing SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Messages endpoint for receiving client JSON-RPC requests +app.post('/messages', async (req: Request, res: Response) => { + console.log('Received POST request to /messages'); + + // Extract session ID from URL query parameter + // In the SSE protocol, this is added by the client based on the endpoint event + const sessionId = req.query.sessionId as string | undefined; + + if (!sessionId) { + console.error('No session ID provided in request URL'); + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = transports[sessionId]; + if (!transport) { + console.error(`No active transport found for session ID: ${sessionId}`); + res.status(404).send('Session not found'); + return; + } + + try { + // Handle the POST message with the transport + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + await server.close(); + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file From 80b0bab08b436b34cd3c0467236d62ea901ce283 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:20:55 +0100 Subject: [PATCH 052/103] backwards compatible client --- .../streamableHttpWithSseFallbackClient.ts | 190 ++++++++++++++++++ src/examples/server/simpleSseServer.ts | 2 +- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/examples/client/streamableHttpWithSseFallbackClient.ts diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 00000000..c3e5e345 --- /dev/null +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,190 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { SSEClientTransport } from '../../client/sse.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, +} from '../../types.js'; + +/** + * Simplified Backwards Compatible MCP Client + * + * This client demonstrates backward compatibility with both: + * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) + * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) + * + * Following the MCP specification for backwards compatibility: + * - Attempts to POST an initialize request to the server URL first (modern transport) + * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Backwards Compatible Client'); + console.log('==============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport | SSEClientTransport; + let transportType: 'streamable-http' | 'sse'; + + try { + // Try connecting with automatic transport detection + const connection = await connectWithBackwardsCompatibility(serverUrl); + client = connection.client; + transport = connection.transport; + transportType = connection.transportType; + + // Set up notification handler + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); + }); + + // DEMO WORKFLOW: + // 1. List available tools + console.log('\n=== Listing Available Tools ==='); + await listTools(client); + + // 2. Call the notification tool + console.log('\n=== Starting Notification Stream ==='); + await startNotificationTool(client); + + // 3. Wait for all notifications (5 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + + } catch (error) { + console.error('Error running client:', error); + process.exit(1); + } +} + +/** + * Connect to an MCP server with backwards compatibility + * Following the spec for client backward compatibility + */ +async function connectWithBackwardsCompatibility(url: string): Promise<{ + client: Client, + transport: StreamableHTTPClientTransport | SSEClientTransport, + transportType: 'streamable-http' | 'sse' +}> { + console.log('1. Trying Streamable HTTP transport first...'); + + // Step 1: Try Streamable HTTP transport first + const client = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + + client.onerror = (error) => { + console.error('Client error:', error); + }; + const baseUrl = new URL(url); + + try { + // Create modern transport + const streamableTransport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(streamableTransport); + + console.log('Successfully connected using modern Streamable HTTP transport.'); + return { + client, + transport: streamableTransport, + transportType: 'streamable-http' + }; + } catch (error) { + // Step 2: If transport fails, try the older SSE transport + console.log(`StreamableHttp transport connection failed: ${error}`); + console.log('2. Falling back to deprecated HTTP+SSE transport...'); + + try { + // Create SSE transport pointing to /sse endpoint + const sseTransport = new SSEClientTransport(baseUrl); + await client.connect(sseTransport); + + console.log('Successfully connected using deprecated HTTP+SSE transport.'); + return { + client, + transport: sseTransport, + transportType: 'sse' + }; + } catch (sseError) { + console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); + throw new Error('Could not connect to server with any available transport'); + } + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start a notification stream by calling the notification tool + */ +async function startNotificationTool(client: Client): Promise { + try { + // Call the notification tool using reasonable defaults + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1000, // 1 second between notifications + count: 5 // Send 5 notifications + } + } + }; + + console.log('Calling notification tool...'); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error) { + console.log(`Error calling notification tool: ${error}`); + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index 7b040955..cb2407d3 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -78,7 +78,7 @@ app.use(express.json()); const transports: Record = {}; // SSE endpoint for establishing the stream -app.get('/sse', async (req: Request, res: Response) => { +app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET request to /sse (establishing SSE stream)'); try { From 7341c9a288658d21209747363b5750b8cabda010 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:44:45 +0100 Subject: [PATCH 053/103] fix lint --- src/examples/client/streamableHttpWithSseFallbackClient.ts | 2 -- src/examples/server/simpleSseServer.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index c3e5e345..86017403 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -32,14 +32,12 @@ async function main(): Promise { let client: Client; let transport: StreamableHTTPClientTransport | SSEClientTransport; - let transportType: 'streamable-http' | 'sse'; try { // Try connecting with automatic transport detection const connection = await connectWithBackwardsCompatibility(serverUrl); client = connection.client; transport = connection.transport; - transportType = connection.transportType; // Set up notification handler client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index cb2407d3..4c5aa446 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -1,5 +1,4 @@ import express, { Request, Response } from 'express'; -import { randomUUID } from "node:crypto"; import { McpServer } from '../../server/mcp.js'; import { SSEServerTransport } from '../../server/sse.js'; import { z } from 'zod'; From c3f08567a2d2078f1f7b890612ef5244e92f8a48 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:48:47 +0100 Subject: [PATCH 054/103] share implementation of in memory store between examples and integration tests --- src/examples/server/simpleStreamableHttp.ts | 80 +------------------ .../sseAndStreamableHttpCompatibleServer.ts | 79 +----------------- src/examples/shared/inMemoryEventStore.ts | 77 ++++++++++++++++++ .../taskResumability.test.ts | 63 +-------------- 4 files changed, 86 insertions(+), 213 deletions(-) create mode 100644 src/examples/shared/inMemoryEventStore.ts diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 0ae0f910..3e7e7c52 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,84 +1,10 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; -import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult, GetPromptResult, isInitializeRequest, JSONRPCMessage, ReadResourceResult } from '../../types.js'; - -// Create a simple in-memory EventStore for resumability -class InMemoryEventStore implements EventStore { - private events: Map = new Map(); - - /** - * Generates a unique event ID for a given stream ID - */ - private generateEventId(streamId: string): string { - return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; - } - - private getStreamIdFromEventId(eventId: string): string { - const parts = eventId.split('_'); - return parts.length > 0 ? parts[0] : ''; - } - - /** - * Stores an event with a generated event ID - * Implements EventStore.storeEvent - */ - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = this.generateEventId(streamId); - console.log(`Storing event ${eventId} for stream ${streamId}`); - this.events.set(eventId, { streamId, message }); - return eventId; - } - - /** - * Replays events that occurred after a specific event ID - * Implements EventStore.replayEventsAfter - */ - async replayEventsAfter(lastEventId: string, - { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } - ): Promise { - if (!lastEventId || !this.events.has(lastEventId)) { - console.log(`No events found for lastEventId: ${lastEventId}`); - return ''; - } - - // Extract the stream ID from the event ID - const streamId = this.getStreamIdFromEventId(lastEventId); - if (!streamId) { - console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); - return ''; - } - - let foundLastEvent = false; - let eventCount = 0; - - // Sort events by eventId for chronological ordering - const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); - - for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { - // Only include events from the same stream - if (eventStreamId !== streamId) { - continue; - } - - // Start sending events after we find the lastEventId - if (eventId === lastEventId) { - foundLastEvent = true; - continue; - } - - if (foundLastEvent) { - await send(eventId, message); - eventCount++; - } - } - - console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); - return streamId; - } -} +import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; // Create an MCP server with implementation details const server = new McpServer({ diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 5a79a4e5..378c0e1f 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -1,10 +1,11 @@ import express, { Request, Response } from 'express'; import { randomUUID } from "node:crypto"; import { McpServer } from '../../server/mcp.js'; -import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { SSEServerTransport } from '../../server/sse.js'; import { z } from 'zod'; -import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types.js'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; /** * This example server demonstrates backwards compatibility with both: @@ -17,81 +18,7 @@ import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types * - /request: The deprecated POST endpoint for older clients (POST to send messages) */ -// Simple in-memory event store for resumability -class InMemoryEventStore implements EventStore { - private events: Map = new Map(); - /** - * Generates a unique event ID for a given stream ID - */ - private generateEventId(streamId: string): string { - return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; - } - - private getStreamIdFromEventId(eventId: string): string { - const parts = eventId.split('_'); - return parts.length > 0 ? parts[0] : ''; - } - - /** - * Stores an event with a generated event ID - * Implements EventStore.storeEvent - */ - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = this.generateEventId(streamId); - console.log(`Storing event ${eventId} for stream ${streamId}`); - this.events.set(eventId, { streamId, message }); - return eventId; - } - - /** - * Replays events that occurred after a specific event ID - * Implements EventStore.replayEventsAfter - */ - async replayEventsAfter(lastEventId: string, - { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } - ): Promise { - if (!lastEventId || !this.events.has(lastEventId)) { - console.log(`No events found for lastEventId: ${lastEventId}`); - return ''; - } - - const streamId = this.getStreamIdFromEventId(lastEventId); - if (!streamId) { - console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); - return ''; - } - - let foundLastEvent = false; - let eventCount = 0; - - // Sort events by eventId for chronological ordering - const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); - - for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { - // Only include events from the same stream - if (eventStreamId !== streamId) { - continue; - } - - // Start sending events after we find the lastEventId - if (eventId === lastEventId) { - foundLastEvent = true; - continue; - } - - if (foundLastEvent) { - await send(eventId, message); - eventCount++; - } - } - - console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); - return streamId; - } -} - -// Create a shared MCP server instance const server = new McpServer({ name: 'backwards-compatible-server', version: '1.0.0', diff --git a/src/examples/shared/inMemoryEventStore.ts b/src/examples/shared/inMemoryEventStore.ts new file mode 100644 index 00000000..fbdebe12 --- /dev/null +++ b/src/examples/shared/inMemoryEventStore.ts @@ -0,0 +1,77 @@ +import { JSONRPCMessage } from '../../types.js'; +import { EventStore } from '../../server/streamableHttp.js'; + +/** + * Simple in-memory implementation of the EventStore interface for resumability + * This is primarily intended for examples and testing, not for production use + * where a persistent storage solution would be more appropriate. + */ +export class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + /** + * Extracts the stream ID from an event ID + */ + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + return ''; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + return ''; + } + + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + } + } + return streamId; + } +} \ No newline at end of file diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 3bd23498..efd2611f 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -4,68 +4,11 @@ import { randomUUID } from 'node:crypto'; import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; -import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; import { z } from 'zod'; +import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; -/** - * Simple in-memory event store implementation for resumability - */ -class InMemoryEventStore implements EventStore { - private events: Map = new Map(); - - private generateEventId(streamId: string): string { - return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; - } - - private getStreamIdFromEventId(eventId: string): string { - const parts = eventId.split('_'); - return parts.length > 0 ? parts[0] : ''; - } - - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = this.generateEventId(streamId); - this.events.set(eventId, { streamId, message }); - return eventId; - } - - async replayEventsAfter(lastEventId: string, - { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } - ): Promise { - if (!lastEventId || !this.events.has(lastEventId)) { - return ''; - } - - // Extract the stream ID from the event ID - const streamId = this.getStreamIdFromEventId(lastEventId); - if (!streamId) { - return ''; - } - let foundLastEvent = false; - - // Sort events by eventId for chronological ordering - const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); - - for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { - // Only include events from the same stream - if (eventStreamId !== streamId) { - continue; - } - - // Start collecting events after we find the lastEventId - if (eventId === lastEventId) { - foundLastEvent = true; - continue; - } - - if (foundLastEvent) { - await send(eventId, message); - } - } - - return streamId; - } -} describe('Transport resumability', () => { From 16df308d4fa30b32467606337f435dcf924f72d7 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:51:09 +0100 Subject: [PATCH 055/103] remove logs how to ititialize with curl - not needed anymore as we have client --- .../server/jsonResponseStreamableHttp.ts | 22 ------------------- src/examples/server/simpleSseServer.ts | 2 +- src/examples/server/simpleStreamableHttp.ts | 22 ------------------- 3 files changed, 1 insertion(+), 45 deletions(-) diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 101a581f..bcd6a960 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -148,28 +148,6 @@ app.get('/mcp', async (req: Request, res: Response) => { const PORT = 3000; app.listen(PORT, () => { console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - console.log(`Initialize session with the command below id you are using curl for testing: - ----------------------------- - SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\\r') - echo "Session ID: $SESSION_ID" - -----------------------------`); }); // Handle server shutdown diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index 4c5aa446..74cdcaac 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -9,7 +9,7 @@ import { CallToolResult } from '../../types.js'; * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. * * The server exposes two endpoints: - * - /sse: For establishing the SSE stream (GET) + * - /mcp: For establishing the SSE stream (GET) * - /messages: For receiving client messages (POST) * */ diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3e7e7c52..35c04fe5 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -272,28 +272,6 @@ app.delete('/mcp', async (req: Request, res: Response) => { const PORT = 3000; app.listen(PORT, () => { console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - console.log(`Initialize session with the command below id you are using curl for testing: - ----------------------------- - SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\\r') - echo "Session ID: $SESSION_ID" - -----------------------------`); }); // Handle server shutdown From 8806c0ecb4f5c5dce7bc8ee1b7362cbcbc024fec Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 17:49:22 +0100 Subject: [PATCH 056/103] examples readme improvements --- src/examples/README.md | 384 +++++++++++++++++++++++++++++------------ 1 file changed, 272 insertions(+), 112 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 2ea1a596..2083f4ed 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -4,134 +4,294 @@ This directory contains example implementations of MCP clients and servers using ## Table of Contents -- [Streamable HTTP Servers - Single Node Deployment](#streamable-http---single-node-deployment-with-basic-session-state-management) - - [Simple Server with Streamable HTTP](#simple-server-with-streamable-http-transport-serversimplestreamablehttpts) - - [Server Supporting SSE via GET](#server-supporting-with-sse-via-get-serverstandalonessewithgetstreamablehttpts) - - [Server with JSON Response Mode](#server-with-json-response-mode-serverjsonresponsestreamablehttpts) -- [Client Example - Streamable HTTP](#client-clientsimplestreamablehttpts) -- [Useful bash commands for testing](#useful-commands-for-testing) +- [Client Implementations](#client-implementations) + - [Streamable HTTP Client](#streamable-http-client) + - [Backwards Compatible Client](#backwards-compatible-client) +- [Server Implementations](#server-implementations) + - [Single Node Deployment](#single-node-deployment) + - [Streamable HTTP Transport](#streamable-http-transport) + - [Deprecated SSE Transport](#deprecated-sse-transport) + - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) + - [Multi-Node Deployment](#multi-node-deployment) +- [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) -## Streamable HTTP - single node deployment with basic session state management +## Client Implementations -Multi node with state management example will be added soon after we add support. +### Streamable HTTP Client +A full-featured interactive client that connects to a Streamable HTTP server, demonstrating how to: -### Simple server with Streamable HTTP transport (`server/simpleStreamableHttp.ts`) - -A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: - -- A simple `greet` tool that returns a greeting for a name -- A `greeting-template` prompt that generates a greeting template -- A static `greeting-resource` resource - -#### Running the server - -```bash -npx tsx src/examples/server/simpleStreamableHttp.ts -``` - -The server will start on port 3000. You can test the initialization and tool listing: - -### Server supporting SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) - -An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). - -#### Running the server - -```bash -npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts -``` - -The server will start on port 3000 and automatically create new resources every 5 seconds. - -### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) - -A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. - -_NOTE: This demonstrates a server that does not use SSE at all. Note that this limits its support for MCP features; for example, it cannot provide logging and progress notifications for tool execution._ - -#### Running the server - -```bash -npx tsx src/examples/server/jsonResponseStreamableHttp.ts -``` - - -### Client (`client/simpleStreamableHttp.ts`) - -A client that connects to the server, initializes it, and demonstrates how to: - -- List available tools and call the `greet` tool -- List available prompts and get the `greeting-template` prompt +- Establish and manage a connection to an MCP server +- List and call tools with arguments +- Handle notifications through the SSE stream +- List and get prompts with arguments - List available resources - -#### Running the client +- Handle session termination and reconnection +- Support for resumability with Last-Event-ID tracking ```bash npx tsx src/examples/client/simpleStreamableHttp.ts ``` -Make sure the server is running before starting the client. +### Backwards Compatible Client +A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: -### Useful commands for testing - -#### Initialize -Streamable HTTP transport requires to do the initialization first. +- The client first POSTs an initialize request to the server URL: + - If successful, it uses the Streamable HTTP transport + - If it fails with a 4xx status, it attempts a GET request to establish an SSE stream ```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID - -``` -Once a sessionĀ is established, we can send POST requests: - -#### List tools -```bash -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp +npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts ``` -#### Call tool +## Server Implementations + +### Single Node Deployment + +These examples demonstrate how to set up an MCP server on a single node with different transport options. + +#### Streamable HTTP Transport + +##### Simple Streamable HTTP Server + +A server that implements the Streamable HTTP transport (protocol version 2025-03-26). + +- Basic server setup with Express and the Streamable HTTP transport +- Session management with an in-memory event store for resumability +- Tool implementation with the `greet` and `multi-greet` tools +- Prompt implementation with the `greeting-template` prompt +- Static resource exposure +- Support for notifications via SSE stream established by GET requests +- Session termination via DELETE requests ```bash -# Call the greet tool using the saved session ID -curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "greet", - "arguments": { - "name": "World" - } - }, - "id": "2" - }' \ - http://localhost:3000/mcp +npx tsx src/examples/server/simpleStreamableHttp.ts ``` + +##### JSON Response Mode Server + +A server that uses Streamable HTTP transport with JSON response mode enabled (no SSE). + +- Streamable HTTP with JSON response mode, which returns responses directly in the response body +- Limited support for notifications (since SSE is disabled) +- Proper response handling according to the MCP specification for servers that don't support SSE +- Returning appropriate HTTP status codes for unsupported methods + +```bash +npx tsx src/examples/server/jsonResponseStreamableHttp.ts +``` + +##### Streamable HTTP with server notifications + +A server that demonstrates server notifications using Streamable HTTP. + +- Resource list change notifications with dynamically added resources +- Automatic resource creation on a timed interval + + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +#### Deprecated SSE Transport + +A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example only used for testing backwards compatibility for clients. + +- Two separate endpoints: `/mcp` for the SSE stream (GET) and `/messages` for client messages (POST) +- Tool implementation with a `start-notification-stream` tool that demonstrates sending periodic notifications + +```bash +npx tsx src/examples/server/simpleSseServer.ts +``` + +#### Streamable Http Backwards Compatible Server with SSE + +A server that supports both Streamable HTTP and SSE transports, adhering to the [MCP specification for backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility). + +- Single MCP server instance with multiple transport options +- Support for Streamable HTTP requests at `/mcp` endpoint (GET/POST/DELETE) +- Support for deprecated SSE transport with `/sse` (GET) and `/messages` (POST) +- Session type tracking to avoid mixing transport types +- Notifications and tool execution across both transport types + +```bash +npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts +``` + +### Multi-Node Deployment + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: +- **Stateless mode** - No need to maintain state between calls to MCP servers. Useful for simple API wrapper servers. +- **Persistent storage mode** - No local state needed, but session data is stored in a database. Example: an MCP server for online ordering where the shopping cart is stored in a database. +- **Local state with message routing** - Local state is needed, and all requests for a session must be routed to the correct node. This can be done with a message queue and pub/sub system. + +#### Stateless Mode + +The Streamable HTTP transport can be configured to operate without tracking sessions. This is perfect for simple API proxies or when each request is completely independent. + +##### Implementation + +To enable stateless mode, configure the `StreamableHTTPServerTransport` with: +```typescript +sessionIdGenerator: () => undefined +``` + +This disables session management entirely, and the server won't generate or expect session IDs. + +- No session ID headers are sent or expected +- Any server node can process any request +- No state is preserved between requests +- Perfect for RESTful or stateless API scenarios +- Simplest deployment model with minimal infrastructure requirements + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Client │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Load Balancer │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ │ + ā–¼ ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + + + +#### Persistent Storage Mode + +For cases where you need session continuity but don't need to maintain in-memory state on specific nodes, you can use a database to persist session data while still allowing any node to handle requests. + +##### Implementation + +Configure the transport with session management, but retrieve and store all state in an external persistent storage: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +All session state is stored in the database, and any node can serve any client by retrieving the state when needed. + +- Maintains sessions with unique IDs +- Stores all session data in an external database +- Provides resumability through the database-backed EventStore +- Any node can handle any request for the same session +- No node-specific memory state means no need for message routing +- Good for applications where state can be fully externalized +- Somewhat higher latency due to database access for each request + + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Client │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Load Balancer │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ │ + ā–¼ ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ │ + │ │ + ā–¼ ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + + + +#### Streamable HTTP with Distributed Message Routing + +For scenarios where local in-memory state must be maintained on specific nodes (such as Computer Use or complex session state), the Streamable HTTP transport can be combined with a pub/sub system to route messages to the correct node handling each session. + +1. **Bidirectional Message Queue Integration**: + - All nodes both publish to and subscribe from the message queue + - Each node registers the sessions it's actively handling + - Messages are routed based on session ownership + +2. **Request Handling Flow**: + - When a client connects to Node A with an existing `mcp-session-id` + - If Node A doesn't own this session, it: + - Establishes and maintains the SSE connection with the client + - Publishes the request to the message queue with the session ID + - Node B (which owns the session) receives the request from the queue + - Node B processes the request with its local session state + - Node B publishes responses/notifications back to the queue + - Node A subscribes to the response channel and forwards to the client + +3. **Channel Identification**: + - Each message channel combines both `mcp-session-id` and `stream-id` + - This ensures responses are correctly routed back to the originating connection + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Client │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Load Balancer │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ │ + ā–¼ ā–¼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + ▲│ ▲│ + │▼ │▼ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + + +- Maintains session affinity for stateful operations without client redirection +- Enables horizontal scaling while preserving complex in-memory state +- Provides fault tolerance through the message queue as intermediary + + +## Backwards Compatibility + +### Testing Streamable HTTP Backwards Compatibility with SSE + +To test the backwards compatibility features: + +1. Start one of the server implementations: + ```bash + # Legacy SSE server (protocol version 2024-11-05) + npx tsx src/examples/server/simpleSseServer.ts + + # Streamable HTTP server (protocol version 2025-03-26) + npx tsx src/examples/server/simpleStreamableHttp.ts + + # Backwards compatible server (supports both protocols) + npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts + ``` + +2. Then run the backwards compatible client: + ```bash + npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts + ``` + +This demonstrates how the MCP ecosystem ensures interoperability between clients and servers regardless of which protocol version they were built for. \ No newline at end of file From 864aa2155c46f094d2284a3c86adce5c82e1e196 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 18:22:55 +0100 Subject: [PATCH 057/103] update readme with StreamableHttp --- README.md | 243 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 219 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 4fcdff17..ab16e100 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - [Prompts](#prompts) - [Running Your Server](#running-your-server) - [stdio](#stdio) - - [HTTP with SSE](#http-with-sse) + - [Streamable HTTP](#streamable-http) - [Testing and Debugging](#testing-and-debugging) - [Examples](#examples) - [Echo Server](#echo-server) @@ -22,6 +22,7 @@ - [Writing MCP Clients](#writing-mcp-clients) - [Server Capabilities](#server-capabilities) - [Proxy OAuth Server](#proxy-authorization-requests-upstream) + - [Backwards Compatibility](#backwards-compatibility) ## Overview @@ -29,7 +30,7 @@ The Model Context Protocol allows applications to provide context for LLMs in a - Build MCP clients that can connect to any MCP server - Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio and SSE +- Use standard transports like stdio and Streamable HTTP - Handle all MCP protocol messages and lifecycle events ## Installation @@ -207,14 +208,18 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` -### HTTP with SSE +### Streamable HTTP -For remote servers, start a web server with a Server-Sent Events (SSE) endpoint, and a separate endpoint for the client to send its messages to: +For remote servers, set up a Streamable HTTP transport that handles both client requests and server-to-client notifications. + +#### With Session Management ```typescript -import express, { Request, Response } from "express"; +import express from "express"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; const server = new McpServer({ name: "example-server", @@ -224,33 +229,128 @@ const server = new McpServer({ // ... set up server resources, tools, and prompts ... const app = express(); +app.use(express.json()); -// to support multiple simultaneous connections we have a lookup object from -// sessionId to transport -const transports: {[sessionId: string]: SSEServerTransport} = {}; +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -app.get("/sse", async (_: Request, res: Response) => { - const transport = new SSEServerTransport('/messages', res); - transports[transport.sessionId] = transport; - res.on("close", () => { - delete transports[transport.sessionId]; - }); - await server.connect(transport); -}); +// Handle POST requests for client-to-server communication +app.post('/mcp', async (req, res) => { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; -app.post("/messages", async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; - if (transport) { - await transport.handlePostMessage(req, res); + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID + transports[sessionId] = transport; + } + }); + + // Clean up transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + + // Connect to the MCP server + await server.connect(transport); } else { - res.status(400).send('No transport found for sessionId'); + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; } + + // Handle the request + await transport.handleRequest(req, res, req.body); }); -app.listen(3001); +// Handle GET requests for server-to-client notifications via SSE +app.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + // Support resumability with Last-Event-ID header + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Handle DELETE requests for session termination +app.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +app.listen(3000); ``` +#### Without Session Management (Stateless) + +For simpler use cases where session management isn't needed: + +```typescript +import express from "express"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +const server = new McpServer({ + name: "stateless-server", + version: "1.0.0" +}); + +// ... set up server resources, tools, and prompts ... + +const app = express(); +app.use(express.json()); + +// Handle all MCP requests (GET, POST, DELETE) at a single endpoint +app.all('/mcp', async (req, res) => { + // Create a transport with sessionIdGenerator set to return undefined + // This disables session tracking completely + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => undefined, + req, + res + }); + + // Connect to server and handle the request + await server.connect(transport); + await transport.handleRequest(req, res); +}); + +app.listen(3000); +``` + +This stateless approach is useful for: +- Simple API wrappers +- RESTful scenarios where each request is independent +- Horizontally scaled deployments without shared session state + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. @@ -596,6 +696,101 @@ This setup allows you to: - Provide custom documentation URLs - Maintain control over the OAuth flow while delegating to an external provider +### Backwards Compatibility + +The SDK provides support for backwards compatibility between different protocol versions: + +#### Client-Side Compatibility + +For clients that need to work with both Streamable HTTP and older SSE servers: + +```typescript +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +// First try connecting with Streamable HTTP transport +try { + const transport = new StreamableHTTPClientTransport( + new URL("http://localhost:3000/mcp") + ); + await client.connect(transport); + console.log("Connected using Streamable HTTP transport"); +} catch (error) { + // If that fails with a 4xx error, try the older SSE transport + console.log("Streamable HTTP connection failed, falling back to SSE transport"); + const sseTransport = new SSEClientTransport({ + sseUrl: new URL("http://localhost:3000/sse"), + postUrl: new URL("http://localhost:3000/messages") + }); + await client.connect(sseTransport); + console.log("Connected using SSE transport"); +} +``` + +#### Server-Side Compatibility + +For servers that need to support both Streamable HTTP and older clients: + +```typescript +import express from "express"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; + +const server = new McpServer({ + name: "backwards-compatible-server", + version: "1.0.0" +}); + +// ... set up server resources, tools, and prompts ... + +const app = express(); +app.use(express.json()); + +// Store transports for each session type +const transports = { + streamable: {} as Record, + sse: {} as Record +}; + +// Modern Streamable HTTP endpoint +app.all('/mcp', async (req, res) => { + // Handle Streamable HTTP transport for modern clients + // Implementation as shown in the "With Session Management" example + // ... +}); + +// Legacy SSE endpoint for older clients +app.get('/sse', async (req, res) => { + // Create SSE transport for legacy clients + const transport = new SSEServerTransport('/messages', res); + transports.sse[transport.sessionId] = transport; + + res.on("close", () => { + delete transports.sse[transport.sessionId]; + }); + + await server.connect(transport); +}); + +// Legacy message endpoint for older clients +app.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.sse[sessionId]; + if (transport) { + await transport.handlePostMessage(req, res); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +app.listen(3000); +``` + +**Note**: The SSE transport is now deprecated in favor of Streamable HTTP. New implementations should use Streamable HTTP, and existing SSE implementations should plan to migrate. + ## Documentation - [Model Context Protocol documentation](https://modelcontextprotocol.io) From ec23b71f4ea6737bde0ac8d906eb437bf164b63a Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 17 Apr 2025 11:41:11 +0200 Subject: [PATCH 058/103] Pass `requestId` to handlers via metadata --- src/server/mcp.test.ts | 163 +++++++++++++++++++++++++++++++++++++++++ src/shared/protocol.ts | 7 ++ 2 files changed, 170 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index eaac5c71..c4cb0c5d 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -101,6 +101,7 @@ describe("ResourceTemplate", () => { const abortController = new AbortController(); const result = await template.listCallback?.({ signal: abortController.signal, + requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, sendNotification: () => { throw new Error("Not implemented") } }); @@ -646,6 +647,59 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + test("should pass requestId to tool callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.tool("request-id-test", async (extra) => { + receivedRequestId = extra.requestId; + return { + content: [ + { + type: "text", + text: `Received request ID: ${extra.requestId}`, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "request-id-test", + }, + }, + CallToolResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content[0].text).toContain("Received request ID:"); + }); + test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { @@ -1702,6 +1756,59 @@ describe("resource()", () => { expect(result.completion.values).toEqual(["movies", "music"]); expect(result.completion.total).toBe(2); }); + + test("should pass requestId to resource callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + resources: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { + receivedRequestId = extra.requestId; + return { + contents: [ + { + uri: "test://resource", + text: `Received request ID: ${extra.requestId}`, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/read", + params: { + uri: "test://resource", + }, + }, + ReadResourceResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents[0].text).toContain("Received request ID:"); + }); }); describe("prompt()", () => { @@ -2511,4 +2618,60 @@ describe("prompt()", () => { expect(result.completion.values).toEqual(["Alice"]); expect(result.completion.total).toBe(1); }); + + test("should pass requestId to prompt callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.prompt("request-id-test", async (extra) => { + receivedRequestId = extra.requestId; + return { + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Received request ID: ${extra.requestId}`, + }, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "prompts/get", + params: { + name: "request-id-test", + }, + }, + GetPromptResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages[0].content.text).toContain("Received request ID:"); + }); }); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index c9ea79fd..2a6bb7f2 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -115,6 +115,12 @@ export type RequestHandlerExtra this.request(r, resultSchema, { ...options, relatedRequestId: request.id }), authInfo: extra?.authInfo, + requestId: request.id, }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. From 5dba9477fff2781b3464efec4787b52a4eda39cd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 17:34:15 +0100 Subject: [PATCH 059/103] small fixes after suggested changes --- src/client/streamableHttp.test.ts | 6 +----- src/client/streamableHttp.ts | 5 +---- src/server/streamableHttp.ts | 2 +- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index ac129e29..9e8efa52 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -169,11 +169,7 @@ describe("StreamableHTTPClientTransport", () => { headers: new Headers() }); - // This should not throw an error - await transport.terminateSession(); - - // The session ID should still be preserved since termination wasn't accepted - expect(transport.sessionId).toBe("test-session-id"); + await expect(transport.terminateSession()).resolves.not.toThrow(); }); it("should handle 404 response when session expires", async () => { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 72559f35..3462b2ab 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -505,10 +505,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - // If session termination was successful, clear our session ID - if (response.ok) { - this._sessionId = undefined; - } + this._sessionId = undefined; } catch (error) { this.onerror?.(error as Error); throw error; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 82fcf321..ed52eb77 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -42,7 +42,7 @@ export interface StreamableHTTPServerTransportOptions { /** * A callback for session initialization events * This is called when the server initializes a new session. - * Usefult in cases when you need to register multiple mcp sessions + * Useful in cases when you need to register multiple mcp sessions * and need to keep track of them. * @param sessionId The generated session ID */ From 29ae6b8e49c9549198c84f8f6fe2aaa30f5106ff Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 17:43:14 +0100 Subject: [PATCH 060/103] appying suggested changes --- .../client/streamableHttpWithSseFallbackClient.ts | 8 ++++++-- .../server/sseAndStreamableHttpCompatibleServer.ts | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 86017403..7646f0f7 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -109,11 +109,15 @@ async function connectWithBackwardsCompatibility(url: string): Promise<{ try { // Create SSE transport pointing to /sse endpoint const sseTransport = new SSEClientTransport(baseUrl); - await client.connect(sseTransport); + const sseClient = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + await sseClient.connect(sseTransport); console.log('Successfully connected using deprecated HTTP+SSE transport.'); return { - client, + client: sseClient, transport: sseTransport, transportType: 'sse' }; diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 5a79a4e5..e53fcf5f 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -14,7 +14,7 @@ import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types * It maintains a single MCP server instance but exposes two transport options: * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) - * - /request: The deprecated POST endpoint for older clients (POST to send messages) + * - /messages: The deprecated POST endpoint for older clients (POST to send messages) */ // Simple in-memory event store for resumability @@ -288,10 +288,10 @@ SUPPORTED TRANSPORT OPTIONS: - Terminate session with DELETE to /mcp 2. Http + SSE (Protocol version: 2024-11-05) - Endpoints: /sse (GET) and /request (POST) + Endpoints: /sse (GET) and /messages (POST) Usage: - Establish SSE stream with GET to /sse - - Send requests with POST to /message?sessionId= + - Send requests with POST to /messages?sessionId= ============================================== `); }); From 1781d31efc0d595f5ae709aa1ffcc5b9151debcd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 18:08:58 +0100 Subject: [PATCH 061/103] readme suggested changes --- README.md | 58 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index ab16e100..07d9eb30 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ For remote servers, set up a Streamable HTTP transport that handles both client #### With Session Management +In some cases, servers need to be stateful. This is achieved by [session management](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#session-management). + ```typescript import express from "express"; import { randomUUID } from "node:crypto"; @@ -221,12 +223,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; -const server = new McpServer({ - name: "example-server", - version: "1.0.0" -}); - -// ... set up server resources, tools, and prompts ... const app = express(); app.use(express.json()); @@ -261,6 +257,12 @@ app.post('/mcp', async (req, res) => { delete transports[transport.sessionId]; } }; + const server = new McpServer({ + name: "example-server", + version: "1.0.0" + }); + + // ... set up server resources, tools, and prompts ... // Connect to the MCP server await server.connect(transport); @@ -281,21 +283,8 @@ app.post('/mcp', async (req, res) => { await transport.handleRequest(req, res, req.body); }); -// Handle GET requests for server-to-client notifications via SSE -app.get('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - return; - } - - // Support resumability with Last-Event-ID header - const transport = transports[sessionId]; - await transport.handleRequest(req, res); -}); - -// Handle DELETE requests for session termination -app.delete('/mcp', async (req, res) => { +// Reusable handler for GET and DELETE requests +const handleSessionRequest = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); @@ -304,7 +293,13 @@ app.delete('/mcp', async (req, res) => { const transport = transports[sessionId]; await transport.handleRequest(req, res); -}); +}; + +// Handle GET requests for server-to-client notifications via SSE +app.get('/mcp', handleSessionRequest); + +// Handle DELETE requests for session termination +app.delete('/mcp', handleSessionRequest); app.listen(3000); ``` @@ -698,7 +693,7 @@ This setup allows you to: ### Backwards Compatibility -The SDK provides support for backwards compatibility between different protocol versions: +Clients and servers with StreamableHttp tranport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows #### Client-Side Compatibility @@ -708,21 +703,26 @@ For clients that need to work with both Streamable HTTP and older SSE servers: import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; - -// First try connecting with Streamable HTTP transport +let client: Client|undefined = undefined +const baseUrl = new URL(url); try { + client = new Client({ + name: 'streamable-http-client', + version: '1.0.0' + }); const transport = new StreamableHTTPClientTransport( - new URL("http://localhost:3000/mcp") + new URL(baseUrl) ); await client.connect(transport); console.log("Connected using Streamable HTTP transport"); } catch (error) { // If that fails with a 4xx error, try the older SSE transport console.log("Streamable HTTP connection failed, falling back to SSE transport"); - const sseTransport = new SSEClientTransport({ - sseUrl: new URL("http://localhost:3000/sse"), - postUrl: new URL("http://localhost:3000/messages") + client = new Client({ + name: 'sse-client', + version: '1.0.0' }); + const sseTransport = new SSEClientTransport(baseUrl); await client.connect(sseTransport); console.log("Connected using SSE transport"); } From f10d1ff93f6ea9daba663c44478db64088389df9 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 18:22:11 +0100 Subject: [PATCH 062/103] change sessionIdGenerator to be either undefined or generate a string --- README.md | 5 ++--- src/examples/README.md | 2 +- .../stateManagementStreamableHttp.test.ts | 2 +- src/server/streamableHttp.test.ts | 13 +++++++------ src/server/streamableHttp.ts | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 07d9eb30..ed97b83a 100644 --- a/README.md +++ b/README.md @@ -325,10 +325,9 @@ app.use(express.json()); // Handle all MCP requests (GET, POST, DELETE) at a single endpoint app.all('/mcp', async (req, res) => { - // Create a transport with sessionIdGenerator set to return undefined - // This disables session tracking completely + // Disable session tracking by setting sessionIdGenerator to undefined const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => undefined, + sessionIdGenerator: undefined, req, res }); diff --git a/src/examples/README.md b/src/examples/README.md index 2083f4ed..611c081e 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -134,7 +134,7 @@ The Streamable HTTP transport can be configured to operate without tracking sess To enable stateless mode, configure the `StreamableHTTPServerTransport` with: ```typescript -sessionIdGenerator: () => undefined +sessionIdGenerator: undefined ``` This disables session management entirely, and the server won't generate or expect session IDs. diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 1e80b7b8..6d553727 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -68,7 +68,7 @@ describe('Streamable HTTP Transport Session Management', () => { const serverTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: withSessionManagement ? () => randomUUID() // With session management, generate UUID - : () => undefined // Without session management, return undefined + : undefined // Without session management, return undefined }); await mcpServer.connect(serverTransport); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 0794e4bb..a0f2e0bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -10,7 +10,7 @@ import { z } from "zod"; * Test server configuration for StreamableHTTPServerTransport tests */ interface TestServerConfig { - sessionIdGenerator?: () => string | undefined; + sessionIdGenerator: (() => string) | undefined; enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; @@ -19,7 +19,7 @@ interface TestServerConfig { /** * Helper to create and start test HTTP server with MCP setup */ -async function createTestServer(config: TestServerConfig = {}): Promise<{ +async function createTestServer(config: TestServerConfig = { sessionIdGenerator: (() => randomUUID()) }): Promise<{ server: Server; transport: StreamableHTTPServerTransport; mcpServer: McpServer; @@ -40,7 +40,7 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ ); const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator ?? (() => randomUUID()), + sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, eventStore: config.eventStore }); @@ -681,7 +681,7 @@ describe("StreamableHTTPServerTransport with JSON Response Mode", () => { let sessionId: string; beforeEach(async () => { - const result = await createTestServer({ enableJsonResponse: true }); + const result = await createTestServer({ sessionIdGenerator: (() => randomUUID()), enableJsonResponse: true }); server = result.server; transport = result.transport; baseUrl = result.baseUrl; @@ -784,7 +784,8 @@ describe("StreamableHTTPServerTransport with pre-parsed body", () => { console.error("Error handling request:", error); if (!res.headersSent) res.writeHead(500).end(); } - } + }, + sessionIdGenerator: (() => randomUUID()) }); server = result.server; @@ -1063,7 +1064,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { let baseUrl: URL; beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: () => undefined }); + const result = await createTestServer({ sessionIdGenerator: undefined }); server = result.server; transport = result.transport; baseUrl = result.baseUrl; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 82fcf321..d9205ac1 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -37,7 +37,7 @@ export interface StreamableHTTPServerTransportOptions { * * Return undefined to disable session management. */ - sessionIdGenerator: () => string | undefined; + sessionIdGenerator: (() => string) | undefined; /** * A callback for session initialization events @@ -98,7 +98,7 @@ export interface StreamableHTTPServerTransportOptions { */ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode - private sessionIdGenerator: () => string | undefined; + private sessionIdGenerator: (() => string) | undefined; private _started: boolean = false; private _streamMapping: Map = new Map(); private _requestToStreamMapping: Map = new Map(); @@ -365,7 +365,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = this.sessionIdGenerator(); + this.sessionId = this.sessionIdGenerator?.(); this._initialized = true; // If we have a session ID and an onsessioninitialized handler, call it immediately From 0058c30032991cbc8d9e26ac7430d5a9dc442015 Mon Sep 17 00:00:00 2001 From: qikaigao <33371207+qikaigao@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:08:34 -0700 Subject: [PATCH 063/103] Update src/server/streamableHttp.ts Co-authored-by: Cliff Hall --- src/server/streamableHttp.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 78f3dcc5..a388b4ee 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -463,7 +463,12 @@ export class StreamableHTTPServerTransport implements Transport { * Returns true if the session is valid, false otherwise */ private validateSession(req: IncomingMessage, res: ServerResponse): boolean { - if (this.sessionId && !this._initialized) { + if (this.sessionId === undefined) { + // If the session ID is not set, the session management is disabled + // and we don't need to validate the session ID + return true; + } + if (!this._initialized) { // If the server has not been initialized yet, reject all requests res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", From 1df348710b78746126391c02ff27d93ceb24d688 Mon Sep 17 00:00:00 2001 From: qikaigao <33371207+qikaigao@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:38:12 -0700 Subject: [PATCH 064/103] Update streamableHttp.ts --- src/server/streamableHttp.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index a388b4ee..dfaeed7b 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -480,11 +480,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return false; } - if (this.sessionId === undefined) { - // If the session ID is not set, the session management is disabled - // and we don't need to validate the session ID - return true; - } + const sessionId = req.headers["mcp-session-id"]; if (!sessionId) { From 0bf8b376dfd410e778299d6853b8c74ae428dde5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 18 Apr 2025 11:46:17 +0100 Subject: [PATCH 065/103] examples for stateless servers --- README.md | 67 ++++--- .../server/simpleStatelessStreamableHttp.ts | 163 ++++++++++++++++++ .../stateManagementStreamableHttp.test.ts | 38 ++++ src/server/streamableHttp.ts | 2 +- 4 files changed, 245 insertions(+), 25 deletions(-) create mode 100644 src/examples/server/simpleStatelessStreamableHttp.ts diff --git a/README.md b/README.md index ed97b83a..929abd84 100644 --- a/README.md +++ b/README.md @@ -309,35 +309,54 @@ app.listen(3000); For simpler use cases where session management isn't needed: ```typescript -import express from "express"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; - -const server = new McpServer({ - name: "stateless-server", - version: "1.0.0" -}); - -// ... set up server resources, tools, and prompts ... - const app = express(); app.use(express.json()); -// Handle all MCP requests (GET, POST, DELETE) at a single endpoint -app.all('/mcp', async (req, res) => { - // Disable session tracking by setting sessionIdGenerator to undefined - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - req, - res - }); - - // Connect to server and handle the request - await server.connect(transport); - await transport.handleRequest(req, res); +const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, +}); +await server.connect(transport); + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + await transport.handleRequest(req, res, req.body); + } catch (error) { + // ... handle error + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Stateless MCP Streamable HTTP Server listening on port ${PORT}`); }); -app.listen(3000); ``` This stateless approach is useful for: diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts new file mode 100644 index 00000000..89433ecb --- /dev/null +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -0,0 +1,163 @@ +import express, { Request, Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { z } from 'zod'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'stateless-streamable-http-server', + version: '1.0.0', +}, { capabilities: { logging: {} } }); + +// Register a simple prompt +server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, + }, + ], + }; + } +); + +// Register a tool specifically for testing resumability +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } +); + +// Create a simple resource at a fixed URI +server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } +); + +const app = express(); +app.use(express.json()); + +const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, +}); +await server.connect(transport); + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + try { + console.log(`Closing transport`); + await transport.close(); + } catch (error) { + console.error(`Error closing transport:`, error); + } + + await server.close(); + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 6d553727..88a06d38 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -109,6 +109,44 @@ describe('Streamable HTTP Transport Session Management', () => { server.close(); }); + it('should support multiple client connections', async () => { + // Create and connect a client + const client1 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + + // Verify that no session ID was set + expect(transport1.sessionId).toBeUndefined(); + + // List available tools + await client1.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + const client2 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport2 = new StreamableHTTPClientTransport(baseUrl); + await client2.connect(transport2); + + // Verify that no session ID was set + expect(transport2.sessionId).toBeUndefined(); + + // List available tools + await client1.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + + }); it('should operate without session management', async () => { // Create and connect a client const client = new Client({ diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index d40ec930..85a8b9f1 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -343,7 +343,7 @@ export class StreamableHTTPServerTransport implements Transport { if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. - if (this._initialized) { + if (this._initialized && this.sessionId !== undefined) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { From ea04be4cf84a6415e73c9fec34c4aeb00e8848dd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 18 Apr 2025 11:52:51 +0100 Subject: [PATCH 066/103] fix build --- README.md | 28 +++++++++++++++---- .../server/simpleStatelessStreamableHttp.ts | 15 ++++++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 929abd84..200cfab6 100644 --- a/README.md +++ b/README.md @@ -313,16 +313,29 @@ const app = express(); app.use(express.json()); const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, + sessionIdGenerator: undefined, // set to undefined for stateless servers }); -await server.connect(transport); + +// Setup routes for the server +const setupServer = async () => { + await server.connect(transport); +}; app.post('/mcp', async (req: Request, res: Response) => { console.log('Received MCP request:', req.body); try { await transport.handleRequest(req, res, req.body); } catch (error) { - // ... handle error + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); } } }); @@ -353,8 +366,13 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { - console.log(`Stateless MCP Streamable HTTP Server listening on port ${PORT}`); +setupServer().then(() => { + app.listen(PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); + }); +}).catch(error => { + console.error('Failed to set up the server:', error); + process.exit(1); }); ``` diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 89433ecb..f1f37510 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -96,7 +96,11 @@ app.use(express.json()); const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); -await server.connect(transport); + +// Setup routes for the server +const setupServer = async () => { + await server.connect(transport); +}; app.post('/mcp', async (req: Request, res: Response) => { console.log('Received MCP request:', req.body); @@ -143,8 +147,13 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); +setupServer().then(() => { + app.listen(PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); + }); +}).catch(error => { + console.error('Failed to set up the server:', error); + process.exit(1); }); // Handle server shutdown From 1414d6a52c3cd0ec11f599be18cedec8d485175e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 18 Apr 2025 11:57:59 +0100 Subject: [PATCH 067/103] improve test --- src/integration-tests/stateManagementStreamableHttp.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 88a06d38..b7ff17e6 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -140,7 +140,7 @@ describe('Streamable HTTP Transport Session Management', () => { expect(transport2.sessionId).toBeUndefined(); // List available tools - await client1.request({ + await client2.request({ method: 'tools/list', params: {} }, ListToolsResultSchema); From b6c8c6019b1d40c94e5eba748b2e0365d4af654f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 18 Apr 2025 11:58:18 +0100 Subject: [PATCH 068/103] bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7963ec6..7d72e6e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.10.0", + "version": "1.10.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b5e6d7bc32890e4b4bb04cc0f8c2172ecb5fbd23 Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sun, 13 Apr 2025 19:49:23 -0400 Subject: [PATCH 069/103] add server.getVersion() --- src/server/index.ts | 7 +++++++ src/server/mcp.test.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/server/index.ts b/src/server/index.ts index 3901099e..befaade2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -104,6 +104,13 @@ export class Server< ); } + /** + * The server's name and version. + */ + getVersion(): { readonly name: string; readonly version: string } { + return this._serverInfo; + } + /** * Registers new capabilities. This can only be called before connecting to a transport. * diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index eaac5c71..5a80d8aa 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -26,6 +26,10 @@ describe("McpServer", () => { }); expect(mcpServer.server).toBeDefined(); + expect(mcpServer.server.getVersion()).toEqual({ + name: "test server", + version: "1.0", + }); }); test("should allow sending notifications via Server", async () => { From f5b4254add2c308b5cc540cc7bbc18eb92cdc368 Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sun, 13 Apr 2025 19:50:24 -0400 Subject: [PATCH 070/103] event notifier --- src/shared/eventNotifier.test.ts | 145 ++++++++++++++++++++++++++++++ src/shared/eventNotifier.ts | 149 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/shared/eventNotifier.test.ts create mode 100644 src/shared/eventNotifier.ts diff --git a/src/shared/eventNotifier.test.ts b/src/shared/eventNotifier.test.ts new file mode 100644 index 00000000..2923f7a0 --- /dev/null +++ b/src/shared/eventNotifier.test.ts @@ -0,0 +1,145 @@ +import { createEventNotifier } from "./eventNotifier.js"; + +describe("EventNotifier", () => { + let notifier: ReturnType>; + + beforeEach(() => { + notifier = createEventNotifier(); + }); + + test("should notify listeners in registration order", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(`first: ${event}`)); + notifier.onEvent((event) => events.push(`second: ${event}`)); + notifier.onEvent((event) => events.push(`third: ${event}`)); + + notifier.notify("test event"); + + expect(events).toEqual([ + "first: test event", + "second: test event", + "third: test event", + ]); + }); + + test("should not notify unsubscribed listeners", () => { + const events: string[] = []; + const subscription = notifier.onEvent((event) => events.push(event)); + + notifier.notify("first event"); + subscription.close(); + notifier.notify("second event"); + + expect(events).toEqual(["first event"]); + }); + + test("should handle function events", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(event)); + + notifier.notify(() => "dynamic event"); + + expect(events).toEqual(["dynamic event"]); + }); + + test("should handle errors through error handler", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe("test error"); + }); + + test("should not notify after close", () => { + const events: string[] = []; + notifier.onEvent((event) => events.push(event)); + + notifier.notify("first event"); + notifier.close(); + notifier.notify("second event"); + + expect(events).toEqual(["first event"]); + }); + + test("should handle multiple subscriptions and unsubscriptions", () => { + const events: string[] = []; + const subscription1 = notifier.onEvent((event) => + events.push(`1: ${event}`) + ); + const subscription2 = notifier.onEvent((event) => + events.push(`2: ${event}`) + ); + + notifier.notify("first event"); + subscription1.close(); + notifier.notify("second event"); + subscription2.close(); + notifier.notify("third event"); + + expect(events).toEqual([ + "1: first event", + "2: first event", + "2: second event", + ]); + }); + + test("should handle error handler after close", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + notifier.close(); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors).toHaveLength(0); + }); + + test("should clear error handler on close", () => { + const errors: Error[] = []; + notifier.onError((error) => errors.push(error)); + + // Close should clear the error handler + notifier.close(); + + // Setting up a new listener after close + notifier.onEvent(() => { + throw new Error("test error"); + }); + + // This should not trigger the error handler since the notifier is closed + notifier.notify("test event"); + + expect(errors).toHaveLength(0); + }); + + test("should use the last set error handler", () => { + const errors1: Error[] = []; + const errors2: Error[] = []; + + // First error handler + notifier.onError((error) => errors1.push(error)); + + // Second error handler should replace the first one + notifier.onError((error) => errors2.push(error)); + + notifier.onEvent(() => { + throw new Error("test error"); + }); + + notifier.notify("test event"); + + expect(errors1).toHaveLength(0); + expect(errors2).toHaveLength(1); + expect(errors2[0].message).toBe("test error"); + }); +}); diff --git a/src/shared/eventNotifier.ts b/src/shared/eventNotifier.ts new file mode 100644 index 00000000..349a9446 --- /dev/null +++ b/src/shared/eventNotifier.ts @@ -0,0 +1,149 @@ +/** + * Provides a simple, type-safe event notification implementation. This module allows components to implement the + * observer pattern with minimal boilerplate and proper type checking. + */ + +/** + * A type-safe event notifier that manages event listeners and notifications. + * + * @template T The type of events this notifier will handle + * @template E The type of error that can be handled (defaults to Error) + * + * EventNotifier provides: + * + * - Type-safe event subscriptions via `onEvent` + * - Synchronized event notifications via `notify` + * - Automatic cleanup of resources via `close` + * - Status tracking via `active` property + * - Error handling via optional error callback + */ +export type EventNotifier = { + /** + * Registers a listener function to be called when events are notified. Listeners are notified in the order they + * were registered. + * + * @example + * + * ```ts + * const notifier = createEventNotifier(); + * const subscription = notifier.onEvent((message) => { + * console.log(`Received message: ${message}`); + * }); + * + * // Later, to stop listening: + * subscription.close(); + * ``` + * + * @param listener A function that will be called with the notified event + * @returns A SyncCloseable that, when closed, will unregister the listener + */ + onEvent: (listener: (event: T) => unknown) => { close: () => void }; + + /** + * Notifies all registered listeners with the provided event. + * + * This method: + * + * - Calls all registered listeners with the event in their registration order + * - Ignores errors thrown by listeners (they won't affect other listeners) + * - Ignores returned promises (results are not awaited) + * - Does nothing if there are no listeners + * - If the event is a function, it will be called if there are listeners and its return value will be + * used as the event. + * + * @example + * + * ```ts + * const notifier = createEventNotifier<{ type: string; data: unknown }>(); + * notifier.onEvent((event) => { + * console.log(`Received ${event.type} with data:`, event.data); + * }); + * + * notifier.notify({ type: 'update', data: { id: 123, status: 'complete' } }); + * ``` + * + * @param event The event to send to all listeners or a function that returns such event. + */ + notify: (event: T | (() => T)) => void; + + /** + * Sets an error handler for the notifier. This handler will be called when a listener throws an error. + * + * @param handler A function that will be called with any errors thrown by listeners + */ + onError: (handler: (error: E) => void) => void; + + /** + * Closes the notifier and removes all listeners. + * + * @warning Failing to call close() on subscriptions or the notifier itself may lead to memory leaks. + */ + close: () => void; +}; + +/** + * Creates a type-safe event notifier. + * + * @example + * + * ```ts + * // Simple string event notifier + * const stringNotifier = createEventNotifier(); + * + * // Complex object event notifier + * interface UserEvent { + * type: 'created' | 'updated' | 'deleted'; + * userId: number; + * data?: Record; + * } + * const userNotifier = createEventNotifier(); + * ``` + * + * @template T The type of events this notifier will handle + * @template E The type of error that can be handled (defaults to Error) + * @returns A new EventNotifier instance + */ +export const createEventNotifier = (): EventNotifier => { + const listeners = new Set<(event: T) => unknown>(); + let errorHandler: ((error: E) => void) | undefined; + + return { + close: () => { + listeners.clear(); + errorHandler = undefined; + }, + + onEvent: (listener) => { + listeners.add(listener); + return { + close: () => { + listeners.delete(listener); + }, + }; + }, + + notify: (event: T | (() => T)) => { + if (!listeners.size) { + return; + } + + if (typeof event === "function") { + event = (event as () => T)(); + } + + for (const listener of listeners) { + try { + void listener(event); + } catch (error) { + if (errorHandler) { + errorHandler(error as E); + } + } + } + }, + + onError: (handler) => { + errorHandler = handler; + }, + }; +}; From b2fea383dd741319187a4e69a68968eb9da54eaa Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sun, 13 Apr 2025 21:05:40 -0400 Subject: [PATCH 071/103] capability event --- src/server/mcp.test.ts | 410 ++++++++++++++++- src/server/mcp.ts | 973 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 1323 insertions(+), 60 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 5a80d8aa..4e6270cc 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,7 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, } from "../types.js"; -import { ResourceTemplate } from "./mcp.js"; +import { ResourceTemplate, CapabilityEvent } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; @@ -866,6 +866,95 @@ describe("tool()", () => { ), ).rejects.toThrow(/Tool nonexistent-tool not found/); }); + + test("should include duration in completed and error events", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register tools with different behaviors + mcpServer.tool("success-tool", async () => { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)); + return { content: [{ type: "text", text: "Success" }] }; + }); + + mcpServer.tool("error-tool", async () => { + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)); + throw new Error("Simulated error"); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Clear events from registration + events.length = 0; + + // Call the success tool + await client.request( + { + method: "tools/call", + params: { + name: "success-tool", + }, + }, + CallToolResultSchema, + ); + + // Find the completed event + const completedEvent = events.find(e => + e.capabilityType === "tool" && + e.capabilityName === "success-tool" && + e.action === "completed" + ) as Extract; + + expect(completedEvent).toBeDefined(); + expect(completedEvent.durationMs).toBeDefined(); + expect(completedEvent.durationMs).toBeGreaterThan(0); + + // Clear events + events.length = 0; + + // Call the error tool + await client.request( + { + method: "tools/call", + params: { + name: "error-tool", + }, + }, + CallToolResultSchema, + ); + + // Find the error event + const errorEvent = events.find(e => + e.capabilityType === "tool" && + e.capabilityName === "error-tool" && + e.action === "error" + ) as Extract; + + expect(errorEvent).toBeDefined(); + expect(errorEvent.durationMs).toBeDefined(); + expect(errorEvent.durationMs).toBeGreaterThan(0); + + subscription.close(); + }); }); describe("resource()", () => { @@ -2516,3 +2605,322 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); }); + +describe("CapabilityEvents", () => { + test("should emit capability events when registering and interacting with tools", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a tool (should trigger "added" event) + const tool = mcpServer.tool("test-tool", async () => ({ + content: [{ type: "text", text: "Test response" }], + })); + + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toMatchObject({ + serverInfo: { name: "test server", version: "1.0" }, + capabilityType: "tool", + capabilityName: "test-tool", + action: "added", + }); + + // Update the tool (should trigger "updated" event) + tool.update({ + description: "Updated description", + }); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "updated" + )).toBe(true); + + // Disable the tool (should trigger "disabled" event) + tool.disable(); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "disabled" + )).toBe(true); + + // Enable the tool (should trigger "enabled" event) + tool.enable(); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "enabled" + )).toBe(true); + + // Clean up + subscription.close(); + }); + + test("should emit capability events when tools are invoked", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + mcpServer.tool("test-tool", async () => ({ + content: [{ type: "text", text: "Test response" }], + })); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + await client.request( + { + method: "tools/call", + params: { + name: "test-tool", + }, + }, + CallToolResultSchema, + ); + + // Should have "invoked" and "completed" events + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "invoked" + )).toBe(true); + + expect(events.some(e => + e.capabilityType === "tool" && + e.capabilityName === "test-tool" && + e.action === "completed" + )).toBe(true); + + // The invoked and completed events should have the same invocationIndex + const invokedEvent = events.find(e => e.action === "invoked"); + const completedEvent = events.find(e => e.action === "completed"); + expect(invokedEvent?.invocationIndex).toBe(completedEvent?.invocationIndex); + + subscription.close(); + }); + + test("should emit capability events for resources", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a resource + const resource = mcpServer.resource("test-resource", "test://resource", async () => ({ + contents: [{ uri: "test://resource", text: "Test content" }], + })); + + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test-resource" && + e.action === "added" + )).toBe(true); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Read the resource + await client.request( + { + method: "resources/read", + params: { + uri: "test://resource", + }, + }, + ReadResourceResultSchema, + ); + + // Should have "invoked" event + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test://resource" && + e.action === "invoked" + )).toBe(true); + + // Clear events + events.length = 0; + + // Remove the resource + resource.remove(); + + // Should have "removed" event + expect(events.some(e => + e.capabilityType === "resource" && + e.capabilityName === "test-resource" && + e.action === "removed" + )).toBe(true); + + subscription.close(); + }); + + test("should emit capability events for prompts", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + events.push(event); + }); + + // Register a prompt + mcpServer.prompt("test-prompt", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + }, + ], + })); + + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "added" + )).toBe(true); + + // Clear events from registration + events.length = 0; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Get the prompt + await client.request( + { + method: "prompts/get", + params: { + name: "test-prompt", + }, + }, + GetPromptResultSchema, + ); + + // Should have "invoked" and "completed" events + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "invoked" + )).toBe(true); + + expect(events.some(e => + e.capabilityType === "prompt" && + e.capabilityName === "test-prompt" && + e.action === "completed" + )).toBe(true); + + subscription.close(); + }); + + test("should emit error events when tool execution fails", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client({ + name: "test client", + version: "1.0", + }); + + const events: Array = []; + const subscription = mcpServer.onCapabilityChange((event) => { + // Only capture error events + if (event.action === "error") { + events.push(event); + } + }); + + mcpServer.tool("error-tool", async () => { + throw new Error("Simulated error"); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Call the tool that throws an error + await client.request( + { + method: "tools/call", + params: { + name: "error-tool", + }, + }, + CallToolResultSchema, + ); + + // Should have error event + expect(events.length).toBe(1); + expect(events[0]).toMatchObject({ + capabilityType: "tool", + capabilityName: "error-tool", + action: "error", + }); + + // Type assert to the specific variant of CapabilityEvent that has the error property + const errorEvent = events[0] as Extract; + expect(errorEvent.error).toBeDefined(); + + subscription.close(); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 652f9774..3627892b 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -44,6 +44,7 @@ import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; import { RequestHandlerExtra } from "../shared/protocol.js"; import { Transport } from "../shared/transport.js"; +import { createEventNotifier } from "../shared/eventNotifier.js"; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. @@ -63,6 +64,11 @@ export class McpServer { private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private _onCapabilityChange = createEventNotifier(); + private _resourceInvocationIndex = 0; + private _toolInvocationIndex = 0; + private _promptInvocationIndex = 0; + constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); } @@ -80,16 +86,31 @@ export class McpServer { * Closes the connection. */ async close(): Promise { + this._onCapabilityChange.close(); await this.server.close(); } + /** + * Event notifier for capability changes. Listeners will be notified when capabilities are added, updated, removed, + * enabled, disabled, invoked, completed, or when errors occur. + * + * @example + * const subscription = server.onCapabilityChange((event) => { + * console.log(`${event.capabilityType} ${event.capabilityName} ${event.action}`); + * }); + * + * // Later, to stop listening: + * subscription.close(); + */ + public readonly onCapabilityChange = this._onCapabilityChange.onEvent; + private _toolHandlersInitialized = false; private setToolRequestHandlers() { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -135,11 +156,35 @@ export class McpServer { ); } + const invocationIndex = this._toolInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "invoked", + invocationIndex, + arguments: request.params.arguments, + })); + if (!tool.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (tool.inputSchema) { @@ -147,17 +192,51 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Invalid arguments for tool ${request.params.name}: ${parseResult.error.message}`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } const args = parseResult.data; const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)); + return await Promise.resolve(cb(args, extra)).then((result) => { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + }); } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + return { content: [ { @@ -171,8 +250,30 @@ export class McpServer { } else { const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(extra)); + const result = await Promise.resolve(cb(extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + return { content: [ { @@ -235,24 +336,79 @@ export class McpServer { ); } + const invocationIndex = this._promptInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "invoked", + invocationIndex, + arguments: request.params.argument.name, + })); + if (!prompt.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (!prompt.argsSchema) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } const field = prompt.argsSchema.shape[request.params.argument.name]; if (!(field instanceof Completable)) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } const def: CompletableDef = field._def; const suggestions = await def.complete(request.params.argument.value); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: ref.name, + action: "completed", + invocationIndex, + result: suggestions, + durationMs: performance.now() - startTime, + })); + return createCompletionResult(suggestions); } @@ -264,8 +420,21 @@ export class McpServer { (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, ); + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + if (!template) { if (this._registeredResources[ref.uri]) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + // Attempting to autocomplete a fixed resource URI is not an error in the spec (but probably should be). return EMPTY_COMPLETION_RESULT; } @@ -280,11 +449,46 @@ export class McpServer { request.params.argument.name, ); if (!completer) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: EMPTY_COMPLETION_RESULT, + durationMs: performance.now() - startTime, + })); + return EMPTY_COMPLETION_RESULT; } - const suggestions = await completer(request.params.argument.value); - return createCompletionResult(suggestions); + try { + const suggestions = await completer(request.params.argument.value); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "completed", + invocationIndex, + result: suggestions, + durationMs: performance.now() - startTime, + })); + + return createCompletionResult(suggestions); + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: ref.uri, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } private _resourceHandlersInitialized = false; @@ -367,13 +571,62 @@ export class McpServer { // First check for exact resource match const resource = this._registeredResources[uri.toString()]; if (resource) { + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "invoked", + invocationIndex, + })); + if (!resource.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Resource ${uri} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } + try { + const result = await resource.readCallback(uri, extra); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } - return resource.readCallback(uri, extra); } // Then check templates @@ -384,7 +637,44 @@ export class McpServer { uri.toString(), ); if (variables) { - return template.readCallback(uri, variables, extra); + const invocationIndex = this._resourceInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "invoked", + invocationIndex, + })); + + try { + const result = await template.readCallback(uri, variables, extra); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: uri.toString(), + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } } @@ -396,7 +686,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -450,11 +740,35 @@ export class McpServer { ); } + const invocationIndex = this._promptInvocationIndex++; + const startTime = performance.now(); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "invoked", + invocationIndex, + arguments: request.params.arguments, + })); + if (!prompt.enabled) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } if (prompt.argsSchema) { @@ -462,24 +776,90 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - throw new McpError( + const error = new McpError( ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${parseResult.error.message}`, ); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; } const args = parseResult.data; const cb = prompt.callback as PromptCallback; - return await Promise.resolve(cb(args, extra)); + + try { + const result = await Promise.resolve(cb(args, extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } else { const cb = prompt.callback as PromptCallback; - return await Promise.resolve(cb(extra)); + + try { + const result = await Promise.resolve(cb(extra)); + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "completed", + invocationIndex, + result, + durationMs: performance.now() - startTime, + })); + + return result; + } catch (error) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: request.params.name, + action: "error", + invocationIndex, + error, + durationMs: performance.now() - startTime, + })); + + throw error; + } } - }, + } ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -541,23 +921,128 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceCallback, enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), + disable: () => { + if (!registeredResource.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "disabled", + })); + + registeredResource.update({ enabled: false }); + }, + enable: () => { + if (registeredResource.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "enabled", + })); + + registeredResource.update({ enabled: true }); + }, + remove: () => { + if (uriOrTemplate === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + + registeredResource.update({ uri: null }); + }, update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { - delete this._registeredResources[uriOrTemplate] - if (updates.uri) this._registeredResources[updates.uri] = registeredResource + let added = false; + let removed = false; + let updated = false; + let enabled = false; + + if ( + typeof updates.uri !== "undefined" && + updates.uri !== uriOrTemplate + ) { + removed = true; + delete this._registeredResources[uriOrTemplate]; + + if (updates.uri) { + added = true; + this._registeredResources[updates.uri] = registeredResource; + } } - if (typeof updates.name !== "undefined") registeredResource.name = updates.name - if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled - this.sendResourceListChanged() + + if (typeof updates.name !== "undefined" && updates.name !== name) { + updated = true; + registeredResource.name = updates.name; + } + + if (typeof updates.metadata !== "undefined" && updates.metadata !== metadata) { + updated = true; + registeredResource.metadata = updates.metadata; + } + + if (typeof updates.callback !== "undefined" && updates.callback !== registeredResource.readCallback) { + updated = true; + registeredResource.readCallback = updates.callback; + } + + if (typeof updates.enabled !== "undefined" && updates.enabled !== registeredResource.enabled) { + enabled = true; + registeredResource.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: registeredResource.name, + action: registeredResource.enabled ? "enabled" : "disabled", + })); + } + this.sendResourceListChanged(); }, }; this._registeredResources[uriOrTemplate] = registeredResource; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "added", + })); + this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResource; @@ -571,23 +1056,136 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceTemplateCallback, enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), + disable: () => { + if (!registeredResourceTemplate.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "disabled", + })); + + registeredResourceTemplate.update({ enabled: false }); + }, + enable: () => { + if (registeredResourceTemplate.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "enabled", + })); + + registeredResourceTemplate.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + + registeredResourceTemplate.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredResourceTemplates[name] - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + removed = true; + delete this._registeredResourceTemplates[name]; + + if (updates.name) { + added = true; + this._registeredResourceTemplates[updates.name] = + registeredResourceTemplate; + } } - if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template - if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata - if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback - if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled - this.sendResourceListChanged() + + if (typeof updates.template !== "undefined" && updates.template !== registeredResourceTemplate.resourceTemplate) { + updated = true; + registeredResourceTemplate.resourceTemplate = updates.template; + } + + if ( + typeof updates.metadata !== "undefined" && + updates.metadata !== metadata + ) { + updated = true; + registeredResourceTemplate.metadata = updates.metadata; + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredResourceTemplate.readCallback + ) { + updated = true; + registeredResourceTemplate.readCallback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredResourceTemplate.enabled + ) { + enabled = true; + registeredResourceTemplate.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: updates.name || name, + action: registeredResourceTemplate.enabled ? "enabled" : "disabled", + })); + } + + this.sendResourceListChanged(); }, }; this._registeredResourceTemplates[name] = registeredResourceTemplate; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "resource", + capabilityName: name, + action: "added", + })); + this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResourceTemplate; @@ -645,23 +1243,138 @@ export class McpServer { paramsSchema === undefined ? undefined : z.object(paramsSchema), callback: cb, enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), + disable: () => { + if (!registeredTool.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "disabled", + })); + + registeredTool.update({ enabled: false }); + }, + enable: () => { + if (registeredTool.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "enabled", + })); + + registeredTool.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "removed", + })); + + registeredTool.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredTools[name] - if (updates.name) this._registeredTools[updates.name] = registeredTool + removed = true; + delete this._registeredTools[name]; + + if (updates.name) { + added = true; + this._registeredTools[updates.name] = registeredTool; + } } - if (typeof updates.description !== "undefined") registeredTool.description = updates.description - if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) - if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled - this.sendToolListChanged() + + if ( + typeof updates.description !== "undefined" && + updates.description !== description + ) { + updated = true; + registeredTool.description = updates.description; + } + + if ( + typeof updates.paramsSchema !== "undefined" && + updates.paramsSchema !== paramsSchema + ) { + updated = true; + registeredTool.inputSchema = z.object(updates.paramsSchema); + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredTool.callback + ) { + updated = true; + registeredTool.callback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredTool.enabled + ) { + enabled = true; + registeredTool.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: updates.name || name, + action: registeredTool.enabled ? "enabled" : "disabled", + })); + } + + this.sendToolListChanged(); }, }; this._registeredTools[name] = registeredTool; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "tool", + capabilityName: name, + action: "added", + })); + this.setToolRequestHandlers(); this.sendToolListChanged() @@ -718,23 +1431,138 @@ export class McpServer { argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), callback: cb, enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), + disable: () => { + if (!registeredPrompt.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "disabled", + })); + + registeredPrompt.update({ enabled: false }); + }, + enable: () => { + if (registeredPrompt.enabled) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "enabled", + })); + + registeredPrompt.update({ enabled: true }); + }, + remove: () => { + if (name === null) return; + + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "removed", + })); + + registeredPrompt.update({ name: null }); + }, update: (updates) => { + let added = false; + let removed = false; + let updated = false; + let enabled = false; + if (typeof updates.name !== "undefined" && updates.name !== name) { - delete this._registeredPrompts[name] - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + removed = true; + delete this._registeredPrompts[name]; + + if (updates.name) { + added = true; + this._registeredPrompts[updates.name!] = registeredPrompt; + } } - if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description - if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) - if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback - if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled - this.sendPromptListChanged() + + if ( + typeof updates.description !== "undefined" && + updates.description !== description + ) { + updated = true; + registeredPrompt.description = updates.description; + } + + if ( + typeof updates.argsSchema !== "undefined" && + updates.argsSchema !== argsSchema + ) { + updated = true; + registeredPrompt.argsSchema = z.object(updates.argsSchema); + } + + if ( + typeof updates.callback !== "undefined" && + updates.callback !== registeredPrompt.callback + ) { + updated = true; + registeredPrompt.callback = updates.callback; + } + + if ( + typeof updates.enabled !== "undefined" && + updates.enabled !== registeredPrompt.enabled + ) { + enabled = true; + registeredPrompt.enabled = updates.enabled; + } + + if (removed) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "removed", + })); + } + + if (added) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: "added", + })); + } + + if (updated) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: "updated", + })); + } + + if (enabled) { + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: updates.name || name, + action: registeredPrompt.enabled ? "enabled" : "disabled", + })); + } + + this.sendPromptListChanged(); }, }; this._registeredPrompts[name] = registeredPrompt; + this._onCapabilityChange.notify(() => ({ + serverInfo: this.server.getVersion(), + capabilityType: "prompt", + capabilityName: name, + action: "added", + })); + this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -970,3 +1798,30 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { hasMore: false, }, }; + +export type CapabilityEvent = { + readonly serverInfo: { readonly name: string; readonly version: string }; + readonly capabilityType: "resource" | "tool" | "prompt"; + readonly capabilityName: string; +} & ( + | { + readonly action: "added" | "updated" | "removed" | "enabled" | "disabled"; + } + | { + readonly action: "invoked"; + readonly invocationIndex: number; + readonly arguments?: unknown; + } + | { + readonly action: "completed"; + readonly invocationIndex: number; + readonly result?: unknown; + readonly durationMs?: number; + } + | { + readonly action: "error"; + readonly invocationIndex: number; + readonly error: unknown; + readonly durationMs?: number; + } +); From 112eed999cdf9975b86744e042b4024cbd23a59b Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sun, 13 Apr 2025 23:17:59 -0400 Subject: [PATCH 072/103] example and more documentation --- src/examples/server/simpleStreamableHttp.ts | 17 ++++++- src/server/mcp.ts | 56 ++++++++++++++++++++- src/shared/eventNotifier.ts | 6 +-- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 35c04fe5..2b25f4b2 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,6 +12,21 @@ const server = new McpServer({ version: '1.0.0', }, { capabilities: { logging: {} } }); +// Log the capability invocation details +server.onCapabilityChange((event) => { + switch (event.action) { + case 'invoked': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' started`); + break; + case 'completed': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' completed in ${event.durationMs}ms`); + break; + case 'error': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' failed in ${event.durationMs}ms: ${event.error}`); + break; + } +}); + // Register a simple tool that returns a greeting server.tool( 'greet', @@ -291,4 +306,4 @@ process.on('SIGINT', async () => { await server.close(); console.log('Server shutdown complete'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 3627892b..b713d0a2 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -65,8 +65,11 @@ export class McpServer { private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; private _onCapabilityChange = createEventNotifier(); + /** Counter for unique resource invocation indexes, used to correlate resource invocation events */ private _resourceInvocationIndex = 0; + /** Counter for unique tool invocation indexes, used to correlate tool invocation events */ private _toolInvocationIndex = 0; + /** Counter for unique prompt invocation indexes, used to correlate prompt invocation events */ private _promptInvocationIndex = 0; constructor(serverInfo: Implementation, options?: ServerOptions) { @@ -94,9 +97,18 @@ export class McpServer { * Event notifier for capability changes. Listeners will be notified when capabilities are added, updated, removed, * enabled, disabled, invoked, completed, or when errors occur. * + * This provides a way to monitor and respond to all capability-related activities in the server, + * including both lifecycle changes and invocation events. Each capability type (resource, tool, prompt) + * maintains its own sequence of invocation indexes, which can be used to correlate invocations + * with their completions or errors. + * * @example * const subscription = server.onCapabilityChange((event) => { - * console.log(`${event.capabilityType} ${event.capabilityName} ${event.action}`); + * if (event.action === "invoked") { + * console.log(`${event.capabilityType} ${event.capabilityName} invoked with index ${event.invocationIndex}`); + * } else if (event.action === "completed" || event.action === "error") { + * console.log(`${event.capabilityType} operation completed in ${event.durationMs}ms`); + * } * }); * * // Later, to stop listening: @@ -1799,29 +1811,71 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { }, }; +/** + * Represents events emitted when capabilities (tools, resources, prompts) change state or are invoked. + * + * These events allow tracking the lifecycle and usage of all capabilities registered with an McpServer. + * Events include capability registration, updates, invocation, completion, and errors. + * + * Each capability type (tool, resource, prompt) maintains its own sequence of invocation indexes. + * The invocationIndex can be used to correlate "invoked" events with their corresponding + * "completed" or "error" events for the same capability type. + */ export type CapabilityEvent = { + /** Information about the server that generated this event */ readonly serverInfo: { readonly name: string; readonly version: string }; + /** The type of capability this event relates to */ readonly capabilityType: "resource" | "tool" | "prompt"; + /** The name (or URI for resources) of the specific capability */ readonly capabilityName: string; } & ( | { + /** + * Lifecycle events for capability registration and status changes. + * - "added": The capability was registered + * - "updated": The capability was modified + * - "removed": The capability was unregistered + * - "enabled": The capability was enabled + * - "disabled": The capability was disabled + */ readonly action: "added" | "updated" | "removed" | "enabled" | "disabled"; } | { + /** Emitted when a capability is invoked */ readonly action: "invoked"; + /** + * Monotonically increasing index for each invocation, per capability type. + * This index can be used to correlate this "invoked" event with a later + * "completed" or "error" event with the same capabilityType and invocationIndex. + */ readonly invocationIndex: number; + /** The arguments passed to the capability, if any */ readonly arguments?: unknown; } | { + /** Emitted when a capability invocation completes successfully */ readonly action: "completed"; + /** + * The invocationIndex from the corresponding "invoked" event. + * This allows correlating the completion with its invocation. + */ readonly invocationIndex: number; + /** The result returned by the capability, if any */ readonly result?: unknown; + /** The duration of the operation in milliseconds, measured from invocation to completion */ readonly durationMs?: number; } | { + /** Emitted when a capability invocation fails with an error */ readonly action: "error"; + /** + * The invocationIndex from the corresponding "invoked" event. + * This allows correlating the error with its invocation. + */ readonly invocationIndex: number; + /** The error that occurred during capability execution */ readonly error: unknown; + /** The duration from invocation to error in milliseconds */ readonly durationMs?: number; } ); diff --git a/src/shared/eventNotifier.ts b/src/shared/eventNotifier.ts index 349a9446..877f4757 100644 --- a/src/shared/eventNotifier.ts +++ b/src/shared/eventNotifier.ts @@ -35,7 +35,7 @@ export type EventNotifier = { * ``` * * @param listener A function that will be called with the notified event - * @returns A SyncCloseable that, when closed, will unregister the listener + * @returns A closeable to unregister the listener (all listeners are unregistered when the notifier is closed). */ onEvent: (listener: (event: T) => unknown) => { close: () => void }; @@ -69,7 +69,7 @@ export type EventNotifier = { /** * Sets an error handler for the notifier. This handler will be called when a listener throws an error. * - * @param handler A function that will be called with any errors thrown by listeners + * @param handler A function that will be called with any errors thrown by listeners. */ onError: (handler: (error: E) => void) => void; @@ -101,7 +101,7 @@ export type EventNotifier = { * * @template T The type of events this notifier will handle * @template E The type of error that can be handled (defaults to Error) - * @returns A new EventNotifier instance + * @returns A new EventNotifier instance. */ export const createEventNotifier = (): EventNotifier => { const listeners = new Set<(event: T) => unknown>(); From dc24098d15faad90ef1dfd0be70097b6f0cea6f9 Mon Sep 17 00:00:00 2001 From: Ram Ananth Date: Fri, 18 Apr 2025 12:46:34 -0400 Subject: [PATCH 073/103] Update README.md to use correct location of InMemoryEventStore --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 200cfab6..78b752e8 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ import express from "express"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; const app = express(); @@ -773,7 +773,7 @@ import express from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; const server = new McpServer({ name: "backwards-compatible-server", From 94759c3b76b6beb62f96b1e44cd6f6625170bb52 Mon Sep 17 00:00:00 2001 From: Marcelo Paternostro Date: Sat, 19 Apr 2025 15:28:00 -0400 Subject: [PATCH 074/103] use more strongly typed examples --- src/shared/eventNotifier.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/shared/eventNotifier.ts b/src/shared/eventNotifier.ts index 877f4757..a1f53238 100644 --- a/src/shared/eventNotifier.ts +++ b/src/shared/eventNotifier.ts @@ -54,12 +54,12 @@ export type EventNotifier = { * @example * * ```ts - * const notifier = createEventNotifier<{ type: string; data: unknown }>(); + * const notifier = createEventNotifier<{ type: string; value: number }>(); * notifier.onEvent((event) => { - * console.log(`Received ${event.type} with data:`, event.data); + * console.log(`Received ${event.type} with value:`, event.value); * }); * - * notifier.notify({ type: 'update', data: { id: 123, status: 'complete' } }); + * notifier.notify({ type: 'progress', value: 75 }); * ``` * * @param event The event to send to all listeners or a function that returns such event. @@ -91,12 +91,15 @@ export type EventNotifier = { * const stringNotifier = createEventNotifier(); * * // Complex object event notifier - * interface UserEvent { - * type: 'created' | 'updated' | 'deleted'; - * userId: number; - * data?: Record; + * interface TaskEvent { + * type: 'started' | 'completed' | 'failed'; + * taskId: number; + * details: { + * name: string; + * duration?: number; + * }; * } - * const userNotifier = createEventNotifier(); + * const taskNotifier = createEventNotifier(); * ``` * * @template T The type of events this notifier will handle From 14b8371ef98b8cf557a8acafe57d84adde2cf30a Mon Sep 17 00:00:00 2001 From: Ram Ananth Date: Sat, 19 Apr 2025 23:30:41 -0400 Subject: [PATCH 075/103] Remove in memory event store from README.md --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 78b752e8..5345c546 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,6 @@ import express from "express"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; const app = express(); @@ -244,7 +243,6 @@ app.post('/mcp', async (req, res) => { const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability onsessioninitialized: (sessionId) => { // Store the transport by session ID transports[sessionId] = transport; @@ -773,7 +771,6 @@ import express from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; const server = new McpServer({ name: "backwards-compatible-server", From e2db54592ee19bf4989f85f1ae869f6dbbd0baa8 Mon Sep 17 00:00:00 2001 From: Matt Ferrante Date: Sun, 20 Apr 2025 23:41:15 -0600 Subject: [PATCH 076/103] Fixed Streamable HTTP Example I tried copying the Streamable HTTP example and it didn't work until I updated these imports. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 200cfab6..c19d5e3b 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,8 @@ import express from "express"; import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { InMemoryEventStore } from "@modelcontextprotocol/sdk/inMemory.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" const app = express(); From 70608b58762fc92c5b615f15685f702eaeefd536 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 21 Apr 2025 11:54:10 +0100 Subject: [PATCH 077/103] Add more test clients --- .../client/multipleClientsParallel.ts | 159 +++++++++++ .../client/parallelToolCallsClient.ts | 197 ++++++++++++++ .../streamableHttpBatchMessages.test.ts | 252 ++++++++++++++++++ 3 files changed, 608 insertions(+) create mode 100644 src/examples/client/multipleClientsParallel.ts create mode 100644 src/examples/client/parallelToolCallsClient.ts create mode 100644 src/integration-tests/streamableHttpBatchMessages.test.ts diff --git a/src/examples/client/multipleClientsParallel.ts b/src/examples/client/multipleClientsParallel.ts new file mode 100644 index 00000000..90b9d4ea --- /dev/null +++ b/src/examples/client/multipleClientsParallel.ts @@ -0,0 +1,159 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, +} from '../../types.js'; + +/** + * Multiple Clients MCP Example + * + * This client demonstrates how to: + * 1. Create multiple MCP clients in parallel + * 2. Each client calls a single tool + * 3. Track notifications from each client independently + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +interface ClientConfig { + id: string; + name: string; + toolName: string; + toolArguments: Record; +} + +async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: any }> { + console.log(`[${config.id}] Creating client: ${config.name}`); + + const client = new Client({ + name: config.name, + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + // Set up client-specific error handler + client.onerror = (error) => { + console.error(`[${config.id}] Client error:`, error); + }; + + // Set up client-specific notification handler + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`[${config.id}] Notification: ${notification.params.data}`); + }); + + try { + // Connect to the server + await client.connect(transport); + console.log(`[${config.id}] Connected to MCP server`); + + // Call the specified tool + console.log(`[${config.id}] Calling tool: ${config.toolName}`); + const toolRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: config.toolName, + arguments: { + ...config.toolArguments, + // Add client ID to arguments for identification in notifications + caller: config.id + } + } + }; + + const result = await client.request(toolRequest, CallToolResultSchema); + console.log(`[${config.id}] Tool call completed`); + + // Keep the connection open for a bit to receive notifications + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Disconnect + await transport.close(); + console.log(`[${config.id}] Disconnected from MCP server`); + + return { id: config.id, result }; + } catch (error) { + console.error(`[${config.id}] Error:`, error); + throw error; + } +} + +async function main(): Promise { + console.log('MCP Multiple Clients Example'); + console.log('============================'); + console.log(`Server URL: ${serverUrl}`); + console.log(''); + + try { + // Define client configurations + const clientConfigs: ClientConfig[] = [ + { + id: 'client1', + name: 'basic-client-1', + toolName: 'start-notification-stream', + toolArguments: { + interval: 3, // 1 second between notifications + count: 5 // Send 5 notifications + } + }, + { + id: 'client2', + name: 'basic-client-2', + toolName: 'start-notification-stream', + toolArguments: { + interval: 2, // 2 seconds between notifications + count: 3 // Send 3 notifications + } + }, + { + id: 'client3', + name: 'basic-client-3', + toolName: 'start-notification-stream', + toolArguments: { + interval: 1, // 0.5 second between notifications + count: 8 // Send 8 notifications + } + } + ]; + + // Start all clients in parallel + console.log(`Starting ${clientConfigs.length} clients in parallel...`); + console.log(''); + + const clientPromises = clientConfigs.map(config => createAndRunClient(config)); + const results = await Promise.all(clientPromises); + + // Display results from all clients + console.log('\n=== Final Results ==='); + results.forEach(({ id, result }) => { + console.log(`\n[${id}] Tool result:`); + if (Array.isArray(result.content)) { + result.content.forEach((item: { type: string; text?: string }) => { + if (item.type === 'text' && item.text) { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else { + console.log(` Unexpected result format:`, result); + } + }); + + console.log('\n=== All clients completed successfully ==='); + + } catch (error) { + console.error('Error running multiple clients:', error); + process.exit(1); + } +} + +// Start the example +main().catch((error: unknown) => { + console.error('Error running MCP multiple clients example:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts new file mode 100644 index 00000000..8a53ade9 --- /dev/null +++ b/src/examples/client/parallelToolCallsClient.ts @@ -0,0 +1,197 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, +} from '../../types.js'; + +/** + * Parallel Tool Calls MCP Client + * + * This client demonstrates how to: + * 1. Start multiple tool calls in parallel + * 2. Track notifications from each tool call using a caller parameter + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Parallel Tool Calls Client'); + console.log('=============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport; + + try { + // Create client with streamable HTTP transport + client = new Client({ + name: 'parallel-tool-calls-client', + version: '1.0.0' + }); + + client.onerror = (error) => { + console.error('Client error:', error); + }; + + // Connect to the server + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + console.log('Successfully connected to MCP server'); + + // Set up notification handler with caller identification + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification: ${notification.params.data}`); + }); + + console.log("List tools") + const toolsRequest = await listTools(client); + console.log("Tools: ", toolsRequest) + + + // 2. Start multiple notification tools in parallel + console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); + const toolResults = await startParallelNotificationTools(client); + + // Log the results from each tool call + for (const [caller, result] of Object.entries(toolResults)) { + console.log(`\n=== Tool result for ${caller} ===`); + result.content.forEach((item: { type: string; text: any; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } + + // 3. Wait for all notifications (10 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 10000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + + } catch (error) { + console.error('Error running client:', error); + process.exit(1); + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start multiple notification tools in parallel with different configurations + * Each tool call includes a caller parameter to identify its notifications + */ +async function startParallelNotificationTools(client: Client): Promise> { + try { + // Define multiple tool calls with different configurations + const toolCalls = [ + { + caller: 'fast-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 2, // 0.5 second between notifications + count: 10, // Send 10 notifications + caller: 'fast-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'slow-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 5, // 2 seconds between notifications + count: 5, // Send 5 notifications + caller: 'slow-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'burst-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1, // 0.1 second between notifications + count: 3, // Send just 3 notifications + caller: 'burst-notifier' // Identify this tool call + } + } + } + } + ]; + + console.log(`Starting ${toolCalls.length} notification tools in parallel...`); + + // Start all tool calls in parallel + const toolPromises = toolCalls.map(({ caller, request }) => { + console.log(`Starting tool call for ${caller}...`); + return client.request(request, CallToolResultSchema) + .then(result => ({ caller, result })) + .catch(error => { + console.error(`Error in tool call for ${caller}:`, error); + throw error; + }); + }); + + // Wait for all tool calls to complete + const results = await Promise.all(toolPromises); + + // Organize results by caller + const resultsByTool: Record = {}; + results.forEach(({ caller, result }) => { + resultsByTool[caller] = result; + }); + + return resultsByTool; + } catch (error) { + console.error(`Error starting parallel notification tools:`, error); + throw error; + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/integration-tests/streamableHttpBatchMessages.test.ts b/src/integration-tests/streamableHttpBatchMessages.test.ts new file mode 100644 index 00000000..dc79e3de --- /dev/null +++ b/src/integration-tests/streamableHttpBatchMessages.test.ts @@ -0,0 +1,252 @@ +/** + * Integration tests for batch messaging in StreamableHttp transport + * + * This test suite focuses specifically on the requirement from the spec: + * "The body of the POST request MUST be one of the following: + * - A single JSON-RPC request, notification, or response + * - An array batching one or more requests and/or notifications + * - An array batching one or more responses" + */ + +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "../server/streamableHttp.js"; +import { McpServer } from "../server/mcp.js"; +import { Client } from "../client/index.js"; +import { CallToolResult, CallToolResultSchema, JSONRPCMessage } from "../types.js"; +import { z } from "zod"; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +describe("StreamableHttp Batch Messaging - SSE", () => { + let server: Server; + let client: Client; + + // Just a basic server and client for testing + beforeEach(async () => { + // Create MCP server with test tools + const mcpServer = new McpServer( + { name: "batch-test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + // Simple greeting tool + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + // Server transport with session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + // Connect transport to server + await mcpServer.connect(serverTransport); + + // Create HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start server on random port + const baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + // Create client that connects to the server + const transport = new StreamableHTTPClientTransport(baseUrl); + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(transport); + + }); + + afterEach(async () => { + await client.close(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + /** + * 1. Test sending a single JSON-RPC request + */ + it("handles a single request", async () => { + const result = await client.callTool({ + name: "greet", + arguments: { + name: "user" + } + }); + + expect(result).toHaveProperty("content"); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content).toHaveLength(1); + + // Type assertion to handle the unknown type + const content = result.content as Array<{ type: string, text: string }>; + expect(content[0]).toHaveProperty("type", "text"); + expect(content[0]).toHaveProperty("text", "Hello, user!"); + + }); + + /** + * 1. Test sending a single JSON-RPC request + */ + it("handles a single request with request", async () => { + const result = await client.request( + { + method: "tools/call", + params: { + name: "greet", + arguments: { + name: "user" + } + }, + }, + CallToolResultSchema, + ); + + + expect(result).toHaveProperty("content"); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content).toHaveLength(1); + + // Type assertion to handle the unknown type + const content = result.content as Array<{ type: string, text: string }>; + expect(content[0]).toHaveProperty("type", "text"); + expect(content[0]).toHaveProperty("text", "Hello, user!"); + + }); + + // it("handles an array batching one or more requests and/or notifications", async () => { + // const result = await client.request({ + + // }); + + // }); + + +}); + +describe("StreamableHttp Batch Messaging - JSON response", () => { + let server: Server; + let client: Client; + + // Just a basic server and client for testing + beforeEach(async () => { + // Create MCP server with test tools + const mcpServer = new McpServer( + { name: "batch-test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + // Simple greeting tool + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + // Server transport with session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + + }); + + // Connect transport to server + await mcpServer.connect(serverTransport); + + // Create HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start server on random port + const baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + // Create client that connects to the server + const transport = new StreamableHTTPClientTransport(baseUrl); + client = new Client({ name: "test-client", version: "1.0.0" }); + await client.connect(transport); + + }); + + afterEach(async () => { + await client.close(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + /** + * 1. Test sending a single JSON-RPC request + */ + it("handles a single request", async () => { + const result = await client.callTool({ + name: "greet", + arguments: { + name: "user" + } + }); + + expect(result).toHaveProperty("content"); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content).toHaveLength(1); + + // Type assertion to handle the unknown type + const content = result.content as Array<{ type: string, text: string }>; + expect(content[0]).toHaveProperty("type", "text"); + expect(content[0]).toHaveProperty("text", "Hello, user!"); + + }); + + /** + * 1. Test sending a single JSON-RPC request + */ + it("handles a single request with request", async () => { + const result = await client.request( + { + method: "tools/call", + params: { + name: "greet", + arguments: { + name: "user" + } + }, + }, + CallToolResultSchema, + ); + + + expect(result).toHaveProperty("content"); + expect(Array.isArray(result.content)).toBe(true); + expect(result.content).toHaveLength(1); + + // Type assertion to handle the unknown type + const content = result.content as Array<{ type: string, text: string }>; + expect(content[0]).toHaveProperty("type", "text"); + expect(content[0]).toHaveProperty("text", "Hello, user!"); + + }); + + // it("handles an array batching one or more requests and/or notifications", async () => { + // const result = await client.request({ + + // }); + + // }); + + +}); \ No newline at end of file From 39f7fdf050b34a04ca06c740f38b9448941f0691 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 21 Apr 2025 14:41:12 +0100 Subject: [PATCH 078/103] fix stateless example --- .../server/simpleStatelessStreamableHttp.ts | 201 +++++++++--------- 1 file changed, 97 insertions(+), 104 deletions(-) diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index f1f37510..1fbdbddc 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -4,108 +4,114 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; -// Create an MCP server with implementation details -const server = new McpServer({ - name: 'stateless-streamable-http-server', - version: '1.0.0', -}, { capabilities: { logging: {} } }); +const getServer = () => { + // Create an MCP server with implementation details + const server = new McpServer({ + name: 'stateless-streamable-http-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); -// Register a simple prompt -server.prompt( - 'greeting-template', - 'A simple greeting prompt template', - { - name: z.string().describe('Name to include in greeting'), - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.`, + // Register a simple prompt + server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, }, - }, - ], - }; - } -); - -// Register a tool specifically for testing resumability -server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(10), - }, - async ({ interval, count }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); - } - catch (error) { - console.error("Error sending notification:", error); - } - // Wait for the specified interval - await sleep(interval); + ], + }; } + ); - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms`, + // Register a tool specifically for testing resumability + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); } - ], - }; - } -); + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } -// Create a simple resource at a fixed URI -server.resource( - 'greeting-resource', - 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!', - }, - ], - }; - } -); + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + + // Create a simple resource at a fixed URI + server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } + ); + return server; +} const app = express(); app.use(express.json()); -const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, -}); -// Setup routes for the server -const setupServer = async () => { - await server.connect(transport); -}; + app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); + const server = getServer(); try { - await transport.handleRequest(req, res, req.body); + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { @@ -145,28 +151,15 @@ app.delete('/mcp', async (req: Request, res: Response) => { })); }); + // Start the server const PORT = 3000; -setupServer().then(() => { - app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - }); -}).catch(error => { - console.error('Failed to set up the server:', error); - process.exit(1); +app.listen(PORT, () => { + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); }); // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); - try { - console.log(`Closing transport`); - await transport.close(); - } catch (error) { - console.error(`Error closing transport:`, error); - } - - await server.close(); - console.log('Server shutdown complete'); process.exit(0); }); \ No newline at end of file From 37fc7d2ca814bfc04ca89244978cec26b5f3f79c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 21 Apr 2025 14:56:12 +0100 Subject: [PATCH 079/103] fix check to skip session validation for stateless mode --- src/server/streamableHttp.test.ts | 2 +- src/server/streamableHttp.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index a0f2e0bb..7f9e85d8 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -288,7 +288,7 @@ describe("StreamableHTTPServerTransport", () => { }); }); - it("should reject requests without a valid session ID", async () => { + it.only("should reject requests without a valid session ID", async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); expect(response.status).toBe(400); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b17ef043..ed83076a 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -463,8 +463,8 @@ export class StreamableHTTPServerTransport implements Transport { * Returns true if the session is valid, false otherwise */ private validateSession(req: IncomingMessage, res: ServerResponse): boolean { - if (this.sessionId === undefined) { - // If the session ID is not set, the session management is disabled + if (this.sessionIdGenerator === undefined) { + // If the sessionIdGenerator ID is not set, the session management is disabled // and we don't need to validate the session ID return true; } From eeda5d630a57d45b04c5522cb733c00e9f2a6552 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 21 Apr 2025 15:13:25 +0100 Subject: [PATCH 080/103] readme update --- README.md | 32 +- .../server/jsonResponseStreamableHttp.ts | 124 +++---- src/examples/server/simpleSseServer.ts | 107 +++--- .../server/simpleStatelessStreamableHttp.ts | 3 - src/examples/server/simpleStreamableHttp.ts | 311 +++++++++--------- .../sseAndStreamableHttpCompatibleServer.ts | 91 ++--- 6 files changed, 337 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index 200cfab6..605cc725 100644 --- a/README.md +++ b/README.md @@ -312,19 +312,19 @@ For simpler use cases where session management isn't needed: const app = express(); app.use(express.json()); -const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // set to undefined for stateless servers -}); - -// Setup routes for the server -const setupServer = async () => { - await server.connect(transport); -}; - app.post('/mcp', async (req: Request, res: Response) => { - console.log('Received MCP request:', req.body); try { - await transport.handleRequest(req, res, req.body); + const server = getServer(); + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { @@ -364,15 +364,11 @@ app.delete('/mcp', async (req: Request, res: Response) => { })); }); + // Start the server const PORT = 3000; -setupServer().then(() => { - app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - }); -}).catch(error => { - console.error('Failed to set up the server:', error); - process.exit(1); +app.listen(PORT, () => { + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); }); ``` diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index bcd6a960..02d8c2de 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -5,74 +5,78 @@ import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, isInitializeRequest } from '../../types.js'; + // Create an MCP server with implementation details -const server = new McpServer({ - name: 'json-response-streamable-http-server', - version: '1.0.0', -}, { - capabilities: { - logging: {}, - } -}); +const getServer = () => { + const server = new McpServer({ + name: 'json-response-streamable-http-server', + version: '1.0.0', + }, { + capabilities: { + logging: {}, + } + }); -// Register a simple tool that returns a greeting -server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!`, - }, - ], - }; - } -); + // Register a simple tool that returns a greeting + server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + } + ); -// Register a tool that sends multiple greetings with notifications -server.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + // Register a tool that sends multiple greetings with notifications + server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); - await sleep(1000); // Wait 1 second before first greeting + await sleep(1000); // Wait 1 second before first greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); - await sleep(1000); // Wait another second before second greeting + await sleep(1000); // Wait another second before second greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!`, - } - ], - }; - } -); + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } + ); + return server; +} const app = express(); app.use(express.json()); @@ -104,6 +108,7 @@ app.post('/mcp', async (req: Request, res: Response) => { }); // Connect the transport to the MCP server BEFORE handling the request + const server = getServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); return; // Already handled @@ -153,6 +158,5 @@ app.listen(PORT, () => { // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); - await server.close(); process.exit(0); }); \ No newline at end of file diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index 74cdcaac..cae3be30 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -15,60 +15,63 @@ import { CallToolResult } from '../../types.js'; */ // Create an MCP server instance -const server = new McpServer({ - name: 'simple-sse-server', - version: '1.0.0', -}, { capabilities: { logging: {} } }); +const getServer = () => { + const server = new McpServer({ + name: 'simple-sse-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); -server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications', - { - interval: z.number().describe('Interval in milliseconds between notifications').default(1000), - count: z.number().describe('Number of notifications to send').default(10), - }, - async ({ interval, count }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; - // Send the initial notification - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Starting notification stream with ${count} messages every ${interval}ms` - } - }); - - // Send periodic notifications - while (counter < count) { - counter++; - await sleep(interval); - - try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Notification #${counter} at ${new Date().toISOString()}` - } - }); - } - catch (error) { - console.error("Error sending notification:", error); - } - } - - return { - content: [ - { - type: 'text', - text: `Completed sending ${count} notifications every ${interval}ms`, + // Send the initial notification + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Starting notification stream with ${count} messages every ${interval}ms` } - ], - }; - } -); + }); + + // Send periodic notifications + while (counter < count) { + counter++; + await sleep(interval); + + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + } + + return { + content: [ + { + type: 'text', + text: `Completed sending ${count} notifications every ${interval}ms`, + } + ], + }; + } + ); + return server; +}; const app = express(); app.use(express.json()); @@ -96,6 +99,7 @@ app.get('/mcp', async (req: Request, res: Response) => { }; // Connect the transport to the MCP server + const server = getServer(); await server.connect(transport); // Start the SSE transport to begin streaming @@ -163,7 +167,6 @@ process.on('SIGINT', async () => { console.error(`Error closing transport for session ${sessionId}:`, error); } } - await server.close(); console.log('Server shutdown complete'); process.exit(0); }); \ No newline at end of file diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 1fbdbddc..6fb2ae83 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -96,9 +96,6 @@ const getServer = () => { const app = express(); app.use(express.json()); - - - app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); try { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 2b25f4b2..d94908bd 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -7,164 +7,167 @@ import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResul import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; // Create an MCP server with implementation details -const server = new McpServer({ - name: 'simple-streamable-http-server', - version: '1.0.0', -}, { capabilities: { logging: {} } }); +const getServer = () => { + const server = new McpServer({ + name: 'simple-streamable-http-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); -// Log the capability invocation details -server.onCapabilityChange((event) => { - switch (event.action) { - case 'invoked': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' started`); - break; - case 'completed': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' completed in ${event.durationMs}ms`); - break; - case 'error': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' failed in ${event.durationMs}ms: ${event.error}`); - break; - } -}); - -// Register a simple tool that returns a greeting -server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!`, - }, - ], - }; - } -); - -// Register a tool that sends multiple greetings with notifications -server.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); - - await sleep(1000); // Wait 1 second before first greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); - - await sleep(1000); // Wait another second before second greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!`, - } - ], - }; - } -); - -// Register a simple prompt -server.prompt( - 'greeting-template', - 'A simple greeting prompt template', - { - name: z.string().describe('Name to include in greeting'), - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please greet ${name} in a friendly manner.`, - }, - }, - ], - }; - } -); - -// Register a tool specifically for testing resumability -server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50), - }, - async ({ interval, count }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); - } - catch (error) { - console.error("Error sending notification:", error); - } - // Wait for the specified interval - await sleep(interval); + // Log the capability invocation details + server.onCapabilityChange((event) => { + switch (event.action) { + case 'invoked': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' started`); + break; + case 'completed': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' completed in ${event.durationMs}ms`); + break; + case 'error': + console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' failed in ${event.durationMs}ms: ${event.error}`); + break; } + }); - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms`, + // Register a simple tool that returns a greeting + server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + } + ); + + // Register a tool that sends multiple greetings with notifications + server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); + + await sleep(1000); // Wait 1 second before first greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); + + await sleep(1000); // Wait another second before second greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } + ); + + // Register a simple prompt + server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, + }, + ], + }; + } + ); + + // Register a tool specifically for testing resumability + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); } - ], - }; - } -); + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } -// Create a simple resource at a fixed URI -server.resource( - 'greeting-resource', - 'https://example.com/greetings/default', - { mimeType: 'text/plain' }, - async (): Promise => { - return { - contents: [ - { - uri: 'https://example.com/greetings/default', - text: 'Hello, world!', - }, - ], - }; - } -); + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + + // Create a simple resource at a fixed URI + server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } + ); + return server; +}; const app = express(); app.use(express.json()); @@ -207,6 +210,7 @@ app.post('/mcp', async (req: Request, res: Response) => { // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport + const server = getServer(); await server.connect(transport); await transport.handleRequest(req, res, req.body); @@ -303,7 +307,6 @@ process.on('SIGINT', async () => { console.error(`Error closing transport for session ${sessionId}:`, error); } } - await server.close(); console.log('Server shutdown complete'); process.exit(0); }); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index d644dd07..7a0ab04e 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -18,52 +18,54 @@ import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; * - /messages: The deprecated POST endpoint for older clients (POST to send messages) */ +const getServer = () => { + const server = new McpServer({ + name: 'backwards-compatible-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); -const server = new McpServer({ - name: 'backwards-compatible-server', - version: '1.0.0', -}, { capabilities: { logging: {} } }); + // Register a simple tool that sends notifications over time + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; -// Register a simple tool that sends notifications over time -server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50), - }, - async ({ interval, count }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); - } - catch (error) { - console.error("Error sending notification:", error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms`, + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); } - ], - }; - } -); + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + return server; +}; // Create Express application const app = express(); @@ -125,6 +127,7 @@ app.all('/mcp', async (req: Request, res: Response) => { }; // Connect the transport to the MCP server + const server = getServer(); await server.connect(transport); } else { // Invalid request - no session ID or not initialization request @@ -167,6 +170,7 @@ app.get('/sse', async (req: Request, res: Response) => { res.on("close", () => { delete transports[transport.sessionId]; }); + const server = getServer(); await server.connect(transport); }); @@ -237,7 +241,6 @@ process.on('SIGINT', async () => { console.error(`Error closing transport for session ${sessionId}:`, error); } } - await server.close(); console.log('Server shutdown complete'); process.exit(0); }); \ No newline at end of file From 6050aa9f0537a965c19d36c443f5ab6a1fceab0c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 21 Apr 2025 15:31:38 +0100 Subject: [PATCH 081/103] lint --- .../client/multipleClientsParallel.ts | 9 +- .../client/parallelToolCallsClient.ts | 14 +- .../streamableHttpBatchMessages.test.ts | 252 ------------------ src/server/streamableHttp.test.ts | 2 +- 4 files changed, 13 insertions(+), 264 deletions(-) delete mode 100644 src/integration-tests/streamableHttpBatchMessages.test.ts diff --git a/src/examples/client/multipleClientsParallel.ts b/src/examples/client/multipleClientsParallel.ts index 90b9d4ea..cc01fc06 100644 --- a/src/examples/client/multipleClientsParallel.ts +++ b/src/examples/client/multipleClientsParallel.ts @@ -4,6 +4,7 @@ import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, + CallToolResult, } from '../../types.js'; /** @@ -23,12 +24,12 @@ interface ClientConfig { id: string; name: string; toolName: string; - toolArguments: Record; + toolArguments: Record; } -async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: any }> { +async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { console.log(`[${config.id}] Creating client: ${config.name}`); - + const client = new Client({ name: config.name, version: '1.0.0' @@ -101,7 +102,7 @@ async function main(): Promise { } }, { - id: 'client2', + id: 'client2', name: 'basic-client-2', toolName: 'start-notification-stream', toolArguments: { diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts index 8a53ade9..3783992d 100644 --- a/src/examples/client/parallelToolCallsClient.ts +++ b/src/examples/client/parallelToolCallsClient.ts @@ -3,9 +3,9 @@ import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; import { ListToolsRequest, ListToolsResultSchema, - CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, + CallToolResult, } from '../../types.js'; /** @@ -57,11 +57,11 @@ async function main(): Promise { // 2. Start multiple notification tools in parallel console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); const toolResults = await startParallelNotificationTools(client); - + // Log the results from each tool call for (const [caller, result] of Object.entries(toolResults)) { console.log(`\n=== Tool result for ${caller} ===`); - result.content.forEach((item: { type: string; text: any; }) => { + result.content.forEach((item: { type: string; text?: string; }) => { if (item.type === 'text') { console.log(` ${item.text}`); } else { @@ -113,7 +113,7 @@ async function listTools(client: Client): Promise { * Start multiple notification tools in parallel with different configurations * Each tool call includes a caller parameter to identify its notifications */ -async function startParallelNotificationTools(client: Client): Promise> { +async function startParallelNotificationTools(client: Client): Promise> { try { // Define multiple tool calls with different configurations const toolCalls = [ @@ -162,7 +162,7 @@ async function startParallelNotificationTools(client: Client): Promise { console.log(`Starting tool call for ${caller}...`); @@ -176,9 +176,9 @@ async function startParallelNotificationTools(client: Client): Promise = {}; + const resultsByTool: Record = {}; results.forEach(({ caller, result }) => { resultsByTool[caller] = result; }); diff --git a/src/integration-tests/streamableHttpBatchMessages.test.ts b/src/integration-tests/streamableHttpBatchMessages.test.ts deleted file mode 100644 index dc79e3de..00000000 --- a/src/integration-tests/streamableHttpBatchMessages.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Integration tests for batch messaging in StreamableHttp transport - * - * This test suite focuses specifically on the requirement from the spec: - * "The body of the POST request MUST be one of the following: - * - A single JSON-RPC request, notification, or response - * - An array batching one or more requests and/or notifications - * - An array batching one or more responses" - */ - -import { createServer, type Server } from "node:http"; -import { AddressInfo } from "node:net"; -import { randomUUID } from "node:crypto"; -import { StreamableHTTPServerTransport } from "../server/streamableHttp.js"; -import { McpServer } from "../server/mcp.js"; -import { Client } from "../client/index.js"; -import { CallToolResult, CallToolResultSchema, JSONRPCMessage } from "../types.js"; -import { z } from "zod"; -import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; -describe("StreamableHttp Batch Messaging - SSE", () => { - let server: Server; - let client: Client; - - // Just a basic server and client for testing - beforeEach(async () => { - // Create MCP server with test tools - const mcpServer = new McpServer( - { name: "batch-test-server", version: "1.0.0" }, - { capabilities: { logging: {} } } - ); - - // Simple greeting tool - mcpServer.tool( - "greet", - "A simple greeting tool", - { name: z.string().describe("Name to greet") }, - async ({ name }): Promise => { - return { content: [{ type: "text", text: `Hello, ${name}!` }] }; - } - ); - - // Server transport with session management - const serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - // Connect transport to server - await mcpServer.connect(serverTransport); - - // Create HTTP server - server = createServer(async (req, res) => { - await serverTransport.handleRequest(req, res); - }); - - // Start server on random port - const baseUrl = await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - // Create client that connects to the server - const transport = new StreamableHTTPClientTransport(baseUrl); - client = new Client({ name: "test-client", version: "1.0.0" }); - await client.connect(transport); - - }); - - afterEach(async () => { - await client.close(); - await new Promise((resolve) => server.close(() => resolve())); - }); - - /** - * 1. Test sending a single JSON-RPC request - */ - it("handles a single request", async () => { - const result = await client.callTool({ - name: "greet", - arguments: { - name: "user" - } - }); - - expect(result).toHaveProperty("content"); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content).toHaveLength(1); - - // Type assertion to handle the unknown type - const content = result.content as Array<{ type: string, text: string }>; - expect(content[0]).toHaveProperty("type", "text"); - expect(content[0]).toHaveProperty("text", "Hello, user!"); - - }); - - /** - * 1. Test sending a single JSON-RPC request - */ - it("handles a single request with request", async () => { - const result = await client.request( - { - method: "tools/call", - params: { - name: "greet", - arguments: { - name: "user" - } - }, - }, - CallToolResultSchema, - ); - - - expect(result).toHaveProperty("content"); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content).toHaveLength(1); - - // Type assertion to handle the unknown type - const content = result.content as Array<{ type: string, text: string }>; - expect(content[0]).toHaveProperty("type", "text"); - expect(content[0]).toHaveProperty("text", "Hello, user!"); - - }); - - // it("handles an array batching one or more requests and/or notifications", async () => { - // const result = await client.request({ - - // }); - - // }); - - -}); - -describe("StreamableHttp Batch Messaging - JSON response", () => { - let server: Server; - let client: Client; - - // Just a basic server and client for testing - beforeEach(async () => { - // Create MCP server with test tools - const mcpServer = new McpServer( - { name: "batch-test-server", version: "1.0.0" }, - { capabilities: { logging: {} } } - ); - - // Simple greeting tool - mcpServer.tool( - "greet", - "A simple greeting tool", - { name: z.string().describe("Name to greet") }, - async ({ name }): Promise => { - return { content: [{ type: "text", text: `Hello, ${name}!` }] }; - } - ); - - // Server transport with session management - const serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - - }); - - // Connect transport to server - await mcpServer.connect(serverTransport); - - // Create HTTP server - server = createServer(async (req, res) => { - await serverTransport.handleRequest(req, res); - }); - - // Start server on random port - const baseUrl = await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - // Create client that connects to the server - const transport = new StreamableHTTPClientTransport(baseUrl); - client = new Client({ name: "test-client", version: "1.0.0" }); - await client.connect(transport); - - }); - - afterEach(async () => { - await client.close(); - await new Promise((resolve) => server.close(() => resolve())); - }); - - /** - * 1. Test sending a single JSON-RPC request - */ - it("handles a single request", async () => { - const result = await client.callTool({ - name: "greet", - arguments: { - name: "user" - } - }); - - expect(result).toHaveProperty("content"); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content).toHaveLength(1); - - // Type assertion to handle the unknown type - const content = result.content as Array<{ type: string, text: string }>; - expect(content[0]).toHaveProperty("type", "text"); - expect(content[0]).toHaveProperty("text", "Hello, user!"); - - }); - - /** - * 1. Test sending a single JSON-RPC request - */ - it("handles a single request with request", async () => { - const result = await client.request( - { - method: "tools/call", - params: { - name: "greet", - arguments: { - name: "user" - } - }, - }, - CallToolResultSchema, - ); - - - expect(result).toHaveProperty("content"); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content).toHaveLength(1); - - // Type assertion to handle the unknown type - const content = result.content as Array<{ type: string, text: string }>; - expect(content[0]).toHaveProperty("type", "text"); - expect(content[0]).toHaveProperty("text", "Hello, user!"); - - }); - - // it("handles an array batching one or more requests and/or notifications", async () => { - // const result = await client.request({ - - // }); - - // }); - - -}); \ No newline at end of file diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 7f9e85d8..a0f2e0bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -288,7 +288,7 @@ describe("StreamableHTTPServerTransport", () => { }); }); - it.only("should reject requests without a valid session ID", async () => { + it("should reject requests without a valid session ID", async () => { const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); expect(response.status).toBe(400); From ded0ce3a8d63be72fde26837725455a6cc49bc7a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 22 Apr 2025 10:45:17 +0100 Subject: [PATCH 082/103] improve readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 49ca11d2..e5a283d9 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,10 @@ const app = express(); app.use(express.json()); app.post('/mcp', async (req: Request, res: Response) => { + // In stateless mode, create a new instance of transport and server for each request + // to ensure complete isolation. A single instance would cause request ID collisions + // when multiple clients connect concurrently. + try { const server = getServer(); const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ From a0276be738bceb19bbc60ec18d6ab53a126e42c5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 22 Apr 2025 11:06:01 +0100 Subject: [PATCH 083/103] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d72e6e2..f24053c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.10.1", + "version": "1.10.2", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 21f492f5317b9a6321c9488b308f72f3acf9cad6 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Tue, 22 Apr 2025 11:35:49 +0100 Subject: [PATCH 084/103] Revert "Merge pull request #334 from m-paternostro/mp/notifier" This reverts commit 3ef902328a940b06f666ac02422bbba1be4541ec, reversing changes made to 5b6d35eb12bebfe48f8fd160b56575ec8eac7a21. --- src/examples/server/simpleStreamableHttp.ts | 17 +- src/server/index.ts | 7 - src/server/mcp.test.ts | 414 +------- src/server/mcp.ts | 1027 ++----------------- src/shared/eventNotifier.test.ts | 145 --- src/shared/eventNotifier.ts | 152 --- 6 files changed, 61 insertions(+), 1701 deletions(-) delete mode 100644 src/shared/eventNotifier.test.ts delete mode 100644 src/shared/eventNotifier.ts diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index d94908bd..969655e3 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,8 +1,8 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { z } from 'zod'; import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; @@ -13,21 +13,6 @@ const getServer = () => { version: '1.0.0', }, { capabilities: { logging: {} } }); - // Log the capability invocation details - server.onCapabilityChange((event) => { - switch (event.action) { - case 'invoked': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' started`); - break; - case 'completed': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' completed in ${event.durationMs}ms`); - break; - case 'error': - console.log(`${event.capabilityType} invocation ${event.invocationIndex}: '${event.capabilityName}' failed in ${event.durationMs}ms: ${event.error}`); - break; - } - }); - // Register a simple tool that returns a greeting server.tool( 'greet', diff --git a/src/server/index.ts b/src/server/index.ts index befaade2..3901099e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -104,13 +104,6 @@ export class Server< ); } - /** - * The server's name and version. - */ - getVersion(): { readonly name: string; readonly version: string } { - return this._serverInfo; - } - /** * Registers new capabilities. This can only be called before connecting to a transport. * diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 4e6270cc..eaac5c71 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -14,7 +14,7 @@ import { LoggingMessageNotificationSchema, Notification, } from "../types.js"; -import { ResourceTemplate, CapabilityEvent } from "./mcp.js"; +import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; import { UriTemplate } from "../shared/uriTemplate.js"; @@ -26,10 +26,6 @@ describe("McpServer", () => { }); expect(mcpServer.server).toBeDefined(); - expect(mcpServer.server.getVersion()).toEqual({ - name: "test server", - version: "1.0", - }); }); test("should allow sending notifications via Server", async () => { @@ -866,95 +862,6 @@ describe("tool()", () => { ), ).rejects.toThrow(/Tool nonexistent-tool not found/); }); - - test("should include duration in completed and error events", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - events.push(event); - }); - - // Register tools with different behaviors - mcpServer.tool("success-tool", async () => { - // Simulate some work - await new Promise(resolve => setTimeout(resolve, 10)); - return { content: [{ type: "text", text: "Success" }] }; - }); - - mcpServer.tool("error-tool", async () => { - // Simulate some work - await new Promise(resolve => setTimeout(resolve, 10)); - throw new Error("Simulated error"); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.connect(serverTransport), - ]); - - // Clear events from registration - events.length = 0; - - // Call the success tool - await client.request( - { - method: "tools/call", - params: { - name: "success-tool", - }, - }, - CallToolResultSchema, - ); - - // Find the completed event - const completedEvent = events.find(e => - e.capabilityType === "tool" && - e.capabilityName === "success-tool" && - e.action === "completed" - ) as Extract; - - expect(completedEvent).toBeDefined(); - expect(completedEvent.durationMs).toBeDefined(); - expect(completedEvent.durationMs).toBeGreaterThan(0); - - // Clear events - events.length = 0; - - // Call the error tool - await client.request( - { - method: "tools/call", - params: { - name: "error-tool", - }, - }, - CallToolResultSchema, - ); - - // Find the error event - const errorEvent = events.find(e => - e.capabilityType === "tool" && - e.capabilityName === "error-tool" && - e.action === "error" - ) as Extract; - - expect(errorEvent).toBeDefined(); - expect(errorEvent.durationMs).toBeDefined(); - expect(errorEvent.durationMs).toBeGreaterThan(0); - - subscription.close(); - }); }); describe("resource()", () => { @@ -2605,322 +2512,3 @@ describe("prompt()", () => { expect(result.completion.total).toBe(1); }); }); - -describe("CapabilityEvents", () => { - test("should emit capability events when registering and interacting with tools", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - events.push(event); - }); - - // Register a tool (should trigger "added" event) - const tool = mcpServer.tool("test-tool", async () => ({ - content: [{ type: "text", text: "Test response" }], - })); - - expect(events.length).toBeGreaterThan(0); - expect(events[0]).toMatchObject({ - serverInfo: { name: "test server", version: "1.0" }, - capabilityType: "tool", - capabilityName: "test-tool", - action: "added", - }); - - // Update the tool (should trigger "updated" event) - tool.update({ - description: "Updated description", - }); - - expect(events.some(e => - e.capabilityType === "tool" && - e.capabilityName === "test-tool" && - e.action === "updated" - )).toBe(true); - - // Disable the tool (should trigger "disabled" event) - tool.disable(); - - expect(events.some(e => - e.capabilityType === "tool" && - e.capabilityName === "test-tool" && - e.action === "disabled" - )).toBe(true); - - // Enable the tool (should trigger "enabled" event) - tool.enable(); - - expect(events.some(e => - e.capabilityType === "tool" && - e.capabilityName === "test-tool" && - e.action === "enabled" - )).toBe(true); - - // Clean up - subscription.close(); - }); - - test("should emit capability events when tools are invoked", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - events.push(event); - }); - - mcpServer.tool("test-tool", async () => ({ - content: [{ type: "text", text: "Test response" }], - })); - - // Clear events from registration - events.length = 0; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.connect(serverTransport), - ]); - - await client.request( - { - method: "tools/call", - params: { - name: "test-tool", - }, - }, - CallToolResultSchema, - ); - - // Should have "invoked" and "completed" events - expect(events.some(e => - e.capabilityType === "tool" && - e.capabilityName === "test-tool" && - e.action === "invoked" - )).toBe(true); - - expect(events.some(e => - e.capabilityType === "tool" && - e.capabilityName === "test-tool" && - e.action === "completed" - )).toBe(true); - - // The invoked and completed events should have the same invocationIndex - const invokedEvent = events.find(e => e.action === "invoked"); - const completedEvent = events.find(e => e.action === "completed"); - expect(invokedEvent?.invocationIndex).toBe(completedEvent?.invocationIndex); - - subscription.close(); - }); - - test("should emit capability events for resources", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - events.push(event); - }); - - // Register a resource - const resource = mcpServer.resource("test-resource", "test://resource", async () => ({ - contents: [{ uri: "test://resource", text: "Test content" }], - })); - - expect(events.some(e => - e.capabilityType === "resource" && - e.capabilityName === "test-resource" && - e.action === "added" - )).toBe(true); - - // Clear events from registration - events.length = 0; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.connect(serverTransport), - ]); - - // Read the resource - await client.request( - { - method: "resources/read", - params: { - uri: "test://resource", - }, - }, - ReadResourceResultSchema, - ); - - // Should have "invoked" event - expect(events.some(e => - e.capabilityType === "resource" && - e.capabilityName === "test://resource" && - e.action === "invoked" - )).toBe(true); - - // Clear events - events.length = 0; - - // Remove the resource - resource.remove(); - - // Should have "removed" event - expect(events.some(e => - e.capabilityType === "resource" && - e.capabilityName === "test-resource" && - e.action === "removed" - )).toBe(true); - - subscription.close(); - }); - - test("should emit capability events for prompts", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - events.push(event); - }); - - // Register a prompt - mcpServer.prompt("test-prompt", async () => ({ - messages: [ - { - role: "assistant", - content: { - type: "text", - text: "Test response", - }, - }, - ], - })); - - expect(events.some(e => - e.capabilityType === "prompt" && - e.capabilityName === "test-prompt" && - e.action === "added" - )).toBe(true); - - // Clear events from registration - events.length = 0; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.connect(serverTransport), - ]); - - // Get the prompt - await client.request( - { - method: "prompts/get", - params: { - name: "test-prompt", - }, - }, - GetPromptResultSchema, - ); - - // Should have "invoked" and "completed" events - expect(events.some(e => - e.capabilityType === "prompt" && - e.capabilityName === "test-prompt" && - e.action === "invoked" - )).toBe(true); - - expect(events.some(e => - e.capabilityType === "prompt" && - e.capabilityName === "test-prompt" && - e.action === "completed" - )).toBe(true); - - subscription.close(); - }); - - test("should emit error events when tool execution fails", async () => { - const mcpServer = new McpServer({ - name: "test server", - version: "1.0", - }); - - const client = new Client({ - name: "test client", - version: "1.0", - }); - - const events: Array = []; - const subscription = mcpServer.onCapabilityChange((event) => { - // Only capture error events - if (event.action === "error") { - events.push(event); - } - }); - - mcpServer.tool("error-tool", async () => { - throw new Error("Simulated error"); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([ - client.connect(clientTransport), - mcpServer.connect(serverTransport), - ]); - - // Call the tool that throws an error - await client.request( - { - method: "tools/call", - params: { - name: "error-tool", - }, - }, - CallToolResultSchema, - ); - - // Should have error event - expect(events.length).toBe(1); - expect(events[0]).toMatchObject({ - capabilityType: "tool", - capabilityName: "error-tool", - action: "error", - }); - - // Type assert to the specific variant of CapabilityEvent that has the error property - const errorEvent = events[0] as Extract; - expect(errorEvent.error).toBeDefined(); - - subscription.close(); - }); -}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index b713d0a2..652f9774 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -44,7 +44,6 @@ import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; import { RequestHandlerExtra } from "../shared/protocol.js"; import { Transport } from "../shared/transport.js"; -import { createEventNotifier } from "../shared/eventNotifier.js"; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. @@ -64,14 +63,6 @@ export class McpServer { private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - private _onCapabilityChange = createEventNotifier(); - /** Counter for unique resource invocation indexes, used to correlate resource invocation events */ - private _resourceInvocationIndex = 0; - /** Counter for unique tool invocation indexes, used to correlate tool invocation events */ - private _toolInvocationIndex = 0; - /** Counter for unique prompt invocation indexes, used to correlate prompt invocation events */ - private _promptInvocationIndex = 0; - constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); } @@ -89,40 +80,16 @@ export class McpServer { * Closes the connection. */ async close(): Promise { - this._onCapabilityChange.close(); await this.server.close(); } - /** - * Event notifier for capability changes. Listeners will be notified when capabilities are added, updated, removed, - * enabled, disabled, invoked, completed, or when errors occur. - * - * This provides a way to monitor and respond to all capability-related activities in the server, - * including both lifecycle changes and invocation events. Each capability type (resource, tool, prompt) - * maintains its own sequence of invocation indexes, which can be used to correlate invocations - * with their completions or errors. - * - * @example - * const subscription = server.onCapabilityChange((event) => { - * if (event.action === "invoked") { - * console.log(`${event.capabilityType} ${event.capabilityName} invoked with index ${event.invocationIndex}`); - * } else if (event.action === "completed" || event.action === "error") { - * console.log(`${event.capabilityType} operation completed in ${event.durationMs}ms`); - * } - * }); - * - * // Later, to stop listening: - * subscription.close(); - */ - public readonly onCapabilityChange = this._onCapabilityChange.onEvent; - private _toolHandlersInitialized = false; private setToolRequestHandlers() { if (this._toolHandlersInitialized) { return; } - + this.server.assertCanSetRequestHandler( ListToolsRequestSchema.shape.method.value, ); @@ -168,35 +135,11 @@ export class McpServer { ); } - const invocationIndex = this._toolInvocationIndex++; - const startTime = performance.now(); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "invoked", - invocationIndex, - arguments: request.params.arguments, - })); - if (!tool.enabled) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } if (tool.inputSchema) { @@ -204,51 +147,17 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Invalid arguments for tool ${request.params.name}: ${parseResult.error.message}`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } const args = parseResult.data; const cb = tool.callback as ToolCallback; try { - return await Promise.resolve(cb(args, extra)).then((result) => { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; - }); + return await Promise.resolve(cb(args, extra)); } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - return { content: [ { @@ -262,30 +171,8 @@ export class McpServer { } else { const cb = tool.callback as ToolCallback; try { - const result = await Promise.resolve(cb(extra)); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; + return await Promise.resolve(cb(extra)); } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - return { content: [ { @@ -348,79 +235,24 @@ export class McpServer { ); } - const invocationIndex = this._promptInvocationIndex++; - const startTime = performance.now(); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: ref.name, - action: "invoked", - invocationIndex, - arguments: request.params.argument.name, - })); - if (!prompt.enabled) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: ref.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } if (!prompt.argsSchema) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: ref.name, - action: "completed", - invocationIndex, - result: EMPTY_COMPLETION_RESULT, - durationMs: performance.now() - startTime, - })); - return EMPTY_COMPLETION_RESULT; } const field = prompt.argsSchema.shape[request.params.argument.name]; if (!(field instanceof Completable)) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: ref.name, - action: "completed", - invocationIndex, - result: EMPTY_COMPLETION_RESULT, - durationMs: performance.now() - startTime, - })); - return EMPTY_COMPLETION_RESULT; } const def: CompletableDef = field._def; const suggestions = await def.complete(request.params.argument.value); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: ref.name, - action: "completed", - invocationIndex, - result: suggestions, - durationMs: performance.now() - startTime, - })); - return createCompletionResult(suggestions); } @@ -432,21 +264,8 @@ export class McpServer { (t) => t.resourceTemplate.uriTemplate.toString() === ref.uri, ); - const invocationIndex = this._resourceInvocationIndex++; - const startTime = performance.now(); - if (!template) { if (this._registeredResources[ref.uri]) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: ref.uri, - action: "completed", - invocationIndex, - result: EMPTY_COMPLETION_RESULT, - durationMs: performance.now() - startTime, - })); - // Attempting to autocomplete a fixed resource URI is not an error in the spec (but probably should be). return EMPTY_COMPLETION_RESULT; } @@ -461,46 +280,11 @@ export class McpServer { request.params.argument.name, ); if (!completer) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: ref.uri, - action: "completed", - invocationIndex, - result: EMPTY_COMPLETION_RESULT, - durationMs: performance.now() - startTime, - })); - return EMPTY_COMPLETION_RESULT; } - try { - const suggestions = await completer(request.params.argument.value); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: ref.uri, - action: "completed", - invocationIndex, - result: suggestions, - durationMs: performance.now() - startTime, - })); - - return createCompletionResult(suggestions); - } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: ref.uri, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; - } + const suggestions = await completer(request.params.argument.value); + return createCompletionResult(suggestions); } private _resourceHandlersInitialized = false; @@ -583,62 +367,13 @@ export class McpServer { // First check for exact resource match const resource = this._registeredResources[uri.toString()]; if (resource) { - const invocationIndex = this._resourceInvocationIndex++; - const startTime = performance.now(); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "invoked", - invocationIndex, - })); - if (!resource.enabled) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Resource ${uri} disabled`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; - } - try { - const result = await resource.readCallback(uri, extra); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; - } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } + return resource.readCallback(uri, extra); } // Then check templates @@ -649,44 +384,7 @@ export class McpServer { uri.toString(), ); if (variables) { - const invocationIndex = this._resourceInvocationIndex++; - const startTime = performance.now(); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "invoked", - invocationIndex, - })); - - try { - const result = await template.readCallback(uri, variables, extra); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; - } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: uri.toString(), - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; - } + return template.readCallback(uri, variables, extra); } } @@ -698,7 +396,7 @@ export class McpServer { ); this.setCompletionRequestHandler(); - + this._resourceHandlersInitialized = true; } @@ -752,35 +450,11 @@ export class McpServer { ); } - const invocationIndex = this._promptInvocationIndex++; - const startTime = performance.now(); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "invoked", - invocationIndex, - arguments: request.params.arguments, - })); - if (!prompt.enabled) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } if (prompt.argsSchema) { @@ -788,90 +462,24 @@ export class McpServer { request.params.arguments, ); if (!parseResult.success) { - const error = new McpError( + throw new McpError( ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${parseResult.error.message}`, ); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; } const args = parseResult.data; const cb = prompt.callback as PromptCallback; - - try { - const result = await Promise.resolve(cb(args, extra)); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; - } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; - } + return await Promise.resolve(cb(args, extra)); } else { const cb = prompt.callback as PromptCallback; - - try { - const result = await Promise.resolve(cb(extra)); - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "completed", - invocationIndex, - result, - durationMs: performance.now() - startTime, - })); - - return result; - } catch (error) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: request.params.name, - action: "error", - invocationIndex, - error, - durationMs: performance.now() - startTime, - })); - - throw error; - } + return await Promise.resolve(cb(extra)); } - } + }, ); this.setCompletionRequestHandler(); - + this._promptHandlersInitialized = true; } @@ -933,128 +541,23 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceCallback, enabled: true, - disable: () => { - if (!registeredResource.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "disabled", - })); - - registeredResource.update({ enabled: false }); - }, - enable: () => { - if (registeredResource.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "enabled", - })); - - registeredResource.update({ enabled: true }); - }, - remove: () => { - if (uriOrTemplate === null) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "removed", - })); - - registeredResource.update({ uri: null }); - }, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), update: (updates) => { - let added = false; - let removed = false; - let updated = false; - let enabled = false; - - if ( - typeof updates.uri !== "undefined" && - updates.uri !== uriOrTemplate - ) { - removed = true; - delete this._registeredResources[uriOrTemplate]; - - if (updates.uri) { - added = true; - this._registeredResources[updates.uri] = registeredResource; - } + if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { + delete this._registeredResources[uriOrTemplate] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource } - - if (typeof updates.name !== "undefined" && updates.name !== name) { - updated = true; - registeredResource.name = updates.name; - } - - if (typeof updates.metadata !== "undefined" && updates.metadata !== metadata) { - updated = true; - registeredResource.metadata = updates.metadata; - } - - if (typeof updates.callback !== "undefined" && updates.callback !== registeredResource.readCallback) { - updated = true; - registeredResource.readCallback = updates.callback; - } - - if (typeof updates.enabled !== "undefined" && updates.enabled !== registeredResource.enabled) { - enabled = true; - registeredResource.enabled = updates.enabled; - } - - if (removed) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "removed", - })); - } - - if (added) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: registeredResource.name, - action: "added", - })); - } - - if (updated) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: registeredResource.name, - action: "updated", - })); - } - - if (enabled) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: registeredResource.name, - action: registeredResource.enabled ? "enabled" : "disabled", - })); - } - this.sendResourceListChanged(); + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() }, }; this._registeredResources[uriOrTemplate] = registeredResource; - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "added", - })); - this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResource; @@ -1068,136 +571,23 @@ export class McpServer { metadata, readCallback: readCallback as ReadResourceTemplateCallback, enabled: true, - disable: () => { - if (!registeredResourceTemplate.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "disabled", - })); - - registeredResourceTemplate.update({ enabled: false }); - }, - enable: () => { - if (registeredResourceTemplate.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "enabled", - })); - - registeredResourceTemplate.update({ enabled: true }); - }, - remove: () => { - if (name === null) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "removed", - })); - - registeredResourceTemplate.update({ name: null }); - }, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), update: (updates) => { - let added = false; - let removed = false; - let updated = false; - let enabled = false; - if (typeof updates.name !== "undefined" && updates.name !== name) { - removed = true; - delete this._registeredResourceTemplates[name]; - - if (updates.name) { - added = true; - this._registeredResourceTemplates[updates.name] = - registeredResourceTemplate; - } + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate } - - if (typeof updates.template !== "undefined" && updates.template !== registeredResourceTemplate.resourceTemplate) { - updated = true; - registeredResourceTemplate.resourceTemplate = updates.template; - } - - if ( - typeof updates.metadata !== "undefined" && - updates.metadata !== metadata - ) { - updated = true; - registeredResourceTemplate.metadata = updates.metadata; - } - - if ( - typeof updates.callback !== "undefined" && - updates.callback !== registeredResourceTemplate.readCallback - ) { - updated = true; - registeredResourceTemplate.readCallback = updates.callback; - } - - if ( - typeof updates.enabled !== "undefined" && - updates.enabled !== registeredResourceTemplate.enabled - ) { - enabled = true; - registeredResourceTemplate.enabled = updates.enabled; - } - - if (removed) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "removed", - })); - } - - if (added) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: updates.name || name, - action: "added", - })); - } - - if (updated) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: updates.name || name, - action: "updated", - })); - } - - if (enabled) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: updates.name || name, - action: registeredResourceTemplate.enabled ? "enabled" : "disabled", - })); - } - - this.sendResourceListChanged(); + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() }, }; this._registeredResourceTemplates[name] = registeredResourceTemplate; - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "resource", - capabilityName: name, - action: "added", - })); - this.setResourceRequestHandlers(); this.sendResourceListChanged(); return registeredResourceTemplate; @@ -1255,138 +645,23 @@ export class McpServer { paramsSchema === undefined ? undefined : z.object(paramsSchema), callback: cb, enabled: true, - disable: () => { - if (!registeredTool.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: name, - action: "disabled", - })); - - registeredTool.update({ enabled: false }); - }, - enable: () => { - if (registeredTool.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: name, - action: "enabled", - })); - - registeredTool.update({ enabled: true }); - }, - remove: () => { - if (name === null) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: name, - action: "removed", - })); - - registeredTool.update({ name: null }); - }, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), update: (updates) => { - let added = false; - let removed = false; - let updated = false; - let enabled = false; - if (typeof updates.name !== "undefined" && updates.name !== name) { - removed = true; - delete this._registeredTools[name]; - - if (updates.name) { - added = true; - this._registeredTools[updates.name] = registeredTool; - } + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool } - - if ( - typeof updates.description !== "undefined" && - updates.description !== description - ) { - updated = true; - registeredTool.description = updates.description; - } - - if ( - typeof updates.paramsSchema !== "undefined" && - updates.paramsSchema !== paramsSchema - ) { - updated = true; - registeredTool.inputSchema = z.object(updates.paramsSchema); - } - - if ( - typeof updates.callback !== "undefined" && - updates.callback !== registeredTool.callback - ) { - updated = true; - registeredTool.callback = updates.callback; - } - - if ( - typeof updates.enabled !== "undefined" && - updates.enabled !== registeredTool.enabled - ) { - enabled = true; - registeredTool.enabled = updates.enabled; - } - - if (removed) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: name, - action: "removed", - })); - } - - if (added) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: updates.name || name, - action: "added", - })); - } - - if (updated) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: updates.name || name, - action: "updated", - })); - } - - if (enabled) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: updates.name || name, - action: registeredTool.enabled ? "enabled" : "disabled", - })); - } - - this.sendToolListChanged(); + if (typeof updates.description !== "undefined") registeredTool.description = updates.description + if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled + this.sendToolListChanged() }, }; this._registeredTools[name] = registeredTool; - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "tool", - capabilityName: name, - action: "added", - })); - this.setToolRequestHandlers(); this.sendToolListChanged() @@ -1443,138 +718,23 @@ export class McpServer { argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), callback: cb, enabled: true, - disable: () => { - if (!registeredPrompt.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: name, - action: "disabled", - })); - - registeredPrompt.update({ enabled: false }); - }, - enable: () => { - if (registeredPrompt.enabled) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: name, - action: "enabled", - })); - - registeredPrompt.update({ enabled: true }); - }, - remove: () => { - if (name === null) return; - - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: name, - action: "removed", - })); - - registeredPrompt.update({ name: null }); - }, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), update: (updates) => { - let added = false; - let removed = false; - let updated = false; - let enabled = false; - if (typeof updates.name !== "undefined" && updates.name !== name) { - removed = true; - delete this._registeredPrompts[name]; - - if (updates.name) { - added = true; - this._registeredPrompts[updates.name!] = registeredPrompt; - } + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt } - - if ( - typeof updates.description !== "undefined" && - updates.description !== description - ) { - updated = true; - registeredPrompt.description = updates.description; - } - - if ( - typeof updates.argsSchema !== "undefined" && - updates.argsSchema !== argsSchema - ) { - updated = true; - registeredPrompt.argsSchema = z.object(updates.argsSchema); - } - - if ( - typeof updates.callback !== "undefined" && - updates.callback !== registeredPrompt.callback - ) { - updated = true; - registeredPrompt.callback = updates.callback; - } - - if ( - typeof updates.enabled !== "undefined" && - updates.enabled !== registeredPrompt.enabled - ) { - enabled = true; - registeredPrompt.enabled = updates.enabled; - } - - if (removed) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: name, - action: "removed", - })); - } - - if (added) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: updates.name || name, - action: "added", - })); - } - - if (updated) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: updates.name || name, - action: "updated", - })); - } - - if (enabled) { - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: updates.name || name, - action: registeredPrompt.enabled ? "enabled" : "disabled", - })); - } - - this.sendPromptListChanged(); + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() }, }; this._registeredPrompts[name] = registeredPrompt; - this._onCapabilityChange.notify(() => ({ - serverInfo: this.server.getVersion(), - capabilityType: "prompt", - capabilityName: name, - action: "added", - })); - this.setPromptRequestHandlers(); this.sendPromptListChanged() @@ -1810,72 +970,3 @@ const EMPTY_COMPLETION_RESULT: CompleteResult = { hasMore: false, }, }; - -/** - * Represents events emitted when capabilities (tools, resources, prompts) change state or are invoked. - * - * These events allow tracking the lifecycle and usage of all capabilities registered with an McpServer. - * Events include capability registration, updates, invocation, completion, and errors. - * - * Each capability type (tool, resource, prompt) maintains its own sequence of invocation indexes. - * The invocationIndex can be used to correlate "invoked" events with their corresponding - * "completed" or "error" events for the same capability type. - */ -export type CapabilityEvent = { - /** Information about the server that generated this event */ - readonly serverInfo: { readonly name: string; readonly version: string }; - /** The type of capability this event relates to */ - readonly capabilityType: "resource" | "tool" | "prompt"; - /** The name (or URI for resources) of the specific capability */ - readonly capabilityName: string; -} & ( - | { - /** - * Lifecycle events for capability registration and status changes. - * - "added": The capability was registered - * - "updated": The capability was modified - * - "removed": The capability was unregistered - * - "enabled": The capability was enabled - * - "disabled": The capability was disabled - */ - readonly action: "added" | "updated" | "removed" | "enabled" | "disabled"; - } - | { - /** Emitted when a capability is invoked */ - readonly action: "invoked"; - /** - * Monotonically increasing index for each invocation, per capability type. - * This index can be used to correlate this "invoked" event with a later - * "completed" or "error" event with the same capabilityType and invocationIndex. - */ - readonly invocationIndex: number; - /** The arguments passed to the capability, if any */ - readonly arguments?: unknown; - } - | { - /** Emitted when a capability invocation completes successfully */ - readonly action: "completed"; - /** - * The invocationIndex from the corresponding "invoked" event. - * This allows correlating the completion with its invocation. - */ - readonly invocationIndex: number; - /** The result returned by the capability, if any */ - readonly result?: unknown; - /** The duration of the operation in milliseconds, measured from invocation to completion */ - readonly durationMs?: number; - } - | { - /** Emitted when a capability invocation fails with an error */ - readonly action: "error"; - /** - * The invocationIndex from the corresponding "invoked" event. - * This allows correlating the error with its invocation. - */ - readonly invocationIndex: number; - /** The error that occurred during capability execution */ - readonly error: unknown; - /** The duration from invocation to error in milliseconds */ - readonly durationMs?: number; - } -); diff --git a/src/shared/eventNotifier.test.ts b/src/shared/eventNotifier.test.ts deleted file mode 100644 index 2923f7a0..00000000 --- a/src/shared/eventNotifier.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createEventNotifier } from "./eventNotifier.js"; - -describe("EventNotifier", () => { - let notifier: ReturnType>; - - beforeEach(() => { - notifier = createEventNotifier(); - }); - - test("should notify listeners in registration order", () => { - const events: string[] = []; - notifier.onEvent((event) => events.push(`first: ${event}`)); - notifier.onEvent((event) => events.push(`second: ${event}`)); - notifier.onEvent((event) => events.push(`third: ${event}`)); - - notifier.notify("test event"); - - expect(events).toEqual([ - "first: test event", - "second: test event", - "third: test event", - ]); - }); - - test("should not notify unsubscribed listeners", () => { - const events: string[] = []; - const subscription = notifier.onEvent((event) => events.push(event)); - - notifier.notify("first event"); - subscription.close(); - notifier.notify("second event"); - - expect(events).toEqual(["first event"]); - }); - - test("should handle function events", () => { - const events: string[] = []; - notifier.onEvent((event) => events.push(event)); - - notifier.notify(() => "dynamic event"); - - expect(events).toEqual(["dynamic event"]); - }); - - test("should handle errors through error handler", () => { - const errors: Error[] = []; - notifier.onError((error) => errors.push(error)); - - notifier.onEvent(() => { - throw new Error("test error"); - }); - - notifier.notify("test event"); - - expect(errors).toHaveLength(1); - expect(errors[0]).toBeInstanceOf(Error); - expect(errors[0].message).toBe("test error"); - }); - - test("should not notify after close", () => { - const events: string[] = []; - notifier.onEvent((event) => events.push(event)); - - notifier.notify("first event"); - notifier.close(); - notifier.notify("second event"); - - expect(events).toEqual(["first event"]); - }); - - test("should handle multiple subscriptions and unsubscriptions", () => { - const events: string[] = []; - const subscription1 = notifier.onEvent((event) => - events.push(`1: ${event}`) - ); - const subscription2 = notifier.onEvent((event) => - events.push(`2: ${event}`) - ); - - notifier.notify("first event"); - subscription1.close(); - notifier.notify("second event"); - subscription2.close(); - notifier.notify("third event"); - - expect(events).toEqual([ - "1: first event", - "2: first event", - "2: second event", - ]); - }); - - test("should handle error handler after close", () => { - const errors: Error[] = []; - notifier.onError((error) => errors.push(error)); - notifier.close(); - - notifier.onEvent(() => { - throw new Error("test error"); - }); - - notifier.notify("test event"); - - expect(errors).toHaveLength(0); - }); - - test("should clear error handler on close", () => { - const errors: Error[] = []; - notifier.onError((error) => errors.push(error)); - - // Close should clear the error handler - notifier.close(); - - // Setting up a new listener after close - notifier.onEvent(() => { - throw new Error("test error"); - }); - - // This should not trigger the error handler since the notifier is closed - notifier.notify("test event"); - - expect(errors).toHaveLength(0); - }); - - test("should use the last set error handler", () => { - const errors1: Error[] = []; - const errors2: Error[] = []; - - // First error handler - notifier.onError((error) => errors1.push(error)); - - // Second error handler should replace the first one - notifier.onError((error) => errors2.push(error)); - - notifier.onEvent(() => { - throw new Error("test error"); - }); - - notifier.notify("test event"); - - expect(errors1).toHaveLength(0); - expect(errors2).toHaveLength(1); - expect(errors2[0].message).toBe("test error"); - }); -}); diff --git a/src/shared/eventNotifier.ts b/src/shared/eventNotifier.ts deleted file mode 100644 index a1f53238..00000000 --- a/src/shared/eventNotifier.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Provides a simple, type-safe event notification implementation. This module allows components to implement the - * observer pattern with minimal boilerplate and proper type checking. - */ - -/** - * A type-safe event notifier that manages event listeners and notifications. - * - * @template T The type of events this notifier will handle - * @template E The type of error that can be handled (defaults to Error) - * - * EventNotifier provides: - * - * - Type-safe event subscriptions via `onEvent` - * - Synchronized event notifications via `notify` - * - Automatic cleanup of resources via `close` - * - Status tracking via `active` property - * - Error handling via optional error callback - */ -export type EventNotifier = { - /** - * Registers a listener function to be called when events are notified. Listeners are notified in the order they - * were registered. - * - * @example - * - * ```ts - * const notifier = createEventNotifier(); - * const subscription = notifier.onEvent((message) => { - * console.log(`Received message: ${message}`); - * }); - * - * // Later, to stop listening: - * subscription.close(); - * ``` - * - * @param listener A function that will be called with the notified event - * @returns A closeable to unregister the listener (all listeners are unregistered when the notifier is closed). - */ - onEvent: (listener: (event: T) => unknown) => { close: () => void }; - - /** - * Notifies all registered listeners with the provided event. - * - * This method: - * - * - Calls all registered listeners with the event in their registration order - * - Ignores errors thrown by listeners (they won't affect other listeners) - * - Ignores returned promises (results are not awaited) - * - Does nothing if there are no listeners - * - If the event is a function, it will be called if there are listeners and its return value will be - * used as the event. - * - * @example - * - * ```ts - * const notifier = createEventNotifier<{ type: string; value: number }>(); - * notifier.onEvent((event) => { - * console.log(`Received ${event.type} with value:`, event.value); - * }); - * - * notifier.notify({ type: 'progress', value: 75 }); - * ``` - * - * @param event The event to send to all listeners or a function that returns such event. - */ - notify: (event: T | (() => T)) => void; - - /** - * Sets an error handler for the notifier. This handler will be called when a listener throws an error. - * - * @param handler A function that will be called with any errors thrown by listeners. - */ - onError: (handler: (error: E) => void) => void; - - /** - * Closes the notifier and removes all listeners. - * - * @warning Failing to call close() on subscriptions or the notifier itself may lead to memory leaks. - */ - close: () => void; -}; - -/** - * Creates a type-safe event notifier. - * - * @example - * - * ```ts - * // Simple string event notifier - * const stringNotifier = createEventNotifier(); - * - * // Complex object event notifier - * interface TaskEvent { - * type: 'started' | 'completed' | 'failed'; - * taskId: number; - * details: { - * name: string; - * duration?: number; - * }; - * } - * const taskNotifier = createEventNotifier(); - * ``` - * - * @template T The type of events this notifier will handle - * @template E The type of error that can be handled (defaults to Error) - * @returns A new EventNotifier instance. - */ -export const createEventNotifier = (): EventNotifier => { - const listeners = new Set<(event: T) => unknown>(); - let errorHandler: ((error: E) => void) | undefined; - - return { - close: () => { - listeners.clear(); - errorHandler = undefined; - }, - - onEvent: (listener) => { - listeners.add(listener); - return { - close: () => { - listeners.delete(listener); - }, - }; - }, - - notify: (event: T | (() => T)) => { - if (!listeners.size) { - return; - } - - if (typeof event === "function") { - event = (event as () => T)(); - } - - for (const listener of listeners) { - try { - void listener(event); - } catch (error) { - if (errorHandler) { - errorHandler(error as E); - } - } - } - }, - - onError: (handler) => { - errorHandler = handler; - }, - }; -}; From d61e59defe622160c66856cda8aab166427a8304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Petras=20Rup=C5=A1ys?= Date: Tue, 22 Apr 2025 14:20:08 +0300 Subject: [PATCH 085/103] Update src/server/streamableHttp.ts Co-authored-by: ihrpr --- src/server/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bb9ea4d5..652733b7 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -40,7 +40,7 @@ export interface StreamableHTTPServerTransportOptions { * * // Stateless mode - explicitly set session ID to undefined * const statelessTransport = new StreamableHTTPServerTransport({ - * sessionIdGenerator: () => undefined, + * sessionIdGenerator: undefined, * }); * * // Using with pre-parsed request body From ad51fd0f5b472a20620d7828cd5373196c35ee56 Mon Sep 17 00:00:00 2001 From: simonedstrom96 <42982214+simonedstrom96@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:59:10 +0200 Subject: [PATCH 086/103] Update README.md to include working examples The current documentation contains code that will cause errors, they should be fixed. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index e0d68909..6de175f6 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,6 @@ app.post('/mcp', async (req, res) => { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { @@ -815,7 +814,7 @@ app.post('/messages', async (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.sse[sessionId]; if (transport) { - await transport.handlePostMessage(req, res); + await transport.handlePostMessage(req, res, req.body); } else { res.status(400).send('No transport found for sessionId'); } From 0096be432f687c8464115f2288b2bc29d343c455 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 10 Apr 2025 23:11:29 -0400 Subject: [PATCH 087/103] Add ToolAnnotations support to McpServer.tool() method --- src/examples/server/simpleStreamableHttp.ts | 93 ++++++++++++-- src/server/mcp.test.ts | 127 ++++++++++++++++++++ src/server/mcp.ts | 73 ++++++++++- src/types.ts | 61 ++++++++++ 4 files changed, 338 insertions(+), 16 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 969655e3..99e1bc1b 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -13,17 +13,88 @@ const getServer = () => { version: '1.0.0', }, { capabilities: { logging: {} } }); - // Register a simple tool that returns a greeting - server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { +// Register a simple tool that returns a greeting +server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + }, + { + title: 'Greeting Tool', + readOnlyHint: true, + openWorldHint: false + } +); + +// Register a tool that sends multiple greetings with notifications +server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); + + await sleep(1000); // Wait 1 second before first greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); + + await sleep(1000); // Wait another second before second greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + }, + { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + } +); + +// Register a simple prompt +server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Hello, ${name}!`, }, diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index c4cb0c5d..1c4671fb 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -494,6 +494,133 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("Test description"); }); + + test("should register tool with annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool("test", { title: "Test Tool", readOnlyHint: true }, async () => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + }); + + test("should register tool with params and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + { name: z.string() }, + { title: "Test Tool", readOnlyHint: true }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } } + }); + expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + }); + + test("should register tool with description, params, and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + "A tool with everything", + { name: z.string() }, + { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].description).toBe("A tool with everything"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } } + }); + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", + readOnlyHint: true, + openWorldHint: false + }); + }); test("should validate tool args", async () => { const mcpServer = new McpServer({ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 652f9774..6204eb2a 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -39,6 +39,7 @@ import { ReadResourceResult, ServerRequest, ServerNotification, + ToolAnnotations, } from "../types.js"; import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; @@ -118,6 +119,7 @@ export class McpServer { strictUnions: true, }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, }; }, ), @@ -605,21 +607,51 @@ export class McpServer { tool(name: string, description: string, cb: ToolCallback): RegisteredTool; /** - * Registers a tool `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. + * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. + * + * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ tool( name: string, - paramsSchema: Args, + paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; /** - * Registers a tool `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * Registers a tool `name` (with a description) taking either parameter schema or annotations. + * This unified overload handles both `tool(name, description, paramsSchema, cb)` and + * `tool(name, description, annotations, cb)` cases. + * + * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. + */ + tool( + name: string, + description: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with both parameter schema and annotations. + */ + tool( + name: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description, parameter schema, and annotations. */ tool( name: string, description: string, paramsSchema: Args, + annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; @@ -627,6 +659,13 @@ export class McpServer { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } + + // Helper to check if an object is a Zod schema (ZodRawShape) + const isZodRawShape = (obj: unknown): obj is ZodRawShape => { + if (typeof obj !== "object" || obj === null) return false; + // Check that at least one property is a ZodType instance + return Object.values(obj as object).some(v => v instanceof ZodType); + }; let description: string | undefined; if (typeof rest[0] === "string") { @@ -634,8 +673,29 @@ export class McpServer { } let paramsSchema: ZodRawShape | undefined; + let annotations: ToolAnnotations | undefined; + + // Handle the different overload combinations if (rest.length > 1) { - paramsSchema = rest.shift() as ZodRawShape; + // We have at least two more args before the callback + const firstArg = rest[0]; + + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + paramsSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } + } else if (typeof firstArg === "object" && firstArg !== null) { + // Not a ZodRawShape, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } } const cb = rest[0] as ToolCallback; @@ -643,6 +703,7 @@ export class McpServer { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), + annotations, callback: cb, enabled: true, disable: () => registeredTool.update({ enabled: false }), @@ -656,6 +717,7 @@ export class McpServer { if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback + if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled this.sendToolListChanged() }, @@ -853,11 +915,12 @@ export type ToolCallback = export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; + annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, enabled?: boolean }): void + update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void remove(): void }; diff --git a/src/types.ts b/src/types.ts index 8ac41372..17b485f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -752,6 +752,62 @@ export const PromptListChangedNotificationSchema = NotificationSchema.extend({ }); /* Tools */ +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + */ +export const ToolAnnotationsSchema = z + .object({ + /** + * A human-readable title for the tool. + */ + title: z.optional(z.string()), + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint: z.optional(z.boolean()), + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint: z.optional(z.boolean()), + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint: z.optional(z.boolean()), + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint: z.optional(z.boolean()), + }) + .passthrough(); + /** * Definition for a tool the client can call. */ @@ -774,6 +830,10 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), }) .passthrough(), + /** + * Optional additional tool information. + */ + annotations: z.optional(ToolAnnotationsSchema), }) .passthrough(); @@ -1246,6 +1306,7 @@ export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; /* Tools */ +export type ToolAnnotations = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; From c5e81adcd80014ab3f82c3ef1c0d9f60b76809f4 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Thu, 10 Apr 2025 23:23:41 -0400 Subject: [PATCH 088/103] Fix example to properly position tool annotations --- src/examples/server/simpleStreamableHttp.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 99e1bc1b..328e8715 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -29,21 +29,21 @@ server.tool( }, ], }; - }, - { - title: 'Greeting Tool', - readOnlyHint: true, - openWorldHint: false } ); -// Register a tool that sends multiple greetings with notifications +// Register a tool that sends multiple greetings with notifications (with annotations) server.tool( 'multi-greet', 'A tool that sends different greetings with delays between them', { name: z.string().describe('Name to greet'), }, + { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + }, async ({ name }, { sendNotification }): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -74,11 +74,6 @@ server.tool( } ], }; - }, - { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false } ); From e53608ea1912e647c8a26a34a4bc1f64db2dd418 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 22 Apr 2025 16:37:16 -0400 Subject: [PATCH 089/103] rebase and address comments --- src/examples/server/simpleStreamableHttp.ts | 95 ++++----------------- src/server/mcp.test.ts | 2 +- 2 files changed, 18 insertions(+), 79 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 328e8715..1933cc94 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -13,83 +13,17 @@ const getServer = () => { version: '1.0.0', }, { capabilities: { logging: {} } }); -// Register a simple tool that returns a greeting -server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!`, - }, - ], - }; - } -); - -// Register a tool that sends multiple greetings with notifications (with annotations) -server.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet'), - }, - { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false - }, - async ({ name }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); - - await sleep(1000); // Wait 1 second before first greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); - - await sleep(1000); // Wait another second before second greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!`, - } - ], - }; - } -); - -// Register a simple prompt -server.prompt( - 'greeting-template', - 'A simple greeting prompt template', - { - name: z.string().describe('Name to include in greeting'), - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { + // Register a simple tool that returns a greeting + server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { type: 'text', text: `Hello, ${name}!`, }, @@ -98,13 +32,18 @@ server.prompt( } ); - // Register a tool that sends multiple greetings with notifications + // Register a tool that sends multiple greetings with notifications (with annotations) server.tool( 'multi-greet', 'A tool that sends different greetings with delays between them', { name: z.string().describe('Name to greet'), }, + { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + }, async ({ name }, { sendNotification }): Promise => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 1c4671fb..c9be5c76 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -404,7 +404,7 @@ describe("tool()", () => { ]) }); - test("should register tool with args schema", async () => { + test("should register tool with params", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", From 9db5b8363c25507473b23835e70726730f8b050e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 23 Apr 2025 22:32:56 +0100 Subject: [PATCH 090/103] fix sse examples --- src/examples/server/simpleSseServer.ts | 4 ---- src/examples/server/sseAndStreamableHttpCompatibleServer.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index cae3be30..c3417920 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -102,10 +102,6 @@ app.get('/mcp', async (req: Request, res: Response) => { const server = getServer(); await server.connect(transport); - // Start the SSE transport to begin streaming - // This sends an initial 'endpoint' event with the session ID in the URL - await transport.start(); - console.log(`Established SSE stream with session ID: ${sessionId}`); } catch (error) { console.error('Error establishing SSE stream:', error); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 7a0ab04e..ded110a1 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -194,7 +194,7 @@ app.post("/messages", async (req: Request, res: Response) => { return; } if (transport) { - await transport.handlePostMessage(req, res); + await transport.handlePostMessage(req, res, req.body); } else { res.status(400).send('No transport found for sessionId'); } From 414cb11898e91e6300316f047673e2cc232d8314 Mon Sep 17 00:00:00 2001 From: Boteng Yao Date: Fri, 25 Apr 2025 01:42:36 +0000 Subject: [PATCH 091/103] fix stateless comments --- src/server/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c9051073..1ddd0ead 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -93,7 +93,7 @@ export interface StreamableHTTPServerTransportOptions { * - State is maintained in-memory (connections, message history) * * In stateless mode: - * - Session ID is only included in initialization responses + * - No Session ID is included in any responses * - No session validation is performed */ export class StreamableHTTPServerTransport implements Transport { From 868fc7b432f872e5b8abd814e6100459aca3eede Mon Sep 17 00:00:00 2001 From: Boteng Yao Date: Fri, 25 Apr 2025 03:43:05 +0000 Subject: [PATCH 092/103] fix format --- src/server/streamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c9051073..92d35d9a 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -166,7 +166,7 @@ export class StreamableHTTPServerTransport implements Transport { } // If an Mcp-Session-Id is returned by the server during initialization, - // clients using the Streamable HTTP transport MUST include it + // clients using the Streamable HTTP transport MUST include it // in the Mcp-Session-Id header on all of their subsequent HTTP requests. if (!this.validateSession(req, res)) { return; @@ -180,7 +180,7 @@ export class StreamableHTTPServerTransport implements Transport { } } - // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, + // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, // or else return HTTP 405 Method Not Allowed const headers: Record = { "Content-Type": "text/event-stream", From 67ad0bd77391cadf80fc956d5f35ccd1b9c8c393 Mon Sep 17 00:00:00 2001 From: Michael Hart Date: Fri, 25 Apr 2025 22:18:18 +1000 Subject: [PATCH 093/103] Ensure JSONRPCError messages are sent for streamable http Prior to this, any JSONRPCError message sent when `enableJsonResponse` is true would be ignored (and response.end() never called) --- src/server/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index c9051073..60a6a13b 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -587,7 +587,7 @@ export class StreamableHTTPServerTransport implements Transport { } } - if (isJSONRPCResponse(message)) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._requestResponseMap.set(requestId, message); const relatedIds = Array.from(this._requestToStreamMapping.entries()) .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) From 75eb1c431a0c972279a9bf05c3d4076bc8e25609 Mon Sep 17 00:00:00 2001 From: Marcel Samyn Date: Fri, 25 Apr 2025 14:42:59 +0200 Subject: [PATCH 094/103] Pass request _meta to request handlers extra param --- src/shared/protocol.ts | 7 +++++++ src/types.ts | 21 +++++++++++---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 2a6bb7f2..4694929d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -21,6 +21,7 @@ import { RequestId, Result, ServerCapabilities, + RequestMeta, } from "../types.js"; import { Transport, TransportSendOptions } from "./transport.js"; import { AuthInfo } from "../server/auth/types.js"; @@ -115,6 +116,11 @@ export type RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, + _meta: request.params?._meta, sendNotification: (notification) => this.notification(notification, { relatedRequestId: request.id }), diff --git a/src/types.ts b/src/types.ts index 17b485f8..daf9921e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,18 +19,18 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); +const RequestMetaSchema = z + .object({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: z.optional(ProgressTokenSchema), + }) + .passthrough(); + const BaseRequestParamsSchema = z .object({ - _meta: z.optional( - z - .object({ - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken: z.optional(ProgressTokenSchema), - }) - .passthrough(), - ), + _meta: z.optional(RequestMetaSchema), }) .passthrough(); @@ -1240,6 +1240,7 @@ type Infer = Flatten>; export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; +export type RequestMeta = Infer; export type Notification = Infer; export type Result = Infer; export type RequestId = Infer; From 3a9a6c944becba59da151d96c4a875c930121d34 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 25 Apr 2025 09:16:28 -0400 Subject: [PATCH 095/103] Update protocol version * This fixes #378 * In types.ts - set LATEST_PROTOCOL_VERSION to "2025-03-26" - add "2025-03-26" to SUPPORTED_PROTOCOL_VERSIONS * In auth.test.ts - in test "returns metadata when discovery succeeds" - expect "MCP-Protocol-Version" to be "2025-03-26" --- src/client/auth.test.ts | 4 ++-- src/types.ts | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index cc4717ec..629feab7 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -39,7 +39,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2024-11-05" + "MCP-Protocol-Version": "2025-03-26" }); }); @@ -478,4 +478,4 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); }); -}); \ No newline at end of file +}); diff --git a/src/types.ts b/src/types.ts index 17b485f8..a2b906e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2024-11-05"; +export const LATEST_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2024-11-05", "2024-10-07", ]; @@ -754,11 +755,11 @@ export const PromptListChangedNotificationSchema = NotificationSchema.extend({ /* Tools */ /** * Additional properties describing a Tool to clients. - * - * NOTE: all properties in ToolAnnotations are **hints**. - * They are not guaranteed to provide a faithful description of + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of * tool behavior (including descriptive properties like `title`). - * + * * Clients should never make tool use decisions based on ToolAnnotations * received from untrusted servers. */ @@ -771,7 +772,7 @@ export const ToolAnnotationsSchema = z /** * If true, the tool does not modify its environment. - * + * * Default: false */ readOnlyHint: z.optional(z.boolean()), @@ -779,19 +780,19 @@ export const ToolAnnotationsSchema = z /** * If true, the tool may perform destructive updates to its environment. * If false, the tool performs only additive updates. - * + * * (This property is meaningful only when `readOnlyHint == false`) - * + * * Default: true */ destructiveHint: z.optional(z.boolean()), /** - * If true, calling the tool repeatedly with the same arguments + * If true, calling the tool repeatedly with the same arguments * will have no additional effect on the its environment. - * + * * (This property is meaningful only when `readOnlyHint == false`) - * + * * Default: false */ idempotentHint: z.optional(z.boolean()), @@ -801,7 +802,7 @@ export const ToolAnnotationsSchema = z * entities. If false, the tool's domain of interaction is closed. * For example, the world of a web search tool is open, whereas that * of a memory tool is not. - * + * * Default: true */ openWorldHint: z.optional(z.boolean()), From 1e9d5d548a4f095c7398e506d9fa7f18b34b30a4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 25 Apr 2025 09:28:16 -0400 Subject: [PATCH 096/103] Update protocol version * Added types.test.ts - "should have correct latest protocol version" - "should have correct supported protocol versions" --- src/types.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/types.test.ts diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 00000000..bdb5ffb3 --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,17 @@ +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS} from "./types.js"; + +describe("Types", () => { + + test("should have correct latest protocol version", () => { + expect(LATEST_PROTOCOL_VERSION).toBeDefined(); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + }); + test("should have correct supported protocol versions", () => { + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeInstanceOf(Array); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-11-05"); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); + }); + +}); From 84dc914231b91289401e446b16fb456b65c0a3e1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 25 Apr 2025 09:29:29 -0400 Subject: [PATCH 097/103] Update protocol version * tidy types.test.ts --- src/types.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.test.ts b/src/types.test.ts index bdb5ffb3..0fbc003d 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -1,4 +1,4 @@ -import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS} from "./types.js"; +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; describe("Types", () => { From eb486d5cbc6aa7d6bf0b886a650b57a9a9f14f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Fri, 25 Apr 2025 16:27:58 +0200 Subject: [PATCH 098/103] Add auth flow on 401 during POST request test case on StreamableHTTP transport --- src/client/streamableHttp.test.ts | 37 ++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 9e8efa52..f748a2be 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,12 +1,24 @@ import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; +import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { JSONRPCMessage } from "../types.js"; describe("StreamableHTTPClientTransport", () => { let transport: StreamableHTTPClientTransport; + let mockAuthProvider: jest.Mocked; beforeEach(() => { - transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + mockAuthProvider = { + get redirectUrl() { return "http://localhost/callback"; }, + get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; }, + clientInformation: jest.fn(() => ({ client_id: "test-client-id", client_secret: "test-client-secret" })), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider }); jest.spyOn(global, "fetch"); }); @@ -497,4 +509,27 @@ describe("StreamableHTTPClientTransport", () => { expect(getDelay(10)).toBe(5000); }); + it("attempts auth flow on 401 during POST request", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }) + .mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); + }); }); From 73b476d60dff043561fe13d2f1ca8b313f627f24 Mon Sep 17 00:00:00 2001 From: archer <545436317@qq.com> Date: Mon, 28 Apr 2025 12:35:11 +0800 Subject: [PATCH 099/103] update usecase --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6de175f6..c9102260 100644 --- a/README.md +++ b/README.md @@ -321,13 +321,13 @@ app.post('/mcp', async (req: Request, res: Response) => { const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); res.on('close', () => { console.log('Request closed'); transport.close(); server.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { From cada5a4266709351ef4259e8770e9b9df6e6d07f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 28 Apr 2025 17:58:47 -0400 Subject: [PATCH 100/103] Add client/server version negotiation tests without mocks * In src/client/index.test.ts added tests: - "should connect new client to old, supported server version" - should negotiate version when client is old, and newer server supports its version" - "should throw when client is old, and server doesn't support its version" --- src/client/index.test.ts | 195 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 36dd6518..6e1c442f 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -165,6 +165,201 @@ test("should reject unsupported protocol version", async () => { expect(clientTransport.close).toHaveBeenCalled(); }); +test("should connect new client to old, supported server version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: OLD_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "old server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "new client", + version: "1.0", + protocolVersion: LATEST_PROTOCOL_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // These should work + // Connection should succeed with the older version + expect(client.getServerVersion()).toEqual({ + name: "old server", + version: "1.0", + }); +}); + +test("should negotiate version when client is old, and newer server supports its version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: "new server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "new server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "old client", + version: "1.0", + protocolVersion: OLD_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + // These should work + // Connection should succeed with the older version + expect(client.getServerVersion()).toEqual({ + name: "new server", + version: "1.0", + }); +}); + +test("should throw when client is old, and server doesn't support its version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const FUTURE_VERSION = "FUTURE_VERSION"; + const server = new Server( + { + name: "new server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: FUTURE_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "new server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "old client", + version: "1.0", + protocolVersion: OLD_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + let closed = false; + clientTransport.onerror = () => {closed = true}; + + await Promise.all([ + expect(client.connect(clientTransport)).rejects.toThrow( + "Server's protocol version is not supported: FUTURE_VERSION" + ), + server.connect(serverTransport), + ]); + +}); + test("should respect server capabilities", async () => { const server = new Server( { From d11673fc2d39743b13719104042500cbb58278c1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 28 Apr 2025 18:01:14 -0400 Subject: [PATCH 101/103] Add client/server version negotiation tests without mocks * In src/client/index.test.ts added tests: - "should connect new client to old, supported server version" - should negotiate version when client is old, and newer server supports its version" - "should throw when client is old, and server doesn't support its version" --- src/client/index.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 6e1c442f..5b4f332f 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -222,8 +222,6 @@ test("should connect new client to old, supported server version", async () => { server.connect(serverTransport), ]); - // These should work - // Connection should succeed with the older version expect(client.getServerVersion()).toEqual({ name: "old server", version: "1.0", @@ -287,8 +285,6 @@ test("should negotiate version when client is old, and newer server supports its server.connect(serverTransport), ]); - // These should work - // Connection should succeed with the older version expect(client.getServerVersion()).toEqual({ name: "new server", version: "1.0", @@ -348,9 +344,6 @@ test("should throw when client is old, and server doesn't support its version", }, ); - let closed = false; - clientTransport.onerror = () => {closed = true}; - await Promise.all([ expect(client.connect(clientTransport)).rejects.toThrow( "Server's protocol version is not supported: FUTURE_VERSION" From def417f2df53d9d54ef6fd7f64be5a96c0930468 Mon Sep 17 00:00:00 2001 From: Henry Wildermuth Date: Wed, 30 Apr 2025 16:46:35 -0700 Subject: [PATCH 102/103] Add stderr access before start --- src/client/stdio.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/client/stdio.ts b/src/client/stdio.ts index b83bf27c..9e35293d 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -1,7 +1,7 @@ import { ChildProcess, IOType } from "node:child_process"; import spawn from "cross-spawn"; import process from "node:process"; -import { Stream } from "node:stream"; +import { Stream, PassThrough } from "node:stream"; import { ReadBuffer, serializeMessage } from "../shared/stdio.js"; import { Transport } from "../shared/transport.js"; import { JSONRPCMessage } from "../types.js"; @@ -93,6 +93,7 @@ export class StdioClientTransport implements Transport { private _abortController: AbortController = new AbortController(); private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; + private _stderrStream: PassThrough | null = null; onclose?: () => void; onerror?: (error: Error) => void; @@ -100,6 +101,9 @@ export class StdioClientTransport implements Transport { constructor(server: StdioServerParameters) { this._serverParams = server; + if (server.stderr === "pipe" || server.stderr === "overlapped") { + this._stderrStream = new PassThrough(); + } } /** @@ -158,15 +162,25 @@ export class StdioClientTransport implements Transport { this._process.stdout?.on("error", (error) => { this.onerror?.(error); }); + + if (this._stderrStream && this._process.stderr) { + this._process.stderr.pipe(this._stderrStream); + } }); } /** * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". * - * This is only available after the process has been started. + * If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to + * attach listeners before the start method is invoked. This prevents loss of any early + * error output emitted by the child process. */ get stderr(): Stream | null { + if (this._stderrStream) { + return this._stderrStream; + } + return this._process?.stderr ?? null; } From f250ea5f2b37199e9b4a382c98ca161999155176 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 1 May 2025 18:46:30 +0100 Subject: [PATCH 103/103] bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f24053c5..c6a65847 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.10.2", + "version": "1.11.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)",