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/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 1572e912..9e8efa52 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"; @@ -101,6 +101,77 @@ 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() + }); + + await expect(transport.terminateSession()).resolves.not.toThrow(); + }); + it("should handle 404 response when session expires", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", @@ -164,7 +235,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 +279,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)); @@ -275,45 +346,62 @@ 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 - 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(); + 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, } }); - (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 { + _reconnectionOptions: StreamableHTTPReconnectionOptions; + }; + expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500); + expect(transportInstance._reconnectionOptions.maxRetries).toBe(5); + }); + + 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() }); + // Call the reconnect method directly with a lastEventId await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); - await new Promise(resolve => setTimeout(resolve, 50)); + // Type assertion to access private method + const transportWithPrivateMethods = transport as unknown as { + _startOrAuthSse: (options: { resumptionToken?: string }) => Promise + }; + await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" }); - // Now simulate attempting to reconnect - (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 - const calls = (global.fetch as jest.Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); + // 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", @@ -323,7 +411,7 @@ describe("StreamableHTTPClientTransport", () => { const stream = new ReadableStream({ start(controller) { - controller.enqueue("invalid text response"); + controller.enqueue(new TextEncoder().encode("invalid text response")); controller.close(); } }); @@ -365,7 +453,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"; @@ -375,4 +463,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 ea69ee77..3462b2ab 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,8 +1,16 @@ import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, 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"; +// 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, @@ -12,6 +20,60 @@ export class StreamableHTTPError extends Error { } } +/** + * Options for starting or authenticating an SSE connection + */ +interface StartSSEOptions { + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off. + */ + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; + + /** + * Override Message ID to associate with the replay message + * so that response can be associate with the new resumed request. + */ + replayMessageId?: string | number; +} + +/** + * 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 2. + */ + maxRetries: number; +} + /** * Configuration options for the `StreamableHTTPClientTransport`. */ @@ -36,6 +98,17 @@ export type StreamableHTTPClientTransportOptions = { * Customizes HTTP requests to the server. */ requestInit?: RequestInit; + + /** + * 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; }; /** @@ -49,7 +122,7 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; - private _lastEventId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -62,6 +135,8 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._sessionId = opts?.sessionId; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } private async _authThenStart(): Promise { @@ -81,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthSse({ resumptionToken: undefined }); } private async _commonHeaders(): Promise { @@ -102,16 +177,18 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(): Promise { + + private async _startOrAuthSse(options: StartSSEOptions): Promise { + 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 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 (resumptionToken) { + headers.set("last-event-id", resumptionToken); } const response = await fetch(this._url, { @@ -137,44 +214,128 @@ 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); + + this._handleSseStream(response.body, options); } catch (error) { this.onerror?.(error as Error); throw error; } } - private _handleSseStream(stream: ReadableStream | null): void { - if (!stream) { + + /** + * 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 + */ + 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(options: StartSSEOptions, 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; } - const processStream = async () => { - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); + // Calculate next delay based on current attempt count + const delay = this._getNextReconnectionDelay(attemptCount); - for await (const event of eventStream) { - // Update last event ID if provided - if (event.id) { - this._lastEventId = event.id; + // Schedule the reconnection + setTimeout(() => { + // Use the last event ID to resume where we left off + 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(options, attemptCount + 1); + }); + }, delay); + } + + private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions): void { + if (!stream) { + return; + } + const { onresumptiontoken, replayMessageId } = options; + + let lastEventId: string | undefined; + const processStream = async () => { + // 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 + 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; + onresumptiontoken?.(event.id); + } + + 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); + } + } } - // 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}`)); + + // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing + if (this._abortController && !this._abortController.signal.aborted) { + // Use the exponential backoff reconnection strategy + if (lastEventId !== undefined) { + try { + this._scheduleReconnection({ + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, 0); + } + catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + + } } } } }; - - processStream().catch(err => this.onerror?.(err)); + processStream(); } async start() { @@ -208,8 +369,16 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { + const { resumptionToken, onresumptiontoken } = options || {}; + + if (resumptionToken) { + // If we have at last event ID, we need to reconnect the SSE stream + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).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"); @@ -251,9 +420,9 @@ 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") { - // We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE - this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + if (isInitializedNotification(message)) { + // Start without a lastEventId since this is a fresh connection + this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); } return; } @@ -268,7 +437,10 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { - this._handleSseStream(response.body); + // 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, { onresumptiontoken }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -291,4 +463,52 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + 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}` + ); + } + + 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 739e1164..c1501a57 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,487 @@ 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'; +let notificationsToolLastEventId: string | undefined = undefined; +let sessionId: string | undefined = undefined; + 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(' 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'); + 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(' 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 'terminate-session': + await terminateSession(); + 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) : 10; + await startNotifications(interval, count); + 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' + }); + client.onerror = (error) => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + } + + transport = new StreamableHTTPClientTransport( + new URL(serverUrl), + { + sessionId: sessionId + } + ); + + // 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); + 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); + 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 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(); + } + 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 onLastEventIdUpdate = (event: string) => { + notificationsToolLastEventId = event; + }; + const result = await client.request(request, CallToolResultSchema, { + resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate + }); + + 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 }); +} + +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 { + // 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!'); + 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...'); + 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/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 00000000..7646f0f7 --- /dev/null +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,192 @@ +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; + + try { + // Try connecting with automatic transport detection + const connection = await connectWithBackwardsCompatibility(serverUrl); + client = connection.client; + transport = connection.transport; + + // 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); + 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: sseClient, + 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/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 34ab65d1..101a581f 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({ @@ -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 @@ -145,14 +144,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/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts new file mode 100644 index 00000000..4c5aa446 --- /dev/null +++ b/src/examples/server/simpleSseServer.ts @@ -0,0 +1,169 @@ +import express, { Request, Response } from 'express'; +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('/mcp', 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 diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index f0f74439..0ae0f910 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,9 +1,84 @@ 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, 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; + } +} // Create an MCP server with implementation details const server = new McpServer({ @@ -92,6 +167,47 @@ 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++; + 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', @@ -127,21 +243,32 @@ 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 + 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; + } }); + // 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); - // 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 @@ -182,18 +309,38 @@ 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); }); -// 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'); +// 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; } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} + + 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; @@ -226,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 diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts new file mode 100644 index 00000000..e53fcf5f --- /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) + * - /messages: 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 /messages (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /messages?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 diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index f9d3696b..8c8c3baa 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({ @@ -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 @@ -107,13 +109,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/inMemory.test.ts b/src/inMemory.test.ts index f7e9e979..baf43446 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, extra) => { + receivedMessage = msg; + receivedAuthInfo = extra?.authInfo; + }; + + await clientTransport.send(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..5dd6e81e 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 { JSONRPCMessage, RequestId } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; + +interface QueuedMessage { + message: JSONRPCMessage; + extra?: { 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, extra?: { 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.extra); } } @@ -41,15 +45,19 @@ export class InMemoryTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage): Promise { + /** + * Sends a message with optional auth info. + * This is useful for testing authentication scenarios. + */ + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message); + this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); } else { - this._otherTransport._messageQueue.push(message); + this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); } } } 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 diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts new file mode 100644 index 00000000..3bd23498 --- /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 { 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(); + + 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', () => { + 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 = []; + let lastEventId: string | undefined; + + // Create first 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) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + 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', + params: { + name: 'run-notifications', + arguments: { + count: 3, + interval: 10 + } + } + }, CallToolResultSchema, { + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate + }); + + // 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(4); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + + // 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 + await transport1.close(); + // 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); + } + }); + + + + // 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, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for second client + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + 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 + await client2.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 1, + interval: 5 + } + } + }, CallToolResultSchema, { + resumptionToken: lastEventId, // Pass the lastEventId from the previous session + onresumptiontoken: onLastEventIdUpdate + }); + + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(1); + + + // Clean up + await transport2.close(); + + }); +}); \ No newline at end of file diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 93c5a493..c25c2b60 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -21,4 +21,10 @@ export interface AuthInfo { * When the token expires (in seconds since epoch). */ expiresAt?: number; + + /** + * Additional data associated with the token. + * This field should be used for any additional data that needs to be attached to the auth info. + */ + extra?: Record; } \ No newline at end of file diff --git a/src/server/sse.ts b/src/server/sse.ts index 46948b47..03f6fefc 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"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -19,7 +20,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => 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`. @@ -76,7 +77,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 { @@ -85,6 +86,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 { @@ -104,7 +106,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; @@ -116,7 +118,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, extra?: { authInfo?: AuthInfo }): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -125,7 +127,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage); + this.onmessage?.(parsedMessage, extra); } async close(): Promise { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 85fcae2f..0794e4bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,1590 +1,1139 @@ -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 { EventStore, StreamableHTTPServerTransport, EventId, StreamId } 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; + eventStore?: EventStore; } -// 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: { logging: {} } } + ); + + 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, + eventStore: config.eventStore + }); + + 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 { + // First close the transport to ensure all SSE streams are closed + await transport.close(); + + // Close the server without waiting indefinitely + server.close(); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + capabilities: { + }, + }, + + 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({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }); + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; }); - afterEach(() => { - jest.clearAllMocks(); + afterEach(async () => { + await stopTestServer({ server, transport }); }); - describe("Session Management", () => { - it("should generate session ID during initialization", async () => { - const initializeMessage: JSONRPCMessage = { + 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 = { + ...TEST_MESSAGES.initialize, + id: "second-init" + }; + + const response = await sendPostRequest(baseUrl, secondInitMessage); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); + }); + + 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 - don't wait indefinitely for server close + tempServer.close(); + }); + + 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 = { + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); + }); + + 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: "tools/list", - params: {}, - id: "test-req-id", - }; + method: "tools/call", + params: { name: "greet", arguments: { name: "Ignored" } }, + id: "ignored-id" + }) + }); - 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), - }); + expect(response.status).toBe(200); - // 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); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); +}); + +// 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 = { + + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + 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 + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + await send(eventId, message); } - }; + } + return streamId; + }, + }; - 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)); + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore }); - 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" }, - ]; + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - 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), - }); + // Verify resumability is enabled on the transport + expect((transport)['_eventStore']).toBeDefined(); - // 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" } })); - }); + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; + expect(sessionId).toBeDefined(); }); - describe("Handling Pre-Parsed Body", () => { - 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 accept pre-parsed request body", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "pre-parsed-test", - }; - - // 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, 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); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); }); -}); \ No newline at end of file + + 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('_GET_stream')).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; + 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 () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // 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 sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // 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 fc13e762..c03246f2 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,32 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { 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"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +export type StreamId = string; +export type EventId = string; + +/** + * Interface for resumability support via event storage + */ +export interface EventStore { + /** + * 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: StreamId, message: JSONRPCMessage): Promise; + + replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise; +} + /** * Configuration options for StreamableHTTPServerTransport */ @@ -18,12 +39,27 @@ export interface StreamableHTTPServerTransportOptions { */ sessionIdGenerator: () => string | undefined; + /** + * A callback for session initialization events + * This is called when the server initializes a new session. + * Useful 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. * 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 +100,14 @@ 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 = '_GET_stream'; + private _eventStore?: EventStore; + private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string | undefined; onclose?: () => void; @@ -79,6 +117,8 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; + this._eventStore = options.eventStore; + this._onsessioninitialized = options.onsessioninitialized; } /** @@ -131,6 +171,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 +192,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 +206,67 @@ 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 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(); + + 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); + } + } + + /** + * 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.) */ @@ -246,10 +339,10 @@ 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. if (this._initialized) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", @@ -275,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 @@ -285,11 +384,9 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests - const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); - const hasOnlyNotificationsOrResponses = messages.every(msg => - ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); + const hasRequests = messages.some(isJSONRPCRequest); - if (hasOnlyNotificationsOrResponses) { + if (!hasRequests) { // if it only contains notifications or responses, return 202 res.writeHead(202).end(); @@ -300,6 +397,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", @@ -317,20 +415,14 @@ 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) { - this._responseMapping.set(message.id, res); + if (isJSONRPCRequest(message)) { + this._streamMapping.set(streamId, res); + this._requestToStreamMapping.set(message.id, streamId); } } - // Set up close handler for client disconnects res.on("close", () => { - // Remove all entries that reference this response - for (const [id, storedRes] of this._responseMapping.entries()) { - if (storedRes === res) { - this._responseMapping.delete(id); - this._requestResponseMap.delete(id); - } - } + this._streamMapping.delete(streamId); }); // handle each message @@ -431,20 +523,19 @@ 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.onclose?.(); } 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; } @@ -454,41 +545,60 @@ 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"); } - - 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) { 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); + } + if (response) { + // 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 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 = { @@ -514,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); } } } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 07bfc02c..c9ea79fd 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,7 +22,8 @@ import { Result, ServerCapabilities, } from "../types.js"; -import { Transport } from "./transport.js"; +import { Transport, TransportSendOptions } from "./transport.js"; +import { AuthInfo } from "../server/auth/types.js"; /** * Callback for progress notifications. @@ -82,12 +83,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; -}; +} & TransportSendOptions; /** * Options that can be given per notification. @@ -109,6 +105,11 @@ export type RequestHandlerExtra { + this._transport.onmessage = (message, extra) => { if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { - this._onrequest(message); + this._onrequest(message, extra); } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { @@ -326,7 +327,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest): void { + private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -351,20 +352,20 @@ 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 = { + const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, sendNotification: (notification) => this.notification(notification, { relatedRequestId: request.id }), sendRequest: (r, resultSchema, options?) => - this.request(r, resultSchema, { ...options, relatedRequestId: request.id }) + this.request(r, resultSchema, { ...options, relatedRequestId: request.id }), + 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) { @@ -501,7 +502,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -543,7 +544,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId }) + }, { relatedRequestId, resumptionToken, onresumptiontoken }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -581,7 +582,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, resumptionToken, onresumptiontoken }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index e464653b..fe0a60e6 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,29 @@ +import { AuthInfo } from "../server/auth/types.js"; import { JSONRPCMessage, RequestId } from "../types.js"; +/** + * 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. + * + * 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 +42,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?: TransportSendOptions): Promise; /** * Closes the connection. @@ -41,8 +65,11 @@ 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, extra?: { authInfo?: AuthInfo }) => void; /** * The session ID generated for this connection. 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.