From af025e26a75547a69629141b42dc932fc3b7177c Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Fri, 28 Feb 2025 15:20:55 -0800 Subject: [PATCH 01/42] Include AuthInfo from bearer validation in transport request handler --- src/server/sse.ts | 12 +++++++----- src/shared/protocol.ts | 14 ++++++++++---- src/shared/transport.ts | 6 +++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 84c1cbb9..e990fafd 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -4,6 +4,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { AuthInfo } from "./auth/types.js"; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -18,7 +19,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -66,7 +67,7 @@ export class SSEServerTransport implements Transport { * This should be called when a POST request is made to send a message to the server. */ async handlePostMessage( - req: IncomingMessage, + req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown, ): Promise { @@ -75,6 +76,7 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + const authInfo: AuthInfo | undefined = req.auth; let body: string | unknown; try { @@ -94,7 +96,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, authInfo); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -106,7 +108,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown): Promise { + async handleMessage(message: unknown, authInfo?: AuthInfo): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -115,7 +117,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage); + this.onmessage?.(parsedMessage, authInfo); } async close(): Promise { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 97213bf0..46b4c96e 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -19,6 +19,7 @@ import { ServerCapabilities, } from "../types.js"; import { Transport } from "./transport.js"; +import { AuthInfo } from "../server/auth/types.js"; /** * Callback for progress notifications. @@ -88,6 +89,11 @@ export type RequestHandlerExtra = { * An abort signal used to communicate if the request was cancelled from the sender's side. */ signal: AbortSignal; + + /** + * Information about a validated access token, provided to request handlers. + */ + authInfo?: AuthInfo; }; /** @@ -232,11 +238,11 @@ export abstract class Protocol< this._onerror(error); }; - this._transport.onmessage = (message) => { + this._transport.onmessage = (message, authInfo) => { if (!("method" in message)) { this._onresponse(message); } else if ("id" in message) { - this._onrequest(message); + this._onrequest(message, authInfo); } else { this._onnotification(message); } @@ -282,7 +288,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest): void { + private _onrequest(request: JSONRPCRequest, authInfo?: AuthInfo): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -309,7 +315,7 @@ export abstract class Protocol< // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(request, { signal: abortController.signal })) + .then(() => handler(request, { signal: abortController.signal, authInfo })) .then( (result) => { if (abortController.signal.aborted) { diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 5843cf00..de7e11d4 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,3 +1,4 @@ +import { AuthInfo } from "../server/auth/types.js"; import { JSONRPCMessage } from "../types.js"; /** @@ -39,6 +40,9 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. + * + * Includes the authInfo if the transport is authenticated. + * */ - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; } From 7b20fd7a50deeb7416cb99635c782e747b52c5f5 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 7 Apr 2025 11:29:21 -0700 Subject: [PATCH 02/42] Add AuthInfo test --- src/inMemory.test.ts | 27 +++++++++++++++++++++++++++ src/inMemory.ts | 28 ++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index f7e9e979..b6906cc8 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -1,5 +1,6 @@ import { InMemoryTransport } from "./inMemory.js"; import { JSONRPCMessage } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; describe("InMemoryTransport", () => { let clientTransport: InMemoryTransport; @@ -35,6 +36,32 @@ describe("InMemoryTransport", () => { expect(receivedMessage).toEqual(message); }); + test("should send message with auth info from client to server", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + id: 1, + }; + + const authInfo: AuthInfo = { + token: "test-token", + clientId: "test-client", + scopes: ["read", "write"], + expiresAt: Date.now() / 1000 + 3600, + }; + + let receivedMessage: JSONRPCMessage | undefined; + let receivedAuthInfo: AuthInfo | undefined; + serverTransport.onmessage = (msg, auth) => { + receivedMessage = msg; + receivedAuthInfo = auth; + }; + + await clientTransport.sendWithAuth(message, authInfo); + expect(receivedMessage).toEqual(message); + expect(receivedAuthInfo).toEqual(authInfo); + }); + test("should send message from server to client", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/inMemory.ts b/src/inMemory.ts index 106a9e7e..9de100c2 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -1,16 +1,22 @@ import { Transport } from "./shared/transport.js"; import { JSONRPCMessage } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; + +interface QueuedMessage { + message: JSONRPCMessage; + authInfo?: AuthInfo; +} /** * In-memory transport for creating clients and servers that talk to each other within the same process. */ export class InMemoryTransport implements Transport { private _otherTransport?: InMemoryTransport; - private _messageQueue: JSONRPCMessage[] = []; + private _messageQueue: QueuedMessage[] = []; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; sessionId?: string; /** @@ -27,10 +33,8 @@ export class InMemoryTransport implements Transport { async start(): Promise { // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { - const message = this._messageQueue.shift(); - if (message) { - this.onmessage?.(message); - } + const queuedMessage = this._messageQueue.shift()!; + this.onmessage?.(queuedMessage.message, queuedMessage.authInfo); } } @@ -42,14 +46,22 @@ export class InMemoryTransport implements Transport { } async send(message: JSONRPCMessage): Promise { + return this.sendWithAuth(message); + } + + /** + * Sends a message with optional auth info. + * This is useful for testing authentication scenarios. + */ + async sendWithAuth(message: JSONRPCMessage, authInfo?: AuthInfo): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message); + this._otherTransport.onmessage(message, authInfo); } else { - this._otherTransport._messageQueue.push(message); + this._otherTransport._messageQueue.push({ message, authInfo }); } } } From 72f46d44c27e4b1eae393a20933c1ac4d8279904 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:49:31 -0500 Subject: [PATCH 03/42] Update src/shared/transport.ts Co-authored-by: Justin Spahr-Summers --- src/shared/transport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 68b7149d..ffc77d0b 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -44,7 +44,7 @@ export interface Transport { * Includes the authInfo if the transport is authenticated. * */ - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * The session ID generated for this connection. From bc38bb29c7e988ea58d56b533c7fa589e4fa485f Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:49:58 -0500 Subject: [PATCH 04/42] Update src/inMemory.ts Co-authored-by: Justin Spahr-Summers --- src/inMemory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inMemory.ts b/src/inMemory.ts index 9de100c2..8889fdd8 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -53,7 +53,7 @@ export class InMemoryTransport implements Transport { * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async sendWithAuth(message: JSONRPCMessage, authInfo?: AuthInfo): Promise { + async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise if (!this._otherTransport) { throw new Error("Not connected"); } From 03de5b6853ec2f214848931f02764710b9da59e8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 18:27:12 +0100 Subject: [PATCH 05/42] Use server and transport classes instead of mocks in streamable http transport --- src/server/streamableHttp.test.ts | 2401 +++++++++++------------------ src/server/streamableHttp.ts | 2 + 2 files changed, 902 insertions(+), 1501 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 85fcae2f..dda69331 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,1590 +1,989 @@ -import { IncomingMessage, ServerResponse } from "node:http"; -import { StreamableHTTPServerTransport } from "./streamableHttp.js"; -import { JSONRPCMessage } from "../types.js"; -import { Readable } from "node:stream"; +import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -// Mock IncomingMessage -function createMockRequest(options: { - method: string; - headers: Record; - body?: string; -}): IncomingMessage { - const readable = new Readable(); - readable._read = () => { }; - if (options.body) { - readable.push(options.body); - readable.push(null); - } +import { StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { McpServer } from "./mcp.js"; +import { CallToolResult, JSONRPCMessage } from "../types.js"; +import { z } from "zod"; - return Object.assign(readable, { - method: options.method, - headers: options.headers, - }) as IncomingMessage; +/** + * Test server configuration for StreamableHTTPServerTransport tests + */ +interface TestServerConfig { + sessionIdGenerator?: () => string | undefined; + enableJsonResponse?: boolean; + customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; } -// Mock ServerResponse -function createMockResponse(): jest.Mocked { - const response = { - writeHead: jest.fn().mockReturnThis(), - write: jest.fn().mockReturnThis(), - end: jest.fn().mockReturnThis(), - on: jest.fn().mockReturnThis(), - emit: jest.fn().mockReturnThis(), - getHeader: jest.fn(), - setHeader: jest.fn(), - flushHeaders: jest.fn(), - } as unknown as jest.Mocked; - return response; +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServer(config: TestServerConfig = {}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: {} } + ); + + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator ?? (() => randomUUID()), + enableJsonResponse: config.enableJsonResponse ?? false + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, transport, mcpServer, baseUrl }; +} + +/** + * Helper to stop test server + */ +async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + await transport.close(); + await new Promise((resolve) => server.close(() => resolve())); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + }, + id: "init-1", + } as JSONRPCMessage, + + toolsList: { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "tools-1", + } as JSONRPCMessage +}; + +/** + * Helper to extract text from SSE response + * Note: Can only be called once per response stream. For multiple reads, + * get the reader manually and read multiple times. + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to send JSON-RPC request + */ +async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + + if (sessionId) { + headers["mcp-session-id"] = sessionId; + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + +function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { + expect(data).toMatchObject({ + jsonrpc: "2.0", + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern), + }), + }); } describe("StreamableHTTPServerTransport", () => { + let server: Server; let transport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - let mockRequest: string; + let baseUrl: URL; + let sessionId: string; - beforeEach(() => { - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - mockResponse = createMockResponse(); - mockRequest = JSON.stringify({ + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get("mcp-session-id"); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it("should initialize server and generate session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("mcp-session-id")).toBeDefined(); + }); + + it("should reject second initialization request", async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); + + // Try second initialize + const secondInitMessage: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }); + method: "initialize", + params: { + clientInfo: { name: "test-client-2", version: "1.0" }, + protocolVersion: "2025-03-26", + }, + id: "init-2", + }; + const response = await sendPostRequest(baseUrl, secondInitMessage); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("Session Management", () => { - it("should generate session ID during initialization", async () => { - const initializeMessage: JSONRPCMessage = { + it("should reject batch initialize request", async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { jsonrpc: "2.0", method: "initialize", params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - expect(transport.sessionId).toBeUndefined(); - expect(transport["_initialized"]).toBe(false); - - await transport.handleRequest(req, mockResponse); - - expect(transport.sessionId).toBeDefined(); - expect(transport["_initialized"]).toBe(true); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "mcp-session-id": transport.sessionId, - }) - ); - }); - - it("should reject second initialization request", async () => { - // First initialize - const initMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage1), - }); - - await transport.handleRequest(req1, mockResponse); - expect(transport["_initialized"]).toBe(true); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - mockResponse.end.mockClear(); - - // Try second initialize - const initMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + clientInfo: { name: "test-client-2", version: "1.0" }, + protocolVersion: "2025-03-26", }, id: "init-2", - }; + } + ]; - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage2), - }); + const response = await sendPostRequest(baseUrl, batchInitMessages); - await transport.handleRequest(req2, mockResponse); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); + }); - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Server already initialized"')); + it("should pandle post requests via sse response correctly", async () => { + sessionId = await initializeServer(); + + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + + // Read the SSE stream for the response + const text = await readSSEEvent(response); + + // Parse the SSE event + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "greet", + description: "A simple greeting tool", + }), + ]), + }), + id: "tools-1", }); + }); - it("should reject batch initialize request", async () => { - const batchInitialize: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + it("should call a tool and return the result", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", }, - id: "init-1", - }, - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client-2", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-2", - } - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(batchInitialize), - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Only one initialization request is allowed"')); - }); - - it("should reject invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an invalid session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": "invalid-session-id", - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Session not found"')); - }); - - it("should reject non-initialization requests without session ID with 400 Bad Request", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try without session ID - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - // No mcp-session-id header - }, - body: mockRequest - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header is required"')); - }); - - it("should reject requests to uninitialized server", async () => { - // Create a new transport that hasn't been initialized - const uninitializedTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - "mcp-session-id": "any-session-id", - }, - body: mockRequest - }); - - await uninitializedTransport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Server not initialized"')); - }); - - it("should reject session ID as array", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an array session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": ["session1", "session2"], - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header must be a single value"')); - }); - }); - describe("Mode without state management", () => { - let transportWithoutState: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - - beforeEach(async () => { - transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - mockResponse = createMockResponse(); - - // Initialize the transport for each test - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transportWithoutState.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should not include session ID in response headers when in mode without state management", async () => { - // Use a non-initialization request - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(message), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalled(); - // Extract the headers from writeHead call - const headers = mockResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); - }); - - it("should not validate session ID in mode without state management", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "invalid-session-id", // This would cause a 404 in mode with state management - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1 - }), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - // Should still get 200 OK, not 404 Not Found - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.not.objectContaining({ - "mcp-session-id": expect.anything(), - }) - ); - }); - - it("should handle POST requests without session validation in mode without state management", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "non-existent-session-id", // This would be rejected in mode with state management - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transportWithoutState.onmessage = onMessageMock; - - await transportWithoutState.handleRequest(req, mockResponse); - - // Message should be processed despite invalid session ID - expect(onMessageMock).toHaveBeenCalledWith(message); - }); - - it("should work with a mix of requests with and without session IDs in mode without state management", async () => { - // First request without session ID - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-id" - }) - }); - - await transportWithoutState.handleRequest(req1, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - - // Second request with a session ID (which would be invalid in mode with state management) - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": "some-random-session-id", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "test-id-2" - }) - }); - - await transportWithoutState.handleRequest(req2, mockResponse); - - // Should still succeed - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle initialization in mode without state management", async () => { - const transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - - // Initialize message - const initializeMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(false); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - const newResponse = createMockResponse(); - await transportWithoutState.handleRequest(req, newResponse); - - // After initialization, the sessionId should still be undefined - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(true); - - // Headers should NOT include session ID in mode without state management - const headers = newResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); + ], + }, + id: "call-1", }); }); - describe("Request Handling", () => { - // Initialize the transport before tests that need initialization - beforeEach(async () => { - // For tests that need initialization, initialize here - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should reject requests without a valid session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request/); + expect(errorData.id).toBeNull(); + }); - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); + it("should reject invalid session ID", async () => { + // First initialize to be in valid state + await initializeServer(); + + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, "invalid-session-id"); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); + + it("should establish standalone SSE stream and receive server-initiated messages", async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should accept GET requests for SSE with proper Accept header", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); - await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "Connection": "keep-alive", - "mcp-session-id": transport.sessionId, - })); - }); + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, + }; - it("should reject GET requests without Accept: text/event-stream header", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "application/json", - "mcp-session-id": transport.sessionId, - }, - }); + // Send the notification via transport + await transport.send(notification); - await transport.handleRequest(req, mockResponse); + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Not Acceptable: Client must accept text/event-stream"')); - }); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); - it("should send server-initiated requests to GET SSE stream", async () => { - // Open a standalone SSE stream with GET - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse = createMockResponse(); - await transport.handleRequest(req, sseResponse); - - // Send a notification without a related request ID - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "notifications/resources/updated", - params: { uri: "someuri" } - }; - - await transport.send(notification); - - // Verify notification was sent on SSE stream - expect(sseResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(notification)}\n\n`) - ); - }); - - it("should not close GET SSE stream after sending server requests or notifications", async () => { - // Open a standalone SSE stream - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse = createMockResponse(); - await transport.handleRequest(req, sseResponse); - - // Send multiple notifications - const notification1: JSONRPCMessage = { jsonrpc: "2.0", method: "event1", params: {} }; - const notification2: JSONRPCMessage = { jsonrpc: "2.0", method: "event2", params: {} }; - - await transport.send(notification1); - await transport.send(notification2); - - // Stream should remain open - expect(sseResponse.end).not.toHaveBeenCalled(); - }); - - it("should reject second GET SSE stream for the same session", async () => { - // Open first SSE stream - should succeed - const req1 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse1 = createMockResponse(); - await transport.handleRequest(req1, sseResponse1); - - // Try to open a second SSE stream - should be rejected - const req2 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); - - const sseResponse2 = createMockResponse(); - await transport.handleRequest(req2, sseResponse2); - - // First stream should be good - expect(sseResponse1.writeHead).toHaveBeenCalledWith(200, expect.anything()); - - // Second stream should get 409 Conflict - expect(sseResponse2.writeHead).toHaveBeenCalledWith(409); - expect(sseResponse2.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Conflict: Only one SSE stream is allowed per session"')); - }); - - it("should reject POST requests without proper Accept header", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); - }); - - it("should properly handle JSON-RPC request messages in POST requests", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should properly handle JSON-RPC notification or response messages in POST requests", async () => { - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(notification), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(notification); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch notification messages properly with 202 response", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {} }, - { jsonrpc: "2.0", method: "test2", params: {} }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch request messages with SSE when Accept header includes text/event-stream", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "test2", params: {}, id: "req2" }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "text/event-stream, application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - mockResponse = createMockResponse(); // Create fresh mock - await transport.handleRequest(req, mockResponse); - - // Should establish SSE connection - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream" - }) - ); - expect(onMessageMock).toHaveBeenCalledTimes(2); - // Stream should remain open until responses are sent - expect(mockResponse.end).not.toHaveBeenCalled(); - }); - - it("should reject unsupported Content-Type", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "text/plain", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: "test", - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(415); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - }); - - it("should properly handle DELETE requests and close session", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with proper session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": transport.sessionId, - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(200); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("should reject DELETE requests with invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with invalid session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": "invalid-session-id", - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(onCloseMock).not.toHaveBeenCalled(); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, }); }); - describe("SSE Response Handling", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should not close GET SSE stream after sending multiple server notifications", async () => { + sessionId = await initializeServer(); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - const initResponse = createMockResponse(); - await transport.handleRequest(initReq, initResponse); - mockResponse.writeHead.mockClear(); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should send response messages as SSE events", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" } + }; - await transport.handleRequest(req, mockResponse); + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); - // Send a response to the request - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("First notification"); + expect(done).toBe(false); // Stream should still be open + }); - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); + it("should reject second SSE stream for the same session", async () => { + sessionId = await initializeServer(); - // Verify response was sent as SSE event - expect(mockResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(responseMessage)}\n\n`) - ); - - // Stream should be closed after sending response - expect(mockResponse.end).toHaveBeenCalled(); + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when sending intermediate notifications and requests", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(firstStream.status).toBe(200); - // Create fresh response for this test - mockResponse = createMockResponse(); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); - - await transport.handleRequest(req, mockResponse); - - // Send an intermediate notification - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "progress", - params: { progress: "50%" } - }; - - await transport.send(notification, { relatedRequestId: "test-req-id" }); - - // Stream should remain open - expect(mockResponse.end).not.toHaveBeenCalled(); - - // Send the final response - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; - - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); - - // Now stream should be closed - expect(mockResponse.end).toHaveBeenCalled(); + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when multiple requests share the same connection", async () => { - // Create a fresh response for this test - const sharedResponse = createMockResponse(); + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); + }); - // Send two requests in a batch that will share the same connection - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "method1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "method2", params: {}, id: "req2" } - ]; + it("should reject GET requests without Accept: text/event-stream header", async () => { + sessionId = await initializeServer(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(batchRequests) - }); - - await transport.handleRequest(req, sharedResponse); - - // Respond to first request - const response1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result1" }, - id: "req1" - }; - - await transport.send(response1); - - // Connection should remain open because req2 is still pending - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response1)}\n\n`) - ); - expect(sharedResponse.end).not.toHaveBeenCalled(); - - // Respond to second request - const response2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result2" }, - id: "req2" - }; - - await transport.send(response2); - - // Now connection should close as all requests are complete - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response2)}\n\n`) - ); - expect(sharedResponse.end).toHaveBeenCalled(); + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "application/json", + "mcp-session-id": sessionId, + }, }); - it("should clean up connection tracking when a response is sent", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "cleanup-test" - }) - }); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); + }); - const response = createMockResponse(); - await transport.handleRequest(req, response); + it("should reject POST requests without proper Accept header", async () => { + sessionId = await initializeServer(); - // Verify that the request is tracked in the SSE map - expect(transport["_responseMapping"].size).toBe(2); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(true); - - // Send a response - await transport.send({ - jsonrpc: "2.0", - result: {}, - id: "cleanup-test" - }); - - // Verify that the mapping was cleaned up - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(false); + // Try POST without Accept: text/event-stream + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", // Missing text/event-stream + "mcp-session-id": sessionId, + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), }); - it("should clean up connection tracking when client disconnects", async () => { - // Setup two requests that share a connection - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify([ - { jsonrpc: "2.0", method: "longRunning1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "longRunning2", params: {}, id: "req2" } + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); + }); + + it("should reject unsupported Content-Type", async () => { + sessionId = await initializeServer(); + + // Try POST with text/plain Content-Type + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is plain text", + }); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); + }); + + it("should handle JSON-RPC batch notification messages with 202 response", async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "someNotification1", params: {} }, + { jsonrpc: "2.0", method: "someNotification2", params: {} }, + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); + }); + + it("should handle batch request messages with SSE stream for responses", async () => { + sessionId = await initializeServer(); + + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "BatchUser" } }, id: "req-2" }, + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it("should properly handle invalid JSON data", async () => { + sessionId = await initializeServer(); + + // Send invalid JSON + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is not valid JSON", + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32700, /Parse error/); + }); + + it("should return 400 error for invalid JSON-RPC messages", async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: "tools/list", params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: "2.0", + error: expect.anything(), + }); + }); + + it("should reject requests to uninitialized server", async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "uninitialized-test", + }; + + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, "any-session-id"); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); + }); + + it("should send response messages to the connection that sent the request", async () => { + sessionId = await initializeServer(); + + const message1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "req-1" + }; + + const message2: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { name: "Connection2" } + }, + id: "req-2" + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it("should keep stream open after sending server notifications", async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + // Send several server-initiated notifications + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" }, + }); + + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Second notification" }, + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it("should properly handle DELETE requests and close session", async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { "mcp-session-id": tempSessionId || "" }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + await new Promise((resolve) => tempServer.close(() => resolve())); + }); + + it("should reject DELETE requests with invalid session ID", async () => { + // Initialize the server first to activate it + sessionId = await initializeServer(); + + // Try to delete with invalid session ID + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { "mcp-session-id": "invalid-session-id" }, + }); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); +}); + +// Test JSON Response Mode +describe("StreamableHTTPServerTransport with JSON Response Mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer({ enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + sessionId = initResponse.headers.get("mcp-session-id") as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should return JSON response for a single request", async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "json-req-1", + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) ]) - }); + }), + id: "json-req-1" + }); + }); - const response = createMockResponse(); + it("should return JSON response for batch requests", async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "JSON" } }, id: "batch-2" } + ]; - // We need to manually store the callback to trigger it later - let closeCallback: (() => void) | undefined; - response.on.mockImplementation((event, callback: () => void) => { - if (typeof event === "string" && event === "close") { - closeCallback = callback; + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const results = await response.json(); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: string }) => r.id === "batch-1"); + const callResponse = results.find((r: { id?: string }) => r.id === "batch-2"); + + expect(listResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-1", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) + ]) + }) + })); + + expect(callResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-2", + result: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "Hello, JSON!" }) + ]) + }) + })); + }); +}); + +// Test pre-parsed body handling +describe("StreamableHTTPServerTransport with pre-parsed body", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody !== null) { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); } - return response; - }); - - await transport.handleRequest(req, response); - - // Both requests should be mapped to the same response - expect(transport["_responseMapping"].size).toBe(3); - expect(transport["_responseMapping"].get("req1")).toBe(response); - expect(transport["_responseMapping"].get("req2")).toBe(response); - - // Simulate client disconnect by triggering the stored callback - if (closeCallback) closeCallback(); - - // All entries using this response should be removed - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("req1")).toBe(false); - expect(transport["_responseMapping"].has("req2")).toBe(false); + } }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; }); - describe("Message Targeting", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should send response messages to the connection that sent the request", async () => { - // Create request with two separate connections - const requestMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test1", - params: {}, - id: "req-id-1", - }; - - const mockResponse1 = createMockResponse(); - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage1), - }); - await transport.handleRequest(req1, mockResponse1); - - const requestMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "req-id-2", - }; - - const mockResponse2 = createMockResponse(); - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage2), - }); - await transport.handleRequest(req2, mockResponse2); - - // Send responses with matching IDs - const responseMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-1", - }; - - await transport.send(responseMessage1, { relatedRequestId: "req-id-1" }); - - const responseMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-2", - }; - - await transport.send(responseMessage2, { relatedRequestId: "req-id-2" }); - - // Verify responses were sent to the right connections - expect(mockResponse1.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage1)) - ); - - expect(mockResponse2.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage2)) - ); - - // Verify responses were not sent to the wrong connections - const resp1HasResp2 = mockResponse1.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage2)) - ); - expect(resp1HasResp2).toBe(false); - - const resp2HasResp1 = mockResponse2.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage1)) - ); - expect(resp2HasResp1).toBe(false); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); }); - describe("Error Handling", () => { - it("should return 400 error for invalid JSON data", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: "invalid json", - }); + it("should accept pre-parsed request body", async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-1", + }; - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"code":-32700')); - expect(onErrorMock).toHaveBeenCalled(); + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + // Empty body - we're testing pre-parsed body + body: "" }); - it("should return 400 error for invalid JSON-RPC messages", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify({ invalid: "message" }), - }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(onErrorMock).toHaveBeenCalled(); - }); + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); }); - describe("JSON Response Mode", () => { - let jsonResponseTransport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; + it("should handle pre-parsed batch messages", async () => { + parsedBody = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "PreParsed" } }, id: "batch-2" } + ]; - beforeEach(async () => { - jsonResponseTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - }); - - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - mockResponse = createMockResponse(); - await jsonResponseTransport.handleRequest(initReq, mockResponse); - mockResponse = createMockResponse(); // Reset for tests + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "" // Empty as we're using pre-parsed }); - it("should return JSON response for a single request", async () => { - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "tools/list", - params: {}, - id: "test-req-id", - }; + expect(response.status).toBe(200); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(requestMessage), - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Mock immediate response - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `test-result` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); - } - }; - - await jsonResponseTransport.handleRequest(req, mockResponse); - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Should return the response as JSON - const expectedResponse = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id", - }; - - expect(mockResponse.end).toHaveBeenCalledWith(JSON.stringify(expectedResponse)); - }); - - it("should return JSON response for batch requests", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "tools/call", params: {}, id: "req2" }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - // Mock responses without enforcing specific order - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `result-for-${message.id}` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); - } - }; - - await jsonResponseTransport.handleRequest(req, mockResponse); - - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Verify response was sent but don't assume specific order - expect(mockResponse.end).toHaveBeenCalled(); - const responseJson = JSON.parse(mockResponse.end.mock.calls[0][0] as string); - expect(Array.isArray(responseJson)).toBe(true); - expect(responseJson).toHaveLength(2); - - // Check each response exists separately without assuming order - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req1", result: { value: "result-for-req1" } })); - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req2", result: { value: "result-for-req2" } })); - }); + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); }); - describe("Handling Pre-Parsed Body", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { + it("should prefer pre-parsed body over request body", async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-wins", + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); + method: "tools/call", + params: { name: "greet", arguments: { name: "Ignored" } }, + id: "ignored-id" + }) }); - it("should accept pre-parsed request body", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "pre-parsed-test", - }; + expect(response.status).toBe(200); - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, message); - - // Verify the message was processed correctly - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle pre-parsed batch messages", async () => { - const batchMessages: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "method1", - params: { data: "test1" }, - id: "batch1" - }, - { - jsonrpc: "2.0", - method: "method2", - params: { data: "test2" }, - id: "batch2" - }, - ]; - - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, batchMessages); - - // Should be called for each message in the batch - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[0]); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[1]); - }); - - it("should prefer pre-parsed body over request body", async () => { - const requestBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromRequestBody", - params: {}, - id: "request-body", - }; - - const parsedBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromParsedBody", - params: {}, - id: "parsed-body", - }; - - // Create a request with actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(requestBodyMessage), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, parsedBodyMessage); - - // Should use the parsed body instead of the request body - expect(onMessageMock).toHaveBeenCalledWith(parsedBodyMessage); - expect(onMessageMock).not.toHaveBeenCalledWith(requestBodyMessage); - }); + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); }); -}); \ No newline at end of file +}); + +// Test stateless mode +describe("StreamableHTTPServerTransport in stateless mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: () => undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should operate without session ID validation", async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get("mcp-session-id")).toBeNull(); + + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(200); + }); + + it("should handle POST requests with various session IDs in stateless mode", async () => { + // Initialize the server first + await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" + }), + }); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "random-id-1", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t1" }), + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "different-id-2", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t2" }), + }); + expect(response2.status).toBe(200); + }); + + it("should reject second SSE stream even in stateless mode", async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ + jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" + }), + }); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream1.status).toBe(200); + + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed + }); +}); \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index ec8d2aa7..0eaaa673 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -438,6 +438,8 @@ export class StreamableHTTPServerTransport implements Transport { // Clear any pending responses this._requestResponseMap.clear(); + this._standaloneSSE?.end(); + this._standaloneSSE = undefined; this.onclose?.(); } From 055fe61d9381594fdfec32d49040cccd00c11f8d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 20:52:27 +0100 Subject: [PATCH 06/42] close server --- src/server/streamableHttp.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index dda69331..949cafc7 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -72,8 +72,11 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ * Helper to stop test server */ async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + // First close the transport to ensure all SSE streams are closed await transport.close(); - await new Promise((resolve) => server.close(() => resolve())); + + // Close the server without waiting indefinitely + server.close(); } /** @@ -649,8 +652,8 @@ describe("StreamableHTTPServerTransport", () => { expect(deleteResponse.status).toBe(200); - // Clean up - await new Promise((resolve) => tempServer.close(() => resolve())); + // Clean up - don't wait indefinitely for server close + tempServer.close(); }); it("should reject DELETE requests with invalid session ID", async () => { From c4381b857be73e7f5f87771ff2daf6e2e47b0ff9 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:38:55 -0700 Subject: [PATCH 07/42] Pass extra object instead of authInfo directly --- src/inMemory.test.ts | 6 +++--- src/inMemory.ts | 16 ++++++---------- src/server/sse.ts | 8 ++++---- src/shared/protocol.ts | 15 ++++++++------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index b6906cc8..baf43446 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -52,12 +52,12 @@ describe("InMemoryTransport", () => { let receivedMessage: JSONRPCMessage | undefined; let receivedAuthInfo: AuthInfo | undefined; - serverTransport.onmessage = (msg, auth) => { + serverTransport.onmessage = (msg, extra) => { receivedMessage = msg; - receivedAuthInfo = auth; + receivedAuthInfo = extra?.authInfo; }; - await clientTransport.sendWithAuth(message, authInfo); + await clientTransport.send(message, { authInfo }); expect(receivedMessage).toEqual(message); expect(receivedAuthInfo).toEqual(authInfo); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 8889fdd8..558ca7ad 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -4,7 +4,7 @@ import { AuthInfo } from "./server/auth/types.js"; interface QueuedMessage { message: JSONRPCMessage; - authInfo?: AuthInfo; + extra?: { authInfo?: AuthInfo }; } /** @@ -16,7 +16,7 @@ export class InMemoryTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; sessionId?: string; /** @@ -34,7 +34,7 @@ export class InMemoryTransport implements Transport { // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { const queuedMessage = this._messageQueue.shift()!; - this.onmessage?.(queuedMessage.message, queuedMessage.authInfo); + this.onmessage?.(queuedMessage.message, queuedMessage.extra); } } @@ -45,23 +45,19 @@ export class InMemoryTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage): Promise { - return this.sendWithAuth(message); - } - /** * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise + async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message, authInfo); + this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); } else { - this._otherTransport._messageQueue.push({ message, authInfo }); + this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); } } } diff --git a/src/server/sse.ts b/src/server/sse.ts index 848043ba..ae993dd7 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -19,7 +19,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, authInfo?: AuthInfo) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -96,7 +96,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, authInfo); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -108,7 +108,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown, authInfo?: AuthInfo): Promise { + async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -117,7 +117,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage, authInfo); + this.onmessage?.(parsedMessage, extra); } async close(): Promise { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 789f5a7e..a39eba68 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -246,11 +246,11 @@ export abstract class Protocol< this._onerror(error); }; - this._transport.onmessage = (message, authInfo) => { + this._transport.onmessage = (message, extra) => { if (!("method" in message)) { this._onresponse(message); } else if ("id" in message) { - this._onrequest(message, authInfo); + this._onrequest(message, extra); } else { this._onnotification(message); } @@ -296,7 +296,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest, authInfo?: AuthInfo): void { + private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -321,16 +321,17 @@ export abstract class Protocol< const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); - // Create extra object with both abort signal and sessionId from transport - const extra: RequestHandlerExtra = { + // Create fullExtra object with both abort signal and sessionId from transport + // in addition to any authInfo provided + const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, - authInfo, + authInfo: extra?.authInfo, }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(request, extra)) + .then(() => handler(request, fullExtra)) .then( (result) => { if (abortController.signal.aborted) { From b0fdf0030bf224dd449766291b5ae1456a56f2d9 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:45:05 -0700 Subject: [PATCH 08/42] Update inMemory.ts --- src/inMemory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/inMemory.ts b/src/inMemory.ts index 558ca7ad..5dd6e81e 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -1,5 +1,5 @@ import { Transport } from "./shared/transport.js"; -import { JSONRPCMessage } from "./types.js"; +import { JSONRPCMessage, RequestId } from "./types.js"; import { AuthInfo } from "./server/auth/types.js"; interface QueuedMessage { @@ -49,7 +49,7 @@ export class InMemoryTransport implements Transport { * Sends a message with optional auth info. * This is useful for testing authentication scenarios. */ - async send(message: JSONRPCMessage, options?: { authInfo?: AuthInfo }): Promise { + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } From 85e6457b842cb28c122e80bd4c7ecbc35a2a018f Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 16:47:18 -0700 Subject: [PATCH 09/42] Update protocol.ts --- src/shared/protocol.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 39483b77..91fa8366 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -357,8 +357,6 @@ export abstract class Protocol< const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); - // Create `fullExtra` object with both abort signal and sessionId from transport - // in addition to any authInfo provided const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, From 23188ec44650007e4ddc716ba218daafb2afebb5 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:06:46 -0700 Subject: [PATCH 10/42] Add custom claims to authinfo --- src/server/auth/types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 93c5a493..cd1430f1 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -21,4 +21,9 @@ export interface AuthInfo { * When the token expires (in seconds since epoch). */ expiresAt?: number; + + /** + * Additional arbitrary properties that may be included. + */ + [key: string]: unknown; } \ No newline at end of file From 64fadf3cf21b1fadb8c28d94213a0bcbe0027040 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 08:26:49 +0100 Subject: [PATCH 11/42] resume streams on error - initial implementation --- src/client/streamableHttp.test.ts | 32 ++++-- src/client/streamableHttp.ts | 72 ++++++++---- src/server/streamableHttp.test.ts | 176 ++++++++++++++++++++++++++++- src/server/streamableHttp.ts | 180 ++++++++++++++++++++++++------ 4 files changed, 392 insertions(+), 68 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 1572e912..8d9a6a9c 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -275,17 +275,21 @@ describe("StreamableHTTPClientTransport", () => { })).toBe(true); }); - it("should include last-event-id header when resuming a broken connection", async () => { - // First make a successful connection that provides an event ID + it("should handle reconnection with last-event-id for resumability", async () => { + // Set up a stream that will send an event with ID then error const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; controller.enqueue(encoder.encode(event)); - controller.close(); + // Simulate network error + setTimeout(() => { + controller.error(new Error("Network error")); + }, 10); } }); + // Mock the initial connection (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, @@ -293,23 +297,33 @@ describe("StreamableHTTPClientTransport", () => { body: stream }); + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + await transport.start(); await transport["_startOrAuthStandaloneSSE"](); + + // Let the stream process and error await new Promise(resolve => setTimeout(resolve, 50)); - - // Now simulate attempting to reconnect + + // Verify error was caught + expect(errorSpy).toHaveBeenCalled(); + + // Mock the reconnection (the transport should try to reconnect after error) (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: null }); - - await transport["_startOrAuthStandaloneSSE"](); - - // Check that Last-Event-ID was included + + // Allow time for automatic reconnection + await new Promise(resolve => setTimeout(resolve, 1100)); // > 1 second delay + + // Verify that the client attempted to reconnect with the last-event-id const calls = (global.fetch as jest.Mock).mock.calls; const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("GET"); expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ea69ee77..9b3c7c20 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -49,7 +49,6 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; - private _lastEventId?: string; onclose?: () => void; onerror?: (error: Error) => void; @@ -102,16 +101,16 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(): Promise { + private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it const headers = await this._commonHeaders(); headers.set("Accept", "text/event-stream"); - // Include Last-Event-ID header for resumable streams - if (this._lastEventId) { - headers.set("last-event-id", this._lastEventId); + // Include Last-Event-ID header for resumable streams if provided + if (lastEventId) { + headers.set("last-event-id", lastEventId); } const response = await fetch(this._url, { @@ -150,31 +149,61 @@ export class StreamableHTTPClientTransport implements Transport { return; } + let lastEventId: string | undefined; + const processStream = async () => { // Create a pipeline: binary stream -> text decoder -> SSE parser const eventStream = stream .pipeThrough(new TextDecoderStream()) .pipeThrough(new EventSourceParserStream()); - for await (const event of eventStream) { - // Update last event ID if provided - if (event.id) { - this._lastEventId = event.id; - } - // Handle message events (default event type is undefined per docs) - // or explicit 'message' event type - if (!event.event || event.event === "message") { - try { - const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); + try { + for await (const event of eventStream) { + // Update last event ID if provided + if (event.id) { + lastEventId = event.id; } + + // Handle message events (default event type is undefined per docs) + // or explicit 'message' event type + if (!event.event || event.event === "message") { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + } catch (error) { + // Handle stream errors - likely a network disconnect + this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); + + // Attempt to reconnect if the stream disconnects unexpectedly + // Wait a short time before reconnecting to avoid rapid reconnection loops + if (this._abortController && !this._abortController.signal.aborted) { + setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); + }); + }, 1000); // 1 second delay before reconnection attempt } } }; - processStream().catch(err => this.onerror?.(err)); + processStream().catch(err => { + this.onerror?.(err); + + // Try to reconnect on unexpected errors + if (this._abortController && !this._abortController.signal.aborted) { + setTimeout(() => { + this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); + }); + }, 1000); + } + }); } async start() { @@ -252,7 +281,7 @@ export class StreamableHTTPClientTransport implements Transport { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { - // We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE + // Start without a lastEventId since this is a fresh connection this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); } return; @@ -268,6 +297,9 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { + // Handle SSE stream responses for requests + // We use the same handler as standalone streams, which now supports + // reconnection with the last event ID this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 949cafc7..a03a457d 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,7 +1,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -import { StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { EventStore, StreamableHTTPServerTransport } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; @@ -13,6 +13,7 @@ interface TestServerConfig { sessionIdGenerator?: () => string | undefined; enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; + eventStore?: EventStore; } /** @@ -26,7 +27,7 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ }> { const mcpServer = new McpServer( { name: "test-server", version: "1.0.0" }, - { capabilities: {} } + { capabilities: { logging: {} } } ); mcpServer.tool( @@ -40,7 +41,8 @@ async function createTestServer(config: TestServerConfig = {}): Promise<{ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator ?? (() => randomUUID()), - enableJsonResponse: config.enableJsonResponse ?? false + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore }); await mcpServer.connect(transport); @@ -89,7 +91,10 @@ const TEST_MESSAGES = { params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26", + capabilities: { + }, }, + id: "init-1", } as JSONRPCMessage, @@ -896,6 +901,171 @@ describe("StreamableHTTPServerTransport with pre-parsed body", () => { }); }); +// Test resumability support +describe("StreamableHTTPServerTransport with resumability", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); + + // Simple implementation of EventStore + const eventStore: EventStore = { + generateEventId(streamId: string): string { + return `${streamId}_${randomUUID()}`; + }, + getStreamIdFromEventId(eventId: string): string { + return eventId.split('_')[0]; // Extract stream ID from the event ID + }, + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + async getEventsAfter(lastEventId: string): Promise> { + const streamId = lastEventId.split('_')[0]; // Extract stream ID from the event ID + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + // This avoids issues with event ordering in tests + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + result.push({ eventId, message }); + } + } + + return result; + }, + }; + + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Verify resumability is enabled on the transport + expect((transport)['_eventStore']).toBeDefined(); + + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); + + it("should store and include event IDs in server SSE messages", async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); + + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification with event ID" }, + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('standalonesse')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); + + + it("should store and replay MCP server tool notifications", async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + expect(sseResponse.status).toBe(200); // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: "info", data: "First notification from MCP server" }); + + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]; + + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: "info", data: "Second notification from MCP server" }); + + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); + + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "last-event-id": firstEventId + }, + }); + + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); + + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); + }); +}); + // Test stateless mode describe("StreamableHTTPServerTransport in stateless mode", () => { let server: Server; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0eaaa673..0474fc21 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,47 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { randomUUID } from "node:crypto"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +/** + * Interface for resumability support via event storage + */ +export interface EventStore { + /** + * Generates a unique event ID for a given stream ID + * @param streamId The stream ID to include in the event ID + * @returns A unique event ID that includes the stream ID + */ + generateEventId(streamId: string): string; + + /** + * Stores an event for later retrieval + * @param streamId ID of the stream the event belongs to + * @param message The JSON-RPC message to store + * @returns The generated event ID for the stored event + */ + storeEvent(streamId: string, message: JSONRPCMessage): Promise; + + /** + * Retrieves events for a stream starting from a given event ID + * @param lastEventId The event ID to start from + * @returns Array of stored events with their event IDs + */ + getEventsAfter(lastEventId: string): Promise>; + + /** + * Extracts the stream ID from an event ID + * This is necessary for resumability to identify which stream the event belongs to + * @param eventId The event ID to extract stream ID from + * @returns The stream ID portion of the event ID + */ + getStreamIdFromEventId(eventId: string): string; +} + /** * Configuration options for StreamableHTTPServerTransport */ @@ -24,6 +60,12 @@ export interface StreamableHTTPServerTransportOptions { * Default is false (SSE streams are preferred). */ enableJsonResponse?: boolean; + + /** + * Event store for resumability support + * If provided, resumability will be enabled, allowing clients to reconnect and resume messages + */ + eventStore?: EventStore; } /** @@ -64,12 +106,13 @@ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private sessionIdGenerator: () => string | undefined; private _started: boolean = false; - private _responseMapping: Map = new Map(); + private _streamMapping: Map = new Map(); + private _requestToStreamMapping: Map = new Map(); private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _standaloneSSE: ServerResponse | undefined; - + private _standaloneSseStreamId: string = 'standalonesse'; + private _eventStore?: EventStore; sessionId?: string | undefined; onclose?: () => void; @@ -79,6 +122,7 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; + this._eventStore = options.eventStore; } /** @@ -131,6 +175,14 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateSession(req, res)) { return; } + // Handle resumability: check for Last-Event-ID header + if (this._eventStore) { + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + await this.replayEvents(lastEventId, res); + return; + } + } // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, // or else return HTTP 405 Method Not Allowed @@ -144,12 +196,9 @@ export class StreamableHTTPServerTransport implements Transport { if (this.sessionId !== undefined) { headers["mcp-session-id"] = this.sessionId; } - // The server MAY include a Last-Event-ID header in the response to this HTTP GET. - // Resumability will be supported in the future // Check if there's already an active standalone SSE stream for this session - - if (this._standaloneSSE !== undefined) { + if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { // Only one GET SSE stream is allowed per session res.writeHead(409).end(JSON.stringify({ jsonrpc: "2.0", @@ -161,19 +210,62 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - // We need to send headers immediately as message will arrive much later, + + // We need to send headers immediately as messages will arrive much later, // otherwise the client will just wait for the first message res.writeHead(200, headers).flushHeaders(); - // Assing the response to the standalone SSE stream - this._standaloneSSE = res; + // Assign the response to the standalone SSE stream + this._streamMapping.set(this._standaloneSseStreamId, res); // Set up close handler for client disconnects res.on("close", () => { - this._standaloneSSE = undefined; + this._streamMapping.delete(this._standaloneSseStreamId); }); } + /** + * Replays events that would have been sent after the specified event ID + * Only used when resumability is enabled + */ + private async replayEvents(lastEventId: string, res: ServerResponse): Promise { + if (!this._eventStore) { + return; + } + + try { + const events = await this._eventStore.getEventsAfter(lastEventId); + const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); + + const oldResStream = this._streamMapping.get(streamId); + this._streamMapping.set(streamId, res); + if (oldResStream) { + // If we have an old response stream, close it + oldResStream.end(); + } + + for (const { eventId, message } of events) { + this.writeSSEEvent(res, message, eventId); + } + } catch (error) { + this.onerror?.(error as Error); + } + } + + /** + * Writes an event to the SSE stream with proper formatting + */ + private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean { + let eventData = `event: message\n`; + // Include event ID if provided - this is important for resumability + if (eventId) { + eventData += `id: ${eventId}\n`; + } + eventData += `data: ${JSON.stringify(message)}\n\n`; + + return res.write(eventData); + } + /** * Handles unsupported requests (PUT, PATCH, etc.) */ @@ -285,9 +377,9 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests - const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); + const hasRequests = messages.some(isJSONRPCRequest); const hasOnlyNotificationsOrResponses = messages.every(msg => - ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); + isJSONRPCNotification(msg) || isJSONRPCResponse(msg)); if (hasOnlyNotificationsOrResponses) { // if it only contains notifications or responses, return 202 @@ -300,6 +392,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (hasRequests) { // The default behavior is to use SSE streaming // but in some cases server will return JSON responses + const streamId = randomUUID(); if (!this._enableJsonResponse) { const headers: Record = { "Content-Type": "text/event-stream", @@ -318,19 +411,22 @@ export class StreamableHTTPServerTransport implements Transport { // We need to track by request ID to maintain the connection for (const message of messages) { if ('method' in message && 'id' in message) { - this._responseMapping.set(message.id, res); + this._streamMapping.set(streamId, res); + this._requestToStreamMapping.set(message.id, streamId); } } // Set up close handler for client disconnects res.on("close", () => { + // find a stream ID for this response // Remove all entries that reference this response - for (const [id, storedRes] of this._responseMapping.entries()) { - if (storedRes === res) { - this._responseMapping.delete(id); + for (const [id, stream] of this._requestToStreamMapping.entries()) { + if (streamId === stream) { + this._requestToStreamMapping.delete(id); this._requestResponseMap.delete(id); } } + this._streamMapping.delete(streamId); }); // handle each message @@ -431,16 +527,13 @@ export class StreamableHTTPServerTransport implements Transport { async close(): Promise { // Close all SSE connections - this._responseMapping.forEach((response) => { + this._streamMapping.forEach((response) => { response.end(); }); - this._responseMapping.clear(); + this._streamMapping.clear(); // Clear any pending responses this._requestResponseMap.clear(); - this._standaloneSSE?.end(); - this._standaloneSSE = undefined; - this.onclose?.(); } @@ -459,32 +552,47 @@ export class StreamableHTTPServerTransport implements Transport { if ('result' in message || 'error' in message) { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } - - if (this._standaloneSSE === undefined) { + const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId) + if (standaloneSse === undefined) { // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream return; } + // Generate and store event ID if event store is provided + let eventId: string | undefined; + if (this._eventStore) { + // Stores the event and gets the generated event ID + eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); + } + // Send the message to the standalone SSE stream - this._standaloneSSE.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + this.writeSSEEvent(standaloneSse, message, eventId); return; } // Get the response for this request - const response = this._responseMapping.get(requestId); - if (!response) { + const streamId = this._requestToStreamMapping.get(requestId); + const response = this._streamMapping.get(streamId!); + if (!streamId || !response) { throw new Error(`No connection established for request ID: ${String(requestId)}`); } if (!this._enableJsonResponse) { - response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); - } - if ('result' in message || 'error' in message) { - this._requestResponseMap.set(requestId, message); + // For SSE responses, generate event ID if event store is provided + let eventId: string | undefined; - // Get all request IDs that share the same request response object - const relatedIds = Array.from(this._responseMapping.entries()) - .filter(([_, res]) => res === response) + if (this._eventStore) { + eventId = await this._eventStore.storeEvent(streamId, message); + } + + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } + + if (isJSONRPCResponse(message)) { + this._requestResponseMap.set(requestId, message); + const relatedIds = Array.from(this._requestToStreamMapping.entries()) + .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) .map(([id]) => id); // Check if we have responses for all requests using this connection @@ -516,7 +624,7 @@ export class StreamableHTTPServerTransport implements Transport { // Clean up for (const id of relatedIds) { this._requestResponseMap.delete(id); - this._responseMapping.delete(id); + this._requestToStreamMapping.delete(id); } } } From 3bd28829a5909cfe703827be3f6901cf4c19d34c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 08:28:09 +0100 Subject: [PATCH 12/42] examples --- src/examples/client/simpleStreamableHttp.ts | 498 ++++++++++++++++---- src/examples/server/simpleStreamableHttp.ts | 118 ++++- 2 files changed, 512 insertions(+), 104 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 739e1164..42ffa28e 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -1,5 +1,6 @@ import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; import { ListToolsRequest, ListToolsResultSchema, @@ -15,139 +16,434 @@ import { ResourceListChangedNotificationSchema, } from '../../types.js'; +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Track received notifications for debugging resumability +let notificationCount = 0; + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; + async function main(): Promise { - // Create a new client with streamable HTTP transport - const client = new Client({ - name: 'example-client', - version: '1.0.0' - }); + console.log('MCP Interactive Client'); + console.log('====================='); - const transport = new StreamableHTTPClientTransport( - new URL('http://localhost:3000/mcp') - ); + // Connect to server immediately with default settings + await connect(); - // Connect the client using the transport and initialize the server - await client.connect(transport); - console.log('Connected to MCP server'); - - // Set up notification handlers for server-initiated messages - client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { - console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); - }); - client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { - console.log(`Resource list changed notification received!`); - const resourcesRequest: ListResourcesRequest = { - method: 'resources/list', - params: {} - }; - const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources count:', resourcesResult.resources.length); - }); - - // List and call tools - await listTools(client); - - await callGreetTool(client); - await callMultiGreetTool(client); - - - // List available prompts - try { - const promptsRequest: ListPromptsRequest = { - method: 'prompts/list', - params: {} - }; - const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); - console.log('Available prompts:', promptsResult.prompts); - } catch (error) { - console.log(`Prompts not supported by this server (${error})`); - } - - // Get a prompt - try { - const promptRequest: GetPromptRequest = { - method: 'prompts/get', - params: { - name: 'greeting-template', - arguments: { name: 'MCP User' } - } - }; - const promptResult = await client.request(promptRequest, GetPromptResultSchema); - console.log('Prompt template:', promptResult.messages[0].content.text); - } catch (error) { - console.log(`Prompt retrieval not supported by this server (${error})`); - } - - // List available resources - try { - const resourcesRequest: ListResourcesRequest = { - method: 'resources/list', - params: {} - }; - const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources:', resourcesResult.resources); - } catch (error) { - console.log(`Resources not supported by this server (${error})`); - } - // Keep the connection open to receive notifications - console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); + // Print help and start the command loop + printHelp(); + commandLoop(); } -async function listTools(client: Client): Promise { +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' greet [name] - Call the greet tool'); + console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' start-notifications [interval] [count] - Start periodic notifications'); + console.log(' simulate-disconnect [delay] - Simulate network disconnection after delay seconds'); + console.log(' list-prompts - List available prompts'); + console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); + console.log(' list-resources - List available resources'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +function commandLoop(): void { + readline.question('\n> ', async (input) => { + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': + await connect(args[1]); + break; + + case 'disconnect': + await disconnect(); + break; + + case 'reconnect': + await reconnect(); + break; + + case 'list-tools': + await listTools(); + break; + + case 'call-tool': + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + + case 'greet': + await callGreetTool(args[1] || 'MCP User'); + break; + + case 'multi-greet': + await callMultiGreetTool(args[1] || 'MCP User'); + break; + + case 'start-notifications': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 0; + await startNotifications(interval, count); + break; + } + + case 'simulate-disconnect': { + const delay = args[1] ? parseInt(args[1], 10) : 5; + simulateDisconnect(delay); + break; + } + + case 'list-prompts': + await listPrompts(); + break; + + case 'get-prompt': + if (args.length < 2) { + console.log('Usage: get-prompt [args]'); + } else { + const promptName = args[1]; + let promptArgs = {}; + if (args.length > 2) { + try { + promptArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await getPrompt(promptName, promptArgs); + } + break; + + case 'list-resources': + await listResources(); + break; + + case 'help': + printHelp(); + break; + + case 'quit': + case 'exit': + await cleanup(); + return; + + default: + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } + + // Continue the command loop + commandLoop(); + }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`Connecting to ${serverUrl}...`); + + try { + // Create a new client + client = new Client({ + name: 'example-client', + version: '1.0.0' + }); + + transport = new StreamableHTTPClientTransport( + new URL(serverUrl) + ); + + // Set up notification handlers + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + notificationCount++; + console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); + // Re-display the prompt + process.stdout.write('> '); + }); + + client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { + console.log(`\nResource list changed notification received!`); + try { + if (!client) { + console.log('Client disconnected, cannot fetch resources'); + return; + } + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + console.log('Available resources count:', resourcesResult.resources.length); + } catch { + console.log('Failed to list resources after change notification'); + } + // Re-display the prompt + process.stdout.write('> '); + }); + + // Connect the client + await client.connect(transport); + console.log('Connected to MCP server'); + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + try { const toolsRequest: ListToolsRequest = { method: 'tools/list', params: {} }; const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); + + console.log('Available tools:'); if (toolsResult.tools.length === 0) { - console.log('No tools available from the server'); + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } } } catch (error) { console.log(`Tools not supported by this server (${error})`); - return } } -async function callGreetTool(client: Client): Promise { - try { - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); - } catch (error) { - console.log(`Error calling greet tool: ${error}`); +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; } -} -async function callMultiGreetTool(client: Client): Promise { try { - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { + const request: CallToolRequest = { method: 'tools/call', params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } + name, + arguments: args } }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { + + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { if (item.type === 'text') { - console.log(`- ${item.text}`); + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); } }); } catch (error) { - console.log(`Error calling multi-greet tool: ${error}`); + console.log(`Error calling tool ${name}: ${error}`); } } +async function callGreetTool(name: string): Promise { + await callTool('greet', { name }); +} + +async function callMultiGreetTool(name: string): Promise { + console.log('Calling multi-greet tool with notifications...'); + await callTool('multi-greet', { name }); +} + +async function startNotifications(interval: number, count: number): Promise { + console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); + await callTool('start-notification-stream', { interval, count }); +} + +function simulateDisconnect(delaySeconds: number): void { + if (!transport) { + console.log('Not connected.'); + return; + } + + console.log(`Will simulate network disconnect in ${delaySeconds} seconds...`); + setTimeout(async () => { + if (!transport) return; + + console.log('\nSimulating network disconnect...'); + await transport.close(); + console.log('Transport closed. Client should automatically attempt to reconnect...'); + + // Keep the client object but null the transport + // This simulates a network disconnection without explicit user disconnect + transport = null; + + // Re-display the prompt + process.stdout.write('> '); + }, delaySeconds * 1000); +} + +async function listPrompts(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptsRequest: ListPromptsRequest = { + method: 'prompts/list', + params: {} + }; + const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); + console.log('Available prompts:'); + if (promptsResult.prompts.length === 0) { + console.log(' No prompts available'); + } else { + for (const prompt of promptsResult.prompts) { + console.log(` - ${prompt.name}: ${prompt.description}`); + } + } + } catch (error) { + console.log(`Prompts not supported by this server (${error})`); + } +} + +async function getPrompt(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptRequest: GetPromptRequest = { + method: 'prompts/get', + params: { + name, + arguments: args as Record + } + }; + + const promptResult = await client.request(promptRequest, GetPromptResultSchema); + console.log('Prompt template:'); + promptResult.messages.forEach((msg, index) => { + console.log(` [${index + 1}] ${msg.role}: ${msg.content.text}`); + }); + } catch (error) { + console.log(`Error getting prompt ${name}: ${error}`); + } +} + +async function listResources(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); + + console.log('Available resources:'); + if (resourcesResult.resources.length === 0) { + console.log(' No resources available'); + } else { + for (const resource of resourcesResult.resources) { + console.log(` - ${resource.name}: ${resource.uri}`); + } + } + } catch (error) { + console.log(`Resources not supported by this server (${error})`); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + readline.close(); + console.log('\nGoodbye!'); + process.exit(0); +} + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client main().catch((error: unknown) => { console.error('Error running MCP client:', error); process.exit(1); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index f0f74439..a45b3158 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,9 +1,75 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, JSONRPCMessage, ReadResourceResult } from '../../types.js'; + +// Create a simple in-memory EventStore for resumability +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + + /** + * Stores an event with a generated event ID + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + console.log(`Storing event ${eventId} for stream ${streamId}`); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Retrieves events that occurred after a specific event + */ + async getEventsAfter(lastEventId: string): Promise> { + if (!lastEventId || !this.events.has(lastEventId)) { + return []; + } + + // Extract the stream ID from the event ID + const streamId = lastEventId.split('_')[0]; + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start collecting events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + result.push({ eventId, message }); + } + } + + console.log(`Found ${result.length} events after ${lastEventId} for replay`); + return result; + } + +} // Create an MCP server with implementation details const server = new McpServer({ @@ -92,6 +158,43 @@ server.prompt( } ); +// Register a tool specifically for testing resumability +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } +); + // Create a simple resource at a fixed URI server.resource( 'greeting-resource', @@ -127,8 +230,10 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request + const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability }); // Connect the transport to the MCP server BEFORE handling the request @@ -182,7 +287,14 @@ app.get('/mcp', async (req: Request, res: Response) => { return; } - console.log(`Establishing SSE stream for session ${sessionId}`); + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + const transport = transports[sessionId]; await transport.handleRequest(req, res); }); From 6374f433909d9b0e78f33fdc021158943c1acf04 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 15:18:52 +0100 Subject: [PATCH 13/42] limit on retries --- src/client/streamableHttp.test.ts | 123 ++++++++++------ src/client/streamableHttp.ts | 155 ++++++++++++++++---- src/examples/client/simpleStreamableHttp.ts | 33 +---- src/server/streamableHttp.ts | 14 +- 4 files changed, 216 insertions(+), 109 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 8d9a6a9c..a73927ac 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,4 +1,4 @@ -import { StreamableHTTPClientTransport } from "./streamableHttp.js"; +import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; import { JSONRPCMessage } from "../types.js"; @@ -275,59 +275,62 @@ describe("StreamableHTTPClientTransport", () => { })).toBe(true); }); - it("should handle reconnection with last-event-id for resumability", async () => { - // Set up a stream that will send an event with ID then error - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; - controller.enqueue(encoder.encode(event)); - // Simulate network error - setTimeout(() => { - controller.error(new Error("Network error")); - }, 10); + it("should support custom reconnection options", () => { + // Create a transport with custom reconnection options + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 500, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 2, + maxRetries: 5, } }); - // Mock the initial connection - (global.fetch as jest.Mock).mockResolvedValueOnce({ + // Verify options were set correctly (checking implementation details) + // Access private properties for testing + const transportInstance = transport as unknown as { + _defaultReconnectionOptions: StreamableHTTPReconnectionOptions; + }; + expect(transportInstance._defaultReconnectionOptions.initialReconnectionDelay).toBe(1000); + expect(transportInstance._defaultReconnectionOptions.maxRetries).toBe(2); + }); + + it("should pass lastEventId when reconnecting", async () => { + // Create a fresh transport + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + + // Mock fetch to verify headers sent + const fetchSpy = global.fetch as jest.Mock; + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), - body: stream + body: new ReadableStream() }); - const errorSpy = jest.fn(); - transport.onerror = errorSpy; - + // Call the reconnect method directly with a lastEventId await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); - - // Let the stream process and error - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify error was caught - expect(errorSpy).toHaveBeenCalled(); - - // Mock the reconnection (the transport should try to reconnect after error) - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers({ "content-type": "text/event-stream" }), - body: null - }); - - // Allow time for automatic reconnection - await new Promise(resolve => setTimeout(resolve, 1100)); // > 1 second delay - - // Verify that the client attempted to reconnect with the last-event-id - const calls = (global.fetch as jest.Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].method).toBe("GET"); - expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); + // Type assertion to access private method + const transportWithPrivateMethods = transport as unknown as { + _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + }; + await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + + // Verify fetch was called with the lastEventId header + expect(fetchSpy).toHaveBeenCalled(); + const fetchCall = fetchSpy.mock.calls[0]; + const headers = fetchCall[1].headers; + expect(headers.get("last-event-id")).toBe("test-event-id"); }); it("should throw error when invalid content-type is received", async () => { + // Clear any previous state from other tests + jest.clearAllMocks(); + + // Create a fresh transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + const message: JSONRPCMessage = { jsonrpc: "2.0", method: "test", @@ -337,7 +340,7 @@ describe("StreamableHTTPClientTransport", () => { const stream = new ReadableStream({ start(controller) { - controller.enqueue("invalid text response"); + controller.enqueue(new TextEncoder().encode("invalid text response")); controller.close(); } }); @@ -389,4 +392,38 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + + + it("should have exponential backoff with configurable maxRetries", () => { + // This test verifies the maxRetries and backoff calculation directly + + // Create transport with specific options for testing + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3, + } + }); + + // Get access to the internal implementation + const getDelay = transport["_getNextReconnectionDelay"].bind(transport); + + // First retry - should use initial delay + expect(getDelay(0)).toBe(100); + + // Second retry - should double (2^1 * 100 = 200) + expect(getDelay(1)).toBe(200); + + // Third retry - should double again (2^2 * 100 = 400) + expect(getDelay(2)).toBe(400); + + // Fourth retry - should double again (2^3 * 100 = 800) + expect(getDelay(3)).toBe(800); + + // Tenth retry - should be capped at maxReconnectionDelay + expect(getDelay(10)).toBe(5000); + }); + }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9b3c7c20..9451e8d9 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,3 +1,4 @@ +import { log } from "node:console"; import { Transport } from "../shared/transport.js"; import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; @@ -12,6 +13,35 @@ export class StreamableHTTPError extends Error { } } +/** + * Configuration options for reconnection behavior of the StreamableHTTPClientTransport. + */ +export interface StreamableHTTPReconnectionOptions { + /** + * Maximum backoff time between reconnection attempts in milliseconds. + * Default is 30000 (30 seconds). + */ + maxReconnectionDelay: number; + + /** + * Initial backoff time between reconnection attempts in milliseconds. + * Default is 1000 (1 second). + */ + initialReconnectionDelay: number; + + /** + * The factor by which the reconnection delay increases after each attempt. + * Default is 1.5. + */ + reconnectionDelayGrowFactor: number; + + /** + * Maximum number of reconnection attempts before giving up. + * Default is 0 (unlimited). + */ + maxRetries: number; +} + /** * Configuration options for the `StreamableHTTPClientTransport`. */ @@ -36,6 +66,11 @@ export type StreamableHTTPClientTransportOptions = { * Customizes HTTP requests to the server. */ requestInit?: RequestInit; + + /** + * Options to configure the reconnection behavior. + */ + reconnectionOptions?: StreamableHTTPReconnectionOptions; }; /** @@ -49,6 +84,7 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -61,6 +97,7 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; } private async _authThenStart(): Promise { @@ -136,7 +173,7 @@ export class StreamableHTTPClientTransport implements Transport { `Failed to open SSE stream: ${response.statusText}`, ); } - // Successful connection, handle the SSE stream as a standalone listener + this._handleSseStream(response.body); } catch (error) { this.onerror?.(error as Error); @@ -144,28 +181,93 @@ export class StreamableHTTPClientTransport implements Transport { } } + // Default reconnection options + private readonly _defaultReconnectionOptions: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, + }; + + // We no longer need global reconnection state as it will be maintained per stream + + /** + * Calculates the next reconnection delay using exponential backoff algorithm + * with jitter for more effective reconnections in high load scenarios. + * + * @param attempt Current reconnection attempt count for the specific stream + * @returns Time to wait in milliseconds before next reconnection attempt + */ + private _getNextReconnectionDelay(attempt: number): number { + // Access default values directly, ensuring they're never undefined + const initialDelay = this._reconnectionOptions.initialReconnectionDelay; + const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor; + const maxDelay = this._reconnectionOptions.maxReconnectionDelay; + + // Cap at maximum delay + return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); + + } + + /** + * Schedule a reconnection attempt with exponential backoff + * + * @param lastEventId The ID of the last received event for resumability + * @param attemptCount Current reconnection attempt count for this specific stream + */ + private _scheduleReconnection(lastEventId: string, attemptCount = 0): void { + // Use provided options or default options + const maxRetries = this._reconnectionOptions.maxRetries; + + // Check if we've exceeded maximum retry attempts + if (maxRetries > 0 && attemptCount >= maxRetries) { + this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + return; + } + + // Calculate next delay based on current attempt count + const delay = this._getNextReconnectionDelay(attemptCount); + log(`Reconnection attempt ${attemptCount + 1} in ${delay}ms...`); + + // Schedule the reconnection + setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); + // Schedule another attempt if this one failed, incrementing the attempt counter + this._scheduleReconnection(lastEventId, attemptCount + 1); + }); + }, delay); + } + private _handleSseStream(stream: ReadableStream | null): void { if (!stream) { return; } let lastEventId: string | undefined; - const processStream = async () => { - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); - + // this is the closest we can get to trying to cath network errors + // if something happens reader will throw try { - for await (const event of eventStream) { + // Create a pipeline: binary stream -> text decoder -> SSE parser + const reader = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + + while (true) { + const { value: event, done } = await reader.read(); + if (done) { + break; + } + // Update last event ID if provided if (event.id) { lastEventId = event.id; } - // Handle message events (default event type is undefined per docs) - // or explicit 'message' event type if (!event.event || event.event === "message") { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); @@ -179,31 +281,22 @@ export class StreamableHTTPClientTransport implements Transport { // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); - // Attempt to reconnect if the stream disconnects unexpectedly - // Wait a short time before reconnecting to avoid rapid reconnection loops + // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing if (this._abortController && !this._abortController.signal.aborted) { - setTimeout(() => { - // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { - this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); - }); - }, 1000); // 1 second delay before reconnection attempt + // Use the exponential backoff reconnection strategy + if (lastEventId !== undefined) { + try { + this._scheduleReconnection(lastEventId, 0); + } + catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + + } + } } } }; - - processStream().catch(err => { - this.onerror?.(err); - - // Try to reconnect on unexpected errors - if (this._abortController && !this._abortController.signal.aborted) { - setTimeout(() => { - this._startOrAuthStandaloneSSE(lastEventId).catch(reconnectError => { - this.onerror?.(new Error(`Failed to reconnect SSE stream: ${reconnectError instanceof Error ? reconnectError.message : String(reconnectError)}`)); - }); - }, 1000); - } - }); + processStream(); } async start() { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 42ffa28e..a687eccd 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -52,7 +52,6 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); - console.log(' simulate-disconnect [delay] - Simulate network disconnection after delay seconds'); console.log(' list-prompts - List available prompts'); console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); console.log(' list-resources - List available resources'); @@ -115,12 +114,6 @@ function commandLoop(): void { break; } - case 'simulate-disconnect': { - const delay = args[1] ? parseInt(args[1], 10) : 5; - simulateDisconnect(delay); - break; - } - case 'list-prompts': await listPrompts(); break; @@ -188,6 +181,9 @@ async function connect(url?: string): Promise { name: 'example-client', version: '1.0.0' }); + client.onerror = (error) => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + } transport = new StreamableHTTPClientTransport( new URL(serverUrl) @@ -324,29 +320,6 @@ async function startNotifications(interval: number, count: number): Promise { - if (!transport) return; - - console.log('\nSimulating network disconnect...'); - await transport.close(); - console.log('Transport closed. Client should automatically attempt to reconnect...'); - - // Keep the client object but null the transport - // This simulates a network disconnection without explicit user disconnect - transport = null; - - // Re-display the prompt - process.stdout.write('> '); - }, delaySeconds * 1000); -} - async function listPrompts(): Promise { if (!client) { console.log('Not connected to server.'); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0474fc21..b8be6d15 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -237,13 +237,17 @@ export class StreamableHTTPServerTransport implements Transport { const events = await this._eventStore.getEventsAfter(lastEventId); const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); - const oldResStream = this._streamMapping.get(streamId); this._streamMapping.set(streamId, res); - if (oldResStream) { - // If we have an old response stream, close it - oldResStream.end(); - } + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }; + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + res.writeHead(200, headers).flushHeaders(); for (const { eventId, message } of events) { this.writeSSEEvent(res, message, eventId); } From 663b68a7335ec1104cd15fd44bc22c3e53dd17d2 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 16:16:14 +0100 Subject: [PATCH 14/42] exc to disconnect in example client --- src/examples/client/simpleStreamableHttp.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index a687eccd..923ffbc2 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -405,11 +405,33 @@ async function cleanup(): Promise { } } + + process.stdin.setRawMode(false); readline.close(); console.log('\nGoodbye!'); process.exit(0); } +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async (data) => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + // Handle Ctrl+C process.on('SIGINT', async () => { console.log('\nReceived SIGINT. Cleaning up...'); From 43cb015844a92c6a08bb5914aee78c2615d4a9cd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 21:40:15 +0100 Subject: [PATCH 15/42] resume long running requst from new client --- src/client/streamableHttp.ts | 25 +++++++++++++++--- src/examples/client/simpleStreamableHttp.ts | 16 ++++++++++-- src/examples/server/simpleStreamableHttp.ts | 20 +++++++++------ src/server/streamableHttp.ts | 28 +++++++++------------ src/shared/protocol.ts | 18 ++++++++++--- src/shared/transport.ts | 2 +- 6 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9451e8d9..77acc9c4 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -71,6 +71,11 @@ export type StreamableHTTPClientTransportOptions = { * Options to configure the reconnection behavior. */ reconnectionOptions?: StreamableHTTPReconnectionOptions; + /** + * Session ID for the connection. This is used to identify the session on the server. + * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. + */ + sessionId?: string; }; /** @@ -98,6 +103,7 @@ export class StreamableHTTPClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; + this._sessionId = opts?.sessionId; } private async _authThenStart(): Promise { @@ -240,7 +246,7 @@ export class StreamableHTTPClientTransport implements Transport { }, delay); } - private _handleSseStream(stream: ReadableStream | null): void { + private _handleSseStream(stream: ReadableStream | null, onLastEventIdUpdate?: (event: string) => void): void { if (!stream) { return; } @@ -266,6 +272,7 @@ export class StreamableHTTPClientTransport implements Transport { // Update last event ID if provided if (event.id) { lastEventId = event.id; + onLastEventIdUpdate?.(lastEventId); } if (!event.event || event.event === "message") { @@ -330,8 +337,16 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise { try { + // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream + const { lastEventId, onLastEventIdUpdate } = options ?? {}; + if (lastEventId) { + // If we have at last event ID, we need to reconnect the SSE stream + this._startOrAuthStandaloneSSE(lastEventId).catch(err => this.onerror?.(err)); + return; + } + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); headers.set("accept", "application/json, text/event-stream"); @@ -393,7 +408,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body); + this._handleSseStream(response.body, onLastEventIdUpdate); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -416,4 +431,8 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } + + get sessionId(): string | undefined { + return this._sessionId; + } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 923ffbc2..922aa0ce 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -29,6 +29,8 @@ let notificationCount = 0; let client: Client | null = null; let transport: StreamableHTTPClientTransport | null = null; let serverUrl = 'http://localhost:3000/mcp'; +let notificationsToolLastEventId: string | undefined = undefined; +let sessionId: string | undefined = undefined; async function main(): Promise { console.log('MCP Interactive Client'); @@ -186,7 +188,10 @@ async function connect(url?: string): Promise { } transport = new StreamableHTTPClientTransport( - new URL(serverUrl) + new URL(serverUrl), + { + sessionId: sessionId + } ); // Set up notification handlers @@ -218,6 +223,8 @@ async function connect(url?: string): Promise { // Connect the client await client.connect(transport); + sessionId = transport.sessionId + console.log('Transport created with session ID:', sessionId); console.log('Connected to MCP server'); } catch (error) { console.error('Failed to connect:', error); @@ -291,7 +298,12 @@ async function callTool(name: string, args: Record): Promise { + notificationsToolLastEventId = event; + }; + const result = await client.request(request, CallToolResultSchema, { + lastEventId: notificationsToolLastEventId, onLastEventIdUpdate + }); console.log('Tool result:'); result.content.forEach(item => { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a45b3158..dc03f1db 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -172,14 +172,18 @@ server.tool( while (count === 0 || counter < count) { counter++; - await sendNotification({ - method: "notifications/message", - params: { - level: "info", - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - } - }); - + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } // Wait for the specified interval await sleep(interval); } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b8be6d15..7740a08f 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -217,7 +217,6 @@ export class StreamableHTTPServerTransport implements Transport { // Assign the response to the standalone SSE stream this._streamMapping.set(this._standaloneSseStreamId, res); - // Set up close handler for client disconnects res.on("close", () => { this._streamMapping.delete(this._standaloneSseStreamId); @@ -345,8 +344,10 @@ export class StreamableHTTPServerTransport implements Transport { const isInitializationRequest = messages.some( msg => 'method' in msg && msg.method === 'initialize' ); + const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { - if (this._initialized) { + // if generateSessionId is not set, the server does not support session management + if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -368,7 +369,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = this.sessionIdGenerator(); + this.sessionId = mcpSessionId ?? this.sessionIdGenerator(); this._initialized = true; } @@ -419,17 +420,8 @@ export class StreamableHTTPServerTransport implements Transport { this._requestToStreamMapping.set(message.id, streamId); } } - // Set up close handler for client disconnects res.on("close", () => { - // find a stream ID for this response - // Remove all entries that reference this response - for (const [id, stream] of this._requestToStreamMapping.entries()) { - if (streamId === stream) { - this._requestToStreamMapping.delete(id); - this._requestResponseMap.delete(id); - } - } this._streamMapping.delete(streamId); }); @@ -577,7 +569,7 @@ export class StreamableHTTPServerTransport implements Transport { // Get the response for this request const streamId = this._requestToStreamMapping.get(requestId); const response = this._streamMapping.get(streamId!); - if (!streamId || !response) { + if (!streamId) { throw new Error(`No connection established for request ID: ${String(requestId)}`); } @@ -588,9 +580,10 @@ export class StreamableHTTPServerTransport implements Transport { if (this._eventStore) { eventId = await this._eventStore.storeEvent(streamId, message); } - - // Write the event to the response stream - this.writeSSEEvent(response, message, eventId); + if (response) { + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } } if (isJSONRPCResponse(message)) { @@ -603,6 +596,9 @@ export class StreamableHTTPServerTransport implements Transport { const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); if (allResponsesReady) { + if (!response) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } if (this._enableJsonResponse) { // All responses ready, send as JSON const headers: Record = { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 07bfc02c..6193f5f1 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,6 +87,18 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; + + /** + * May be used to indicate to the transport which last event ID to associate this outgoing request with. + * This is used to resume a long-running requests that may have been interrupted and a new instance of a client is being created. + */ + lastEventId?: string; + + /** + * A callback that is invoked when the last event ID is updated. + * This is used to notidy the client that the last event ID has changed, so that client can update its state accordingly. + */ + onLastEventIdUpdate?: (event: string) => void; }; /** @@ -501,7 +513,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId } = options ?? {}; + const { relatedRequestId, lastEventId, onLastEventIdUpdate } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -543,7 +555,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId }) + }, { relatedRequestId, lastEventId, onLastEventIdUpdate }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -581,7 +593,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId }).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId, lastEventId, onLastEventIdUpdate }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index e464653b..cfbe8461 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -18,7 +18,7 @@ export interface Transport { * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; + send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise; /** * Closes the connection. From c49769b97a8420008d9f53923bd5652f602eda24 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 13 Apr 2025 23:46:38 +0100 Subject: [PATCH 16/42] integration tests --- .../taskResumability.test.ts | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 src/integration-tests/taskResumability.test.ts diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts new file mode 100644 index 00000000..9397d4a0 --- /dev/null +++ b/src/integration-tests/taskResumability.test.ts @@ -0,0 +1,329 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResult, CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; +import { z } from 'zod'; + +/** + * Simple in-memory event store implementation for resumability + */ +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + async getEventsAfter(lastEventId: string): Promise> { + if (!lastEventId || !this.events.has(lastEventId)) { + return []; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start collecting events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + result.push({ eventId, message }); + } + } + + return result; + } +} + + +describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { capabilities: { logging: {} } } + ); + + // Add a simple notification tool that completes quickly + mcpServer.tool( + 'send-notification', + 'Sends a single notification', + { + message: z.string().describe('Message to send').default('Test notification') + }, + async ({ message }, { sendNotification }) => { + // Send notification immediately + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: message + } + }); + + return { + content: [{ type: 'text', text: 'Notification sent' }] + }; + } + ); + + // Add a long-running tool that sends multiple notifications + mcpServer.tool( + 'run-notifications', + 'Sends multiple notifications over time', + { + count: z.number().describe('Number of notifications to send').default(10), + interval: z.number().describe('Interval between notifications in ms').default(50) + }, + async ({ count, interval }, { sendNotification }) => { + // Send notifications at specified intervals + for (let i = 0; i < count; i++) { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification ${i + 1} of ${count}` + } + }); + + // Wait for the specified interval before sending next notification + if (i < count - 1) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); + + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); + + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should store session ID when client connects', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientId = 'test-client-long-running'; + const notifications: any[] = []; + let sessionId: string | undefined; + let lastEventId: string | undefined; + + // Create first client + let client1 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = jest.fn((eventId: string) => { + lastEventId = eventId; + }); + + // Start the notification tool with event tracking using request + const toolPromise = client1.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 5, + interval: 10 + } + } + }, CallToolResultSchema, { + lastEventId, + onLastEventIdUpdate + }); + + // Wait for some notifications to arrive (not all) + await new Promise(resolve => setTimeout(resolve, 20)); + + // Verify we received some notifications and lastEventId was updated + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.length).toBeLessThan(5); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + // Store original notification count for later comparison + const firstClientNotificationCount = notifications.length; + + // Disconnect first client without waiting for completion + // When we close the connection, it will cause a ConnectionClosed error for + // any in-progress requests, which is expected behavior + // We need to catch the error since closing the transport will + // cause the pending toolPromise to reject with a ConnectionClosed error + await transport1.close(); + + // Try to cancel the promise, but ignore errors since it's already being handled + toolPromise.catch(err => { + // This error is expected - the connection was intentionally closed + if (err?.code !== -32000) { // ConnectionClosed error code + console.error("Unexpected error type during transport close:", err); + } + }); + + + // Create second client with same client ID + const client2 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for second client + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect second client with same session ID + const transport2 = new StreamableHTTPClientTransport(baseUrl, { + sessionId + }); + await client2.connect(transport2); + + // Resume the notification stream using lastEventId + // This is the key part - we're resuming the same long-running tool using lastEventId + const resumedToolPromise = client2.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 5, + interval: 50 + } + } + }, CallToolResultSchema, { + lastEventId, // Pass the lastEventId from the previous session + onLastEventIdUpdate + }); + + // Wait for remaining notifications + await new Promise(resolve => setTimeout(resolve, 200)); + + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(2); + + // Verify the second client received notifications that the first client didn't + expect(notifications.length).toBeGreaterThan(firstClientNotificationCount); + + // Clean up + + await transport2.close(); + + }); +}); \ No newline at end of file From c21b9a0ac2af49a353ee856fe60bc4a974cdf214 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 14 Apr 2025 09:08:01 +0100 Subject: [PATCH 17/42] fix lint --- .../taskResumability.test.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 9397d4a0..c63d3f04 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -5,7 +5,7 @@ import { Client } from '../client/index.js'; import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { EventStore, StreamableHTTPServerTransport } from '../server/streamableHttp.js'; -import { CallToolResult, CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; +import { CallToolResultSchema, JSONRPCMessage, LoggingMessageNotificationSchema } from '../types.js'; import { z } from 'zod'; /** @@ -204,19 +204,18 @@ describe('Transport resumability', () => { it('should resume long-running notifications with lastEventId', async () => { // Create unique client ID for this test const clientId = 'test-client-long-running'; - const notifications: any[] = []; - let sessionId: string | undefined; + const notifications = []; let lastEventId: string | undefined; // Create first client - let client1 = new Client({ + const client1 = new Client({ id: clientId, name: 'test-client', version: '1.0.0' }); // Set up notification handler for first client - client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { if (notification.method === 'notifications/message') { notifications.push(notification.params); } @@ -225,14 +224,14 @@ describe('Transport resumability', () => { // Connect first client const transport1 = new StreamableHTTPClientTransport(baseUrl); await client1.connect(transport1); - sessionId = transport1.sessionId; + const sessionId = transport1.sessionId; expect(sessionId).toBeDefined(); // Start a long-running notification stream with tracking of lastEventId const onLastEventIdUpdate = jest.fn((eventId: string) => { lastEventId = eventId; }); - + expect(lastEventId).toBeUndefined(); // Start the notification tool with event tracking using request const toolPromise = client1.request({ method: 'tools/call', @@ -284,7 +283,7 @@ describe('Transport resumability', () => { }); // Set up notification handler for second client - client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification: any) => { + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { if (notification.method === 'notifications/message') { notifications.push(notification.params); } @@ -298,12 +297,12 @@ describe('Transport resumability', () => { // Resume the notification stream using lastEventId // This is the key part - we're resuming the same long-running tool using lastEventId - const resumedToolPromise = client2.request({ + await client2.request({ method: 'tools/call', params: { name: 'run-notifications', arguments: { - count: 5, + count: 1, interval: 50 } } @@ -312,9 +311,6 @@ describe('Transport resumability', () => { onLastEventIdUpdate }); - // Wait for remaining notifications - await new Promise(resolve => setTimeout(resolve, 200)); - // Verify we eventually received at leaset a few motifications expect(notifications.length).toBeGreaterThan(2); From 7aeb67988a5d4d5d3d065087f0d5cefb2fdfe767 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 14 Apr 2025 09:37:19 +0100 Subject: [PATCH 18/42] improve comments --- src/client/streamableHttp.test.ts | 10 +++++----- src/client/streamableHttp.ts | 10 +++++----- src/server/streamableHttp.ts | 3 ++- src/shared/protocol.ts | 7 +++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index a73927ac..2dfa5869 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,7 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); + await expect(transport["_startOrAuthSse"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -208,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthSse"](); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + _startOrAuthSse: (lastEventId?: string) => Promise }; - await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + await transportWithPrivateMethods._startOrAuthSse("test-event-id"); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); @@ -382,7 +382,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthSse"](); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 77acc9c4..43030239 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -123,7 +123,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthSse(); } private async _commonHeaders(): Promise { @@ -144,7 +144,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { + private async _startOrAuthSse(lastEventId?: string): Promise { try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -238,7 +238,7 @@ export class StreamableHTTPClientTransport implements Transport { // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this._startOrAuthSse(lastEventId).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter this._scheduleReconnection(lastEventId, attemptCount + 1); @@ -343,7 +343,7 @@ export class StreamableHTTPClientTransport implements Transport { const { lastEventId, onLastEventIdUpdate } = options ?? {}; if (lastEventId) { // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthStandaloneSSE(lastEventId).catch(err => this.onerror?.(err)); + this._startOrAuthSse(lastEventId).catch(err => this.onerror?.(err)); return; } @@ -390,7 +390,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + this._startOrAuthSse().catch(err => this.onerror?.(err)); } return; } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 7740a08f..0487e1af 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -346,7 +346,8 @@ export class StreamableHTTPServerTransport implements Transport { ); const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { - // if generateSessionId is not set, the server does not support session management + // If it's a server with session management and the session ID is already set we should reject the request + // to avoid re-initialization. if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 6193f5f1..bc7882e4 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,16 +87,15 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; - /** - * May be used to indicate to the transport which last event ID to associate this outgoing request with. - * This is used to resume a long-running requests that may have been interrupted and a new instance of a client is being created. + * The last event ID to associate with the request. + * Used to resume long-running requests that were interrupted when creating a new client instance. */ lastEventId?: string; /** * A callback that is invoked when the last event ID is updated. - * This is used to notidy the client that the last event ID has changed, so that client can update its state accordingly. + * This is used to notify the client that the last event ID has changed, so the client can update its state accordingly. */ onLastEventIdUpdate?: (event: string) => void; }; From ab3bb94290e802753cf1217c383544cc697f0f16 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:08 -0700 Subject: [PATCH 19/42] Adjust arbitrary keys to custom claims field to prevent conflicts --- src/server/auth/types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index cd1430f1..5e6bc562 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -23,7 +23,9 @@ export interface AuthInfo { expiresAt?: number; /** - * Additional arbitrary properties that may be included. + * Additional custom claims associated with the token. + * This field should be used for any additional data that needs to be attached to the auth info. + * Using this field instead of arbitrary keys ensures compatibility with future interface changes. */ - [key: string]: unknown; + customClaims?: Record; } \ No newline at end of file From b30a77e1b924af1ba4ec57962c4733bda4488e88 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:10:57 -0700 Subject: [PATCH 20/42] Update types.ts --- src/server/auth/types.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 5e6bc562..c25c2b60 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -23,9 +23,8 @@ export interface AuthInfo { expiresAt?: number; /** - * Additional custom claims associated with the token. + * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. - * Using this field instead of arbitrary keys ensures compatibility with future interface changes. - */ - customClaims?: Record; + */ + extra?: Record; } \ No newline at end of file From 583202f81aedfc0b39bc8d9ae82b7cea56bc7688 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 09:24:11 +0100 Subject: [PATCH 22/42] address client comments --- src/client/streamableHttp.ts | 53 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9451e8d9..7bb88c0d 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,9 +1,16 @@ -import { log } from "node:console"; import { Transport } from "../shared/transport.js"; import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +// Default reconnection options for StreamableHTTP connections +const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, +}; + export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -13,6 +20,16 @@ export class StreamableHTTPError extends Error { } } +/** + * Options for starting or authenticating an SSE connection + */ +export interface StartSSEOptions { + /** + * The ID of the last received event, used for resuming a disconnected stream + */ + lastEventId?: string; +} + /** * Configuration options for reconnection behavior of the StreamableHTTPClientTransport. */ @@ -37,7 +54,7 @@ export interface StreamableHTTPReconnectionOptions { /** * Maximum number of reconnection attempts before giving up. - * Default is 0 (unlimited). + * Default is 2. */ maxRetries: number; } @@ -97,7 +114,7 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; - this._reconnectionOptions = opts?.reconnectionOptions || this._defaultReconnectionOptions; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } private async _authThenStart(): Promise { @@ -117,7 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthStandaloneSSE({ lastEventId: undefined }); } private async _commonHeaders(): Promise { @@ -138,7 +155,9 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(lastEventId?: string): Promise { + + private async _startOrAuthStandaloneSSE(options: StartSSEOptions): Promise { + const { lastEventId } = options; try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -181,19 +200,9 @@ export class StreamableHTTPClientTransport implements Transport { } } - // Default reconnection options - private readonly _defaultReconnectionOptions: StreamableHTTPReconnectionOptions = { - initialReconnectionDelay: 1000, - maxReconnectionDelay: 30000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2, - }; - - // We no longer need global reconnection state as it will be maintained per stream /** - * Calculates the next reconnection delay using exponential backoff algorithm - * with jitter for more effective reconnections in high load scenarios. + * Calculates the next reconnection delay using backoff algorithm * * @param attempt Current reconnection attempt count for the specific stream * @returns Time to wait in milliseconds before next reconnection attempt @@ -227,12 +236,11 @@ export class StreamableHTTPClientTransport implements Transport { // Calculate next delay based on current attempt count const delay = this._getNextReconnectionDelay(attemptCount); - log(`Reconnection attempt ${attemptCount + 1} in ${delay}ms...`); // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthStandaloneSSE(lastEventId).catch(error => { + this._startOrAuthStandaloneSSE({ lastEventId }).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter this._scheduleReconnection(lastEventId, attemptCount + 1); @@ -247,7 +255,7 @@ export class StreamableHTTPClientTransport implements Transport { let lastEventId: string | undefined; const processStream = async () => { - // this is the closest we can get to trying to cath network errors + // this is the closest we can get to trying to catch network errors // if something happens reader will throw try { // Create a pipeline: binary stream -> text decoder -> SSE parser @@ -279,7 +287,7 @@ export class StreamableHTTPClientTransport implements Transport { } } catch (error) { // Handle stream errors - likely a network disconnect - this.onerror?.(new Error(`SSE stream disconnected: ${error instanceof Error ? error.message : String(error)}`)); + this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing if (this._abortController && !this._abortController.signal.aborted) { @@ -375,7 +383,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + this._startOrAuthStandaloneSSE({ lastEventId: undefined }).catch(err => this.onerror?.(err)); } return; } @@ -390,9 +398,6 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { - // Handle SSE stream responses for requests - // We use the same handler as standalone streams, which now supports - // reconnection with the last event ID this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses From 643ef5c5e7f2970c6bde41db0f2000ee9adbd497 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 09:26:08 +0100 Subject: [PATCH 23/42] remove redundant check for onlyNotifications --- src/server/streamableHttp.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b8be6d15..37dc2479 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -382,10 +382,8 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests const hasRequests = messages.some(isJSONRPCRequest); - const hasOnlyNotificationsOrResponses = messages.every(msg => - isJSONRPCNotification(msg) || isJSONRPCResponse(msg)); - if (hasOnlyNotificationsOrResponses) { + if (!hasRequests) { // if it only contains notifications or responses, return 202 res.writeHead(202).end(); From 67a4595ae7bacd7a0a52891d0892d0a9e0feee69 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:03:44 +0100 Subject: [PATCH 24/42] improve event store interface --- src/client/streamableHttp.test.ts | 16 +++---- src/examples/server/simpleStreamableHttp.ts | 35 +++++++++------ src/server/streamableHttp.test.ts | 26 +++++------ src/server/streamableHttp.ts | 49 ++++++++------------- 4 files changed, 58 insertions(+), 68 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index a73927ac..073cc9ac 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,7 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); + await expect(transport["_startOrAuthStandaloneSSE"]({})).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -208,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthStandaloneSSE"]({}); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -289,10 +289,10 @@ describe("StreamableHTTPClientTransport", () => { // Verify options were set correctly (checking implementation details) // Access private properties for testing const transportInstance = transport as unknown as { - _defaultReconnectionOptions: StreamableHTTPReconnectionOptions; + _reconnectionOptions: StreamableHTTPReconnectionOptions; }; - expect(transportInstance._defaultReconnectionOptions.initialReconnectionDelay).toBe(1000); - expect(transportInstance._defaultReconnectionOptions.maxRetries).toBe(2); + expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500); + expect(transportInstance._reconnectionOptions.maxRetries).toBe(5); }); it("should pass lastEventId when reconnecting", async () => { @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthStandaloneSSE: (lastEventId?: string) => Promise + _startOrAuthStandaloneSSE: (options: { lastEventId?: string }) => Promise }; - await transportWithPrivateMethods._startOrAuthStandaloneSSE("test-event-id"); + await transportWithPrivateMethods._startOrAuthStandaloneSSE({ lastEventId: "test-event-id" }); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); @@ -382,7 +382,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport["_startOrAuthStandaloneSSE"](); + await transport["_startOrAuthStandaloneSSE"]({}); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a45b3158..153e35b7 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,18 +12,18 @@ class InMemoryEventStore implements EventStore { /** * Generates a unique event ID for a given stream ID */ - generateEventId(streamId: string): string { + private generateEventId(streamId: string): string { return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; } - getStreamIdFromEventId(eventId: string): string { + private getStreamIdFromEventId(eventId: string): string { const parts = eventId.split('_'); return parts.length > 0 ? parts[0] : ''; } - /** * Stores an event with a generated event ID + * Implements EventStore.storeEvent */ async storeEvent(streamId: string, message: JSONRPCMessage): Promise { const eventId = this.generateEventId(streamId); @@ -33,17 +33,26 @@ class InMemoryEventStore implements EventStore { } /** - * Retrieves events that occurred after a specific event + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter */ - async getEventsAfter(lastEventId: string): Promise> { + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { if (!lastEventId || !this.events.has(lastEventId)) { - return []; + console.log(`No events found for lastEventId: ${lastEventId}`); + return ''; } // Extract the stream ID from the event ID - const streamId = lastEventId.split('_')[0]; - const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); + return ''; + } + let foundLastEvent = false; + let eventCount = 0; // Sort events by eventId for chronological ordering const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); @@ -54,21 +63,21 @@ class InMemoryEventStore implements EventStore { continue; } - // Start collecting events after we find the lastEventId + // Start sending events after we find the lastEventId if (eventId === lastEventId) { foundLastEvent = true; continue; } if (foundLastEvent) { - result.push({ eventId, message }); + await send(eventId, message); + eventCount++; } } - console.log(`Found ${result.length} events after ${lastEventId} for replay`); - return result; + console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); + return streamId; } - } // Create an MCP server with implementation details diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index a03a457d..7cb062bc 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,7 +1,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -import { EventStore, StreamableHTTPServerTransport } from "./streamableHttp.js"; +import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; import { McpServer } from "./mcp.js"; import { CallToolResult, JSONRPCMessage } from "../types.js"; import { z } from "zod"; @@ -912,31 +912,25 @@ describe("StreamableHTTPServerTransport with resumability", () => { // Simple implementation of EventStore const eventStore: EventStore = { - generateEventId(streamId: string): string { - return `${streamId}_${randomUUID()}`; - }, - getStreamIdFromEventId(eventId: string): string { - return eventId.split('_')[0]; // Extract stream ID from the event ID - }, + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = this.generateEventId(streamId); + const eventId = `${streamId}_${randomUUID()}`; storedEvents.set(eventId, { eventId, message }); return eventId; }, - async getEventsAfter(lastEventId: string): Promise> { - const streamId = lastEventId.split('_')[0]; // Extract stream ID from the event ID - const result: Array<{ eventId: string, message: JSONRPCMessage }> = []; - + async replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise { + const streamId = lastEventId.split('_')[0]; + // Extract stream ID from the event ID // For test simplicity, just return all events with matching streamId that aren't the lastEventId - // This avoids issues with event ordering in tests for (const [eventId, { message }] of storedEvents.entries()) { if (eventId.startsWith(streamId) && eventId !== lastEventId) { - result.push({ eventId, message }); + await send(eventId, message); } } - - return result; + return streamId; }, }; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 37dc2479..58d0fe14 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,45 +1,30 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +export type StreamId = string; +export type EventId = string; + /** * Interface for resumability support via event storage */ export interface EventStore { - /** - * Generates a unique event ID for a given stream ID - * @param streamId The stream ID to include in the event ID - * @returns A unique event ID that includes the stream ID - */ - generateEventId(streamId: string): string; - /** * Stores an event for later retrieval * @param streamId ID of the stream the event belongs to * @param message The JSON-RPC message to store * @returns The generated event ID for the stored event */ - storeEvent(streamId: string, message: JSONRPCMessage): Promise; + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; - /** - * Retrieves events for a stream starting from a given event ID - * @param lastEventId The event ID to start from - * @returns Array of stored events with their event IDs - */ - getEventsAfter(lastEventId: string): Promise>; - - /** - * Extracts the stream ID from an event ID - * This is necessary for resumability to identify which stream the event belongs to - * @param eventId The event ID to extract stream ID from - * @returns The stream ID portion of the event ID - */ - getStreamIdFromEventId(eventId: string): string; + replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise; } /** @@ -232,12 +217,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this._eventStore) { return; } - try { - const events = await this._eventStore.getEventsAfter(lastEventId); - const streamId = this._eventStore.getStreamIdFromEventId(lastEventId); - - this._streamMapping.set(streamId, res); const headers: Record = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", @@ -248,9 +228,16 @@ export class StreamableHTTPServerTransport implements Transport { headers["mcp-session-id"] = this.sessionId; } res.writeHead(200, headers).flushHeaders(); - for (const { eventId, message } of events) { - this.writeSSEEvent(res, message, eventId); - } + + const streamId = await this._eventStore?.replayEventsAfter(lastEventId, { + send: async (eventId: string, message: JSONRPCMessage) => { + if (!this.writeSSEEvent(res, message, eventId)) { + this.onerror?.(new Error("Failed replay events")); + res.end(); + } + } + }); + this._streamMapping.set(streamId, res); } catch (error) { this.onerror?.(error as Error); } From 1359e9fe6a69913a450a5cc88ebb480af8551f51 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:42:16 +0100 Subject: [PATCH 25/42] pull transport send options into own named type --- src/client/streamableHttp.ts | 6 +++-- src/examples/client/simpleStreamableHttp.ts | 2 +- .../taskResumability.test.ts | 8 +++--- src/shared/protocol.ts | 25 +++++++++++-------- src/shared/transport.ts | 23 ++++++++++++++++- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 63677d55..c1c93ebe 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -88,6 +88,7 @@ export type StreamableHTTPClientTransportOptions = { * Options to configure the reconnection behavior. */ reconnectionOptions?: StreamableHTTPReconnectionOptions; + /** * Session ID for the connection. This is used to identify the session on the server. * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. @@ -345,10 +346,11 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (event: string) => void }): Promise { try { // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream - const { lastEventId, onLastEventIdUpdate } = options ?? {}; + const lastEventId = options?.resumptionToken + const onLastEventIdUpdate = options?.onresumptiontoken; if (lastEventId) { // If we have at last event ID, we need to reconnect the SSE stream this._startOrAuthSse({ lastEventId }).catch(err => this.onerror?.(err)); diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 922aa0ce..6cfad987 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -302,7 +302,7 @@ async function callTool(name: string, args: Record): Promise { } } }, CallToolResultSchema, { - lastEventId, - onLastEventIdUpdate + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate }); // Wait for some notifications to arrive (not all) @@ -311,8 +311,8 @@ describe('Transport resumability', () => { } } }, CallToolResultSchema, { - lastEventId, // Pass the lastEventId from the previous session - onLastEventIdUpdate + resumptionToken: lastEventId, // Pass the lastEventId from the previous session + onresumptiontoken: onLastEventIdUpdate }); // Verify we eventually received at leaset a few motifications diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index bc7882e4..3b098193 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -87,17 +87,20 @@ export type RequestOptions = { * May be used to indicate to the transport which incoming request to associate this outgoing request with. */ relatedRequestId?: RequestId; - /** - * The last event ID to associate with the request. - * Used to resume long-running requests that were interrupted when creating a new client instance. - */ - lastEventId?: string; /** - * A callback that is invoked when the last event ID is updated. - * This is used to notify the client that the last event ID has changed, so the client can update its state accordingly. + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. */ - onLastEventIdUpdate?: (event: string) => void; + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; }; /** @@ -512,7 +515,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId, lastEventId, onLastEventIdUpdate } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -554,7 +557,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId, lastEventId, onLastEventIdUpdate }) + }, { relatedRequestId, resumptionToken, onresumptiontoken }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -592,7 +595,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId, lastEventId, onLastEventIdUpdate }).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index cfbe8461..601a86af 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,26 @@ import { JSONRPCMessage, RequestId } from "../types.js"; +/** + * Options for sending a JSON-RPC message. + */ +export interface TransportSendOptions { + /** + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + */ + relatedRequestId?: RequestId; + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. + */ + resumptionToken?: string; + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; +} /** * Describes the minimal contract for a MCP transport that a client or server can communicate over. */ @@ -18,7 +39,7 @@ export interface Transport { * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, lastEventId?: string, onLastEventIdUpdate?: (event: string) => void }): Promise; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; /** * Closes the connection. From 54d6929149387f197c4554115b9242f31e6dbe7c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 11:58:43 +0100 Subject: [PATCH 26/42] don't need to re-initialize when we reconnect a session --- src/client/index.ts | 6 +++++- src/server/streamableHttp.ts | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 64ea62c4..a3edd0be 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -126,7 +126,11 @@ export class Client< override async connect(transport: Transport, options?: RequestOptions): Promise { await super.connect(transport); - + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } try { const result = await this.request( { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 012458f2..fed85ad9 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -331,11 +331,10 @@ export class StreamableHTTPServerTransport implements Transport { const isInitializationRequest = messages.some( msg => 'method' in msg && msg.method === 'initialize' ); - const mcpSessionId = req.headers["mcp-session-id"] as string | undefined; if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. - if (this._initialized && this.sessionId !== undefined && mcpSessionId !== this.sessionId) { + if (this._initialized) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -357,7 +356,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = mcpSessionId ?? this.sessionIdGenerator(); + this.sessionId = this.sessionIdGenerator(); this._initialized = true; } From f1e6acb17d35ccaaef2c156f24c786d0cc123de5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:37:31 +0100 Subject: [PATCH 27/42] resumed tool to return value --- src/client/streamableHttp.ts | 31 +++++++++++++------ src/examples/client/simpleStreamableHttp.ts | 2 +- .../taskResumability.test.ts | 29 ++++++++--------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index c1c93ebe..770b5d72 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -28,6 +28,14 @@ export interface StartSSEOptions { * The ID of the last received event, used for resuming a disconnected stream */ lastEventId?: string; + /** + * The callback function that is invoked when the last event ID changes + */ + onLastEventIdUpdate?: (event: string) => void + /** + * When reconnecting to a long-running SSE stream, we need to make sure that message id matches + */ + replayMessageId?: string | number; } /** @@ -200,7 +208,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - this._handleSseStream(response.body); + this._handleSseStream(response.body, options); } catch (error) { this.onerror?.(error as Error); throw error; @@ -231,7 +239,7 @@ export class StreamableHTTPClientTransport implements Transport { * @param lastEventId The ID of the last received event for resumability * @param attemptCount Current reconnection attempt count for this specific stream */ - private _scheduleReconnection(lastEventId: string, attemptCount = 0): void { + private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { // Use provided options or default options const maxRetries = this._reconnectionOptions.maxRetries; @@ -247,18 +255,19 @@ export class StreamableHTTPClientTransport implements Transport { // Schedule the reconnection setTimeout(() => { // Use the last event ID to resume where we left off - this._startOrAuthSse({ lastEventId }).catch(error => { + this._startOrAuthSse(options).catch(error => { this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); // Schedule another attempt if this one failed, incrementing the attempt counter - this._scheduleReconnection(lastEventId, attemptCount + 1); + this._scheduleReconnection(options, attemptCount + 1); }); }, delay); } - private _handleSseStream(stream: ReadableStream | null, onLastEventIdUpdate?: (event: string) => void): void { + private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions): void { if (!stream) { return; } + const { onLastEventIdUpdate, replayMessageId } = options; let lastEventId: string | undefined; const processStream = async () => { @@ -287,6 +296,9 @@ export class StreamableHTTPClientTransport implements Transport { if (!event.event || event.event === "message") { try { const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + if (replayMessageId !== undefined && isJSONRPCResponse(message)) { + message.id = replayMessageId; + } this.onmessage?.(message); } catch (error) { this.onerror?.(error as Error); @@ -302,7 +314,7 @@ export class StreamableHTTPClientTransport implements Transport { // Use the exponential backoff reconnection strategy if (lastEventId !== undefined) { try { - this._scheduleReconnection(lastEventId, 0); + this._scheduleReconnection(options, 0); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); @@ -352,8 +364,9 @@ export class StreamableHTTPClientTransport implements Transport { const lastEventId = options?.resumptionToken const onLastEventIdUpdate = options?.onresumptiontoken; if (lastEventId) { + // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ lastEventId }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ lastEventId, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); return; } @@ -418,7 +431,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, onLastEventIdUpdate); + this._handleSseStream(response.body, { onLastEventIdUpdate }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 6cfad987..d0d5408b 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -111,7 +111,7 @@ function commandLoop(): void { case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; - const count = args[2] ? parseInt(args[2], 10) : 0; + const count = args[2] ? parseInt(args[2], 10) : 10; await startNotifications(interval, count); break; } diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 7a53ee01..7101925f 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -242,7 +242,7 @@ describe('Transport resumability', () => { params: { name: 'run-notifications', arguments: { - count: 5, + count: 3, interval: 10 } } @@ -251,27 +251,23 @@ describe('Transport resumability', () => { onresumptiontoken: onLastEventIdUpdate }); - // Wait for some notifications to arrive (not all) + // Wait for some notifications to arrive (not all) - shorter wait time await new Promise(resolve => setTimeout(resolve, 20)); // Verify we received some notifications and lastEventId was updated expect(notifications.length).toBeGreaterThan(0); - expect(notifications.length).toBeLessThan(5); + expect(notifications.length).toBeLessThan(4); expect(onLastEventIdUpdate).toHaveBeenCalled(); expect(lastEventId).toBeDefined(); // Store original notification count for later comparison const firstClientNotificationCount = notifications.length; - // Disconnect first client without waiting for completion // When we close the connection, it will cause a ConnectionClosed error for // any in-progress requests, which is expected behavior - // We need to catch the error since closing the transport will - // cause the pending toolPromise to reject with a ConnectionClosed error await transport1.close(); - - // Try to cancel the promise, but ignore errors since it's already being handled - toolPromise.catch(err => { + // Save the promise so we can catch it after closing + const catchPromise = toolPromise.catch(err => { // This error is expected - the connection was intentionally closed if (err?.code !== -32000) { // ConnectionClosed error code console.error("Unexpected error type during transport close:", err); @@ -279,6 +275,14 @@ describe('Transport resumability', () => { }); + + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); + + // Wait for the rejection to be handled + await catchPromise; + + // Create second client with same client ID const client2 = new Client({ id: clientId, @@ -307,7 +311,7 @@ describe('Transport resumability', () => { name: 'run-notifications', arguments: { count: 1, - interval: 50 + interval: 5 } } }, CallToolResultSchema, { @@ -316,13 +320,10 @@ describe('Transport resumability', () => { }); // Verify we eventually received at leaset a few motifications - expect(notifications.length).toBeGreaterThan(2); + expect(notifications.length).toBeGreaterThan(1); - // Verify the second client received notifications that the first client didn't - expect(notifications.length).toBeGreaterThan(firstClientNotificationCount); // Clean up - await transport2.close(); }); From b062d75a73e8a56c898b1525a2d3f651e05a2410 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:41:33 +0100 Subject: [PATCH 28/42] change standalone sse key --- src/server/streamableHttp.test.ts | 2 +- src/server/streamableHttp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 7cb062bc..efd5de1c 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1000,7 +1000,7 @@ describe("StreamableHTTPServerTransport with resumability", () => { const eventId = idMatch![1]; expect(storedEvents.has(eventId)).toBe(true); const storedEvent = storedEvents.get(eventId); - expect(eventId.startsWith('standalonesse')).toBe(true); + expect(eventId.startsWith('_GET_stream')).toBe(true); expect(storedEvent?.message).toMatchObject(notification); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 58d0fe14..7ddfa3ab 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -96,7 +96,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _standaloneSseStreamId: string = 'standalonesse'; + private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; sessionId?: string | undefined; From f900592a2f7634555c56fd9307f567fc10c0160d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 16:43:24 +0100 Subject: [PATCH 29/42] lint --- src/integration-tests/taskResumability.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 7101925f..3bd23498 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -260,8 +260,7 @@ describe('Transport resumability', () => { expect(onLastEventIdUpdate).toHaveBeenCalled(); expect(lastEventId).toBeDefined(); - // Store original notification count for later comparison - const firstClientNotificationCount = notifications.length; + // Disconnect first client without waiting for completion // When we close the connection, it will cause a ConnectionClosed error for // any in-progress requests, which is expected behavior From f1c5ef1a8d9f5cec8e465f1d8f7571b21048ed2e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 20:40:26 +0100 Subject: [PATCH 30/42] apply suggested changes --- src/client/streamableHttp.ts | 9 ++++++--- src/shared/protocol.ts | 23 ++--------------------- src/shared/transport.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 770b5d72..44bda015 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -23,17 +23,20 @@ export class StreamableHTTPError extends Error { /** * Options for starting or authenticating an SSE connection */ -export interface StartSSEOptions { +interface StartSSEOptions { /** * The ID of the last received event, used for resuming a disconnected stream */ lastEventId?: string; + /** * The callback function that is invoked when the last event ID changes */ onLastEventIdUpdate?: (event: string) => void + /** - * When reconnecting to a long-running SSE stream, we need to make sure that message id matches + * Override Message ID to associate with the replay message + * so that response can be associate with the new resumed request. */ replayMessageId?: string | number; } @@ -358,7 +361,7 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (event: string) => void }): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream const lastEventId = options?.resumptionToken diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 3b098193..289c68d3 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -22,7 +22,7 @@ import { Result, ServerCapabilities, } from "../types.js"; -import { Transport } from "./transport.js"; +import { Transport, TransportSendOptions } from "./transport.js"; /** * Callback for progress notifications. @@ -82,26 +82,7 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; - - /** - * May be used to indicate to the transport which incoming request to associate this outgoing request with. - */ - relatedRequestId?: RequestId; - - /** - * The resumption token used to continue long-running requests that were interrupted. - * - * This allows clients to reconnect and continue from where they left off, if supported by the transport. - */ - resumptionToken?: string; - - /** - * A callback that is invoked when the resumption token changes, if supported by the transport. - * - * This allows clients to persist the latest token for potential reconnection. - */ - onresumptiontoken?: (token: string) => void; -}; +} & TransportSendOptions; /** * Options that can be given per notification. diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 601a86af..5d559674 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,19 +1,21 @@ import { JSONRPCMessage, RequestId } from "../types.js"; /** - * Options for sending a JSON-RPC message. - */ -export interface TransportSendOptions { + * Options for sending a JSON-RPC message. + */ +export type TransportSendOptions = { /** * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. - */ + */ relatedRequestId?: RequestId; + /** * The resumption token used to continue long-running requests that were interrupted. * * This allows clients to reconnect and continue from where they left off, if supported by the transport. */ resumptionToken?: string; + /** * A callback that is invoked when the resumption token changes, if supported by the transport. * From 8bc49f9f347e99a7c2dc0b8b69b3d02982bd9481 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 15 Apr 2025 22:06:10 +0100 Subject: [PATCH 31/42] change names to resumptionToken instead of last event id --- src/client/streamableHttp.test.ts | 4 +-- src/client/streamableHttp.ts | 42 ++++++++++++++++++------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 22958582..93e44150 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -313,9 +313,9 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); // Type assertion to access private method const transportWithPrivateMethods = transport as unknown as { - _startOrAuthSse: (options: { lastEventId?: string }) => Promise + _startOrAuthSse: (options: { resumptionToken?: string }) => Promise }; - await transportWithPrivateMethods._startOrAuthSse({ lastEventId: "test-event-id" }); + await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" }); // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 44bda015..077b0f15 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -25,14 +25,18 @@ export class StreamableHTTPError extends Error { */ interface StartSSEOptions { /** - * The ID of the last received event, used for resuming a disconnected stream + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off. */ - lastEventId?: string; + resumptionToken?: string; /** - * The callback function that is invoked when the last event ID changes + * A callback that is invoked when the resumption token changes. + * + * This allows clients to persist the latest token for potential reconnection. */ - onLastEventIdUpdate?: (event: string) => void + onresumptiontoken?: (token: string) => void; /** * Override Message ID to associate with the replay message @@ -152,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthSse({ lastEventId: undefined }); + return await this._startOrAuthSse({ resumptionToken: undefined }); } private async _commonHeaders(): Promise { @@ -175,7 +179,7 @@ export class StreamableHTTPClientTransport implements Transport { private async _startOrAuthSse(options: StartSSEOptions): Promise { - const { lastEventId } = options; + const { resumptionToken } = options; try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it @@ -183,8 +187,8 @@ export class StreamableHTTPClientTransport implements Transport { headers.set("Accept", "text/event-stream"); // Include Last-Event-ID header for resumable streams if provided - if (lastEventId) { - headers.set("last-event-id", lastEventId); + if (resumptionToken) { + headers.set("last-event-id", resumptionToken); } const response = await fetch(this._url, { @@ -270,7 +274,7 @@ export class StreamableHTTPClientTransport implements Transport { if (!stream) { return; } - const { onLastEventIdUpdate, replayMessageId } = options; + const { onresumptiontoken, replayMessageId } = options; let lastEventId: string | undefined; const processStream = async () => { @@ -293,7 +297,7 @@ export class StreamableHTTPClientTransport implements Transport { // Update last event ID if provided if (event.id) { lastEventId = event.id; - onLastEventIdUpdate?.(lastEventId); + onresumptiontoken?.(event.id); } if (!event.event || event.event === "message") { @@ -317,7 +321,11 @@ export class StreamableHTTPClientTransport implements Transport { // Use the exponential backoff reconnection strategy if (lastEventId !== undefined) { try { - this._scheduleReconnection(options, 0); + this._scheduleReconnection({ + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, 0); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); @@ -363,13 +371,11 @@ export class StreamableHTTPClientTransport implements Transport { async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { - // If client passes in a lastEventId in the request options, we need to reconnect the SSE stream - const lastEventId = options?.resumptionToken - const onLastEventIdUpdate = options?.onresumptiontoken; - if (lastEventId) { + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ lastEventId, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); return; } @@ -416,7 +422,7 @@ export class StreamableHTTPClientTransport implements Transport { // if it's supported by the server if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { // Start without a lastEventId since this is a fresh connection - this._startOrAuthSse({ lastEventId: undefined }).catch(err => this.onerror?.(err)); + this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); } return; } @@ -434,7 +440,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onLastEventIdUpdate }); + this._handleSseStream(response.body, { onresumptiontoken }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); From 31f44e0b68c17f10496f4ffc4bfc94f4243bb746 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 10:19:46 +0100 Subject: [PATCH 32/42] add isInitializeRequest and isInitializedNotification utility --- src/client/streamableHttp.ts | 4 ++-- src/examples/server/jsonResponseStreamableHttp.ts | 10 +--------- src/examples/server/simpleStreamableHttp.ts | 12 ++---------- .../server/standaloneSseWithGetStreamableHttp.ts | 9 +-------- src/types.ts | 7 +++++++ 5 files changed, 13 insertions(+), 29 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 077b0f15..0510fe84 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isInitializedNotification, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -420,7 +420,7 @@ export class StreamableHTTPClientTransport implements Transport { if (response.status === 202) { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server - if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { + if (isInitializedNotification(message)) { // Start without a lastEventId since this is a fresh connection this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); } diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 34ab65d1..9be63f52 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult } from '../../types.js'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -145,14 +145,6 @@ app.get('/mcp', async (req: Request, res: Response) => { res.status(405).set('Allow', 'POST').send('Method Not Allowed'); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} - // Start the server const PORT = 3000; app.listen(PORT, () => { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 98333730..eb28bf4c 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult, GetPromptResult, JSONRPCMessage, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, JSONRPCMessage, ReadResourceResult } from '../../types.js'; // Create a simple in-memory EventStore for resumability class InMemoryEventStore implements EventStore { @@ -36,7 +36,7 @@ class InMemoryEventStore implements EventStore { * Replays events that occurred after a specific event ID * Implements EventStore.replayEventsAfter */ - async replayEventsAfter(lastEventId: string, + async replayEventsAfter(lastEventId: string, { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } ): Promise { if (!lastEventId || !this.events.has(lastEventId)) { @@ -312,14 +312,6 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} - // Start the server const PORT = 3000; app.listen(PORT, () => { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index f9d3696b..5f96ca82 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { ReadResourceResult } from '../../types.js'; +import { isInitializeRequest, ReadResourceResult } from '../../types.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -107,13 +107,6 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); - } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} // Start the server const PORT = 3000; diff --git a/src/types.ts b/src/types.ts index db6bf125..8ac41372 100644 --- a/src/types.ts +++ b/src/types.ts @@ -248,6 +248,10 @@ export const InitializeRequestSchema = RequestSchema.extend({ }), }); +export const isInitializeRequest = (value: unknown): value is InitializeRequest => + InitializeRequestSchema.safeParse(value).success; + + /** * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. */ @@ -337,6 +341,9 @@ export const InitializedNotificationSchema = NotificationSchema.extend({ method: z.literal("notifications/initialized"), }); +export const isInitializedNotification = (value: unknown): value is InitializedNotification => + InitializedNotificationSchema.safeParse(value).success; + /* Ping */ /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. From 585dc06708a80920634d2ae7ca76f067c6024874 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:09:47 +0100 Subject: [PATCH 33/42] introduce onsessioninitialized to avoid race conditions --- src/client/streamableHttp.ts | 2 +- .../server/jsonResponseStreamableHttp.ts | 13 ++++----- src/examples/server/simpleStreamableHttp.ts | 12 ++++---- .../standaloneSseWithGetStreamableHttp.ts | 12 ++++---- src/server/streamableHttp.test.ts | 29 ++++--------------- src/server/streamableHttp.ts | 23 ++++++++++++--- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 0510fe84..9efd8536 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { isInitializedNotification, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 9be63f52..101a581f 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -95,18 +95,17 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, // Enable JSON response mode + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server BEFORE handling the request await server.connect(transport); - - // After handling the request, if we get a session ID back, store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index eb28bf4c..a318b738 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -247,19 +247,19 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport await server.connect(transport); - // After handling the request, if we get a session ID back, store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 5f96ca82..8c8c3baa 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -52,17 +52,19 @@ app.post('/mcp', async (req: Request, res: Response) => { // New initialization request transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server await server.connect(transport); + // Handle the request - the onsessioninitialized callback will store the transport await transport.handleRequest(req, res, req.body); - - // Store the transport by session ID for future requests - if (transport.sessionId) { - transports[transport.sessionId] = transport; - } return; // Already handled } else { // Invalid request - no session ID or not initialization request diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index efd5de1c..0794e4bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -187,15 +187,11 @@ describe("StreamableHTTPServerTransport", () => { expect(sessionId).toBeDefined(); // Try second initialize - const secondInitMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client-2", version: "1.0" }, - protocolVersion: "2025-03-26", - }, - id: "init-2", + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: "second-init" }; + const response = await sendPostRequest(baseUrl, secondInitMessage); expect(response.status).toBe(400); @@ -1092,14 +1088,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); it("should handle POST requests with various session IDs in stateless mode", async () => { - // Initialize the server first - await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, - body: JSON.stringify({ - jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" - }), - }); + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); // Try with a random session ID - should be accepted const response1 = await fetch(baseUrl, { @@ -1131,13 +1120,7 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { // one standalone SSE stream at a time // Initialize the server first - await fetch(baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, - body: JSON.stringify({ - jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, id: "init-1" - }), - }); + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); // Open first SSE stream const stream1 = await fetch(baseUrl, { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 31aad09c..92bbed37 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -39,6 +39,15 @@ export interface StreamableHTTPServerTransportOptions { */ sessionIdGenerator: () => string | undefined; + /** + * A callback for session initialization events + * This is called when the server initializes a new session. + * Usefult in cases when you need to register multiple mcp sessions + * and need to keep track of them. + * @param sessionId The generated session ID + */ + onsessioninitialized?: (sessionId: string) => void; + /** * If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. @@ -98,6 +107,7 @@ export class StreamableHTTPServerTransport implements Transport { private _enableJsonResponse: boolean = false; private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; + private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string | undefined; onclose?: () => void; @@ -108,6 +118,7 @@ export class StreamableHTTPServerTransport implements Transport { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; + this._onsessioninitialized = options.onsessioninitialized; } /** @@ -328,9 +339,7 @@ export class StreamableHTTPServerTransport implements Transport { // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ - const isInitializationRequest = messages.some( - msg => 'method' in msg && msg.method === 'initialize' - ); + const isInitializationRequest = messages.some(isInitializeRequest); if (isInitializationRequest) { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. @@ -359,6 +368,12 @@ export class StreamableHTTPServerTransport implements Transport { this.sessionId = this.sessionIdGenerator(); this._initialized = true; + // If we have a session ID and an onsessioninitialized handler, call it immediately + // This is needed in cases where the server needs to keep track of multiple sessions + if (this.sessionId && this._onsessioninitialized) { + this._onsessioninitialized(this.sessionId); + } + } // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it From 8775c1fefc0d65688acb5dd81ada7d8bbb231c41 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:26:39 +0100 Subject: [PATCH 34/42] add integratoin test --- .../stateManagementStreamableHttp.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 src/integration-tests/stateManagementStreamableHttp.test.ts diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts new file mode 100644 index 00000000..1e80b7b8 --- /dev/null +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -0,0 +1,265 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { z } from 'zod'; + +describe('Streamable HTTP Transport Session Management', () => { + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { + logging: {}, + tools: {}, + resources: {}, + prompts: {} + } + } + ); + + // Add a simple resource + mcpServer.resource( + 'test-resource', + '/test', + { description: 'A test resource' }, + async () => ({ + contents: [{ + uri: '/test', + text: 'This is a test resource content' + }] + }) + ); + + mcpServer.prompt( + 'test-prompt', + 'A test prompt', + async () => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + }] + }) + ); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet').default('World'), + }, + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } + ); + + // Create transport with or without session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: withSessionManagement + ? () => randomUUID() // With session management, generate UUID + : () => undefined // Without session management, return undefined + }); + + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, mcpServer, serverTransport, baseUrl }; + } + + describe('Stateless Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(false); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should operate without session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that no session ID was set + expect(transport.sessionId).toBeUndefined(); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateless Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateless Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); + + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateful Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateful Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); +}); \ No newline at end of file From a5144dc022f8c5b121b6a1a35fcf37b6765b785d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:34:21 +0100 Subject: [PATCH 35/42] use utility to check for message types --- src/server/streamableHttp.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 92bbed37..82fcf321 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; import { randomUUID } from "node:crypto"; @@ -415,7 +415,7 @@ export class StreamableHTTPServerTransport implements Transport { // Store the response for this request to send messages back through this connection // We need to track by request ID to maintain the connection for (const message of messages) { - if ('method' in message && 'id' in message) { + if (isJSONRPCRequest(message)) { this._streamMapping.set(streamId, res); this._requestToStreamMapping.set(message.id, streamId); } @@ -535,7 +535,7 @@ export class StreamableHTTPServerTransport implements Transport { async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; - if ('result' in message || 'error' in message) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { // If the message is a response, use the request ID from the message requestId = message.id; } @@ -545,7 +545,7 @@ export class StreamableHTTPServerTransport implements Transport { // Those will be sent via dedicated response SSE streams if (requestId === undefined) { // For standalone SSE streams, we can only send requests and notifications - if ('result' in message || 'error' in message) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId) From c053c2e97dcb0a1cf8be0583bd17a8b3d6af35ec Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 11:56:28 +0100 Subject: [PATCH 36/42] handle session termination --- src/client/streamableHttp.test.ts | 75 +++++++++++++++++++++ src/client/streamableHttp.ts | 47 +++++++++++++ src/examples/client/simpleStreamableHttp.ts | 48 ++++++++++++- src/examples/server/simpleStreamableHttp.ts | 42 ++++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 93e44150..ac129e29 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -101,6 +101,81 @@ describe("StreamableHTTPClientTransport", () => { expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); }); + it("should terminate session with DELETE request", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + expect(transport.sessionId).toBe("test-session-id"); + + // Now terminate the session + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers() + }); + + await transport.terminateSession(); + + // Verify the DELETE request was sent with the session ID + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("DELETE"); + expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); + + // The session ID should be cleared after successful termination + expect(transport.sessionId).toBeUndefined(); + }); + + it("should handle 405 response when server doesn't support session termination", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + + // Now terminate the session, but server responds with 405 + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: "Method Not Allowed", + headers: new Headers() + }); + + // This should not throw an error + await transport.terminateSession(); + + // The session ID should still be preserved since termination wasn't accepted + expect(transport.sessionId).toBe("test-session-id"); + }); + it("should handle 404 response when session expires", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9efd8536..72559f35 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -467,4 +467,51 @@ export class StreamableHTTPClientTransport implements Transport { get sessionId(): string | undefined { return this._sessionId; } + + /** + * Terminates the current session by sending a DELETE request to the server. + * + * Clients that no longer need a particular session + * (e.g., because the user is leaving the client application) SHOULD send an + * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly + * terminate the session. + * + * The server MAY respond with HTTP 405 Method Not Allowed, indicating that + * the server does not allow clients to terminate sessions. + */ + async terminateSession(): Promise { + if (!this._sessionId) { + return; // No session to terminate + } + + try { + const headers = await this._commonHeaders(); + + const init = { + ...this._requestInit, + method: "DELETE", + headers, + signal: this._abortController?.signal, + }; + + const response = await fetch(this._url, init); + + // We specifically handle 405 as a valid response according to the spec, + // meaning the server does not support explicit session termination + if (!response.ok && response.status !== 405) { + throw new StreamableHTTPError( + response.status, + `Failed to terminate session: ${response.statusText}` + ); + } + + // If session termination was successful, clear our session ID + if (response.ok) { + this._sessionId = undefined; + } + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index d0d5408b..c1501a57 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -48,6 +48,7 @@ function printHelp(): void { console.log('\nAvailable commands:'); console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); console.log(' reconnect - Reconnect to the server'); console.log(' list-tools - List available tools'); console.log(' call-tool [args] - Call a tool with optional JSON arguments'); @@ -76,6 +77,10 @@ function commandLoop(): void { await disconnect(); break; + case 'terminate-session': + await terminateSession(); + break; + case 'reconnect': await reconnect(); break; @@ -249,6 +254,36 @@ async function disconnect(): Promise { } } +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (!transport.sessionId) { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + async function reconnect(): Promise { if (client) { await disconnect(); @@ -411,13 +446,24 @@ async function listResources(): Promise { async function cleanup(): Promise { if (client && transport) { try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport await transport.close(); } catch (error) { console.error('Error closing transport:', error); } } - process.stdin.setRawMode(false); readline.close(); console.log('\nGoodbye!'); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a318b738..0ae0f910 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -255,6 +255,15 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport await server.connect(transport); @@ -312,6 +321,27 @@ app.get('/mcp', async (req: Request, res: Response) => { await transport.handleRequest(req, res); }); +// Handle DELETE requests for session termination (according to MCP spec) +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + // Start the server const PORT = 3000; app.listen(PORT, () => { @@ -343,6 +373,18 @@ app.listen(PORT, () => { // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } await server.close(); + console.log('Server shutdown complete'); process.exit(0); }); \ No newline at end of file From 4d2968cec39f781c8d52c2a298c2031f91f89117 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 13:30:00 +0100 Subject: [PATCH 37/42] backward compatible server example --- .../sseAndStreamableHttpCompatibleServer.ts | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/examples/server/sseAndStreamableHttpCompatibleServer.ts diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts new file mode 100644 index 00000000..5a79a4e5 --- /dev/null +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -0,0 +1,316 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from "node:crypto"; +import { McpServer } from '../../server/mcp.js'; +import { EventStore, StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types.js'; + +/** + * This example server demonstrates backwards compatibility with both: + * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) + * 2. The Streamable HTTP transport (protocol version 2025-03-26) + * + * It maintains a single MCP server instance but exposes two transport options: + * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) + * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) + * - /request: The deprecated POST endpoint for older clients (POST to send messages) + */ + +// Simple in-memory event store for resumability +class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + console.log(`Storing event ${eventId} for stream ${streamId}`); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + console.log(`No events found for lastEventId: ${lastEventId}`); + return ''; + } + + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + console.log(`Could not extract streamId from lastEventId: ${lastEventId}`); + return ''; + } + + let foundLastEvent = false; + let eventCount = 0; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + eventCount++; + } + } + + console.log(`Replayed ${eventCount} events after ${lastEventId} for stream ${streamId}`); + return streamId; + } +} + +// Create a shared MCP server instance +const server = new McpServer({ + name: 'backwards-compatible-server', + version: '1.0.0', +}, { capabilities: { logging: {} } }); + +// Register a simple tool that sends notifications over time +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } +); + +// Create Express application +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +//============================================================================= +// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) +//============================================================================= + +// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint +app.all('/mcp', async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Check if the transport is of the correct type + const existingTransport = transports[sessionId]; + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with the transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +//============================================================================= +// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) +//============================================================================= + +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (deprecated SSE transport)'); + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on("close", () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +app.post("/messages", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + let transport: SSEServerTransport; + const existingTransport = transports[sessionId]; + if (existingTransport instanceof SSEServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + if (transport) { + await transport.handlePostMessage(req, res); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Backwards compatible MCP server listening on port ${PORT}`); + console.log(` +============================================== +SUPPORTED TRANSPORT OPTIONS: + +1. Streamable Http(Protocol version: 2025-03-26) + Endpoint: /mcp + Methods: GET, POST, DELETE + Usage: + - Initialize with POST to /mcp + - Establish SSE stream with GET to /mcp + - Send requests with POST to /mcp + - Terminate session with DELETE to /mcp + +2. Http + SSE (Protocol version: 2024-11-05) + Endpoints: /sse (GET) and /request (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /message?sessionId= +============================================== +`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + await server.close(); + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file From 8328cfe3270800db8fc326ce60e194a8674f4c50 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 13:36:37 +0100 Subject: [PATCH 38/42] example of sse server --- src/examples/server/simpleSseServer.ts | 170 +++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/examples/server/simpleSseServer.ts diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts new file mode 100644 index 00000000..7b040955 --- /dev/null +++ b/src/examples/server/simpleSseServer.ts @@ -0,0 +1,170 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from "node:crypto"; +import { McpServer } from '../../server/mcp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult } from '../../types.js'; + +/** + * This example server demonstrates the deprecated HTTP+SSE transport + * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. + * + * The server exposes two endpoints: + * - /sse: For establishing the SSE stream (GET) + * - /messages: For receiving client messages (POST) + * + */ + +// Create an MCP server instance +const server = new McpServer({ + name: 'simple-sse-server', + version: '1.0.0', +}, { capabilities: { logging: {} } }); + +server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + // Send the initial notification + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Starting notification stream with ${count} messages every ${interval}ms` + } + }); + + // Send periodic notifications + while (counter < count) { + counter++; + await sleep(interval); + + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + } + + return { + content: [ + { + type: 'text', + text: `Completed sending ${count} notifications every ${interval}ms`, + } + ], + }; + } +); + +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +// SSE endpoint for establishing the stream +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (establishing SSE stream)'); + + try { + // Create a new SSE transport for the client + // The endpoint for POST messages is '/messages' + const transport = new SSEServerTransport('/messages', res); + + // Store the transport by session ID + const sessionId = transport.sessionId; + transports[sessionId] = transport; + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + console.log(`SSE transport closed for session ${sessionId}`); + delete transports[sessionId]; + }; + + // Connect the transport to the MCP server + await server.connect(transport); + + // Start the SSE transport to begin streaming + // This sends an initial 'endpoint' event with the session ID in the URL + await transport.start(); + + console.log(`Established SSE stream with session ID: ${sessionId}`); + } catch (error) { + console.error('Error establishing SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Messages endpoint for receiving client JSON-RPC requests +app.post('/messages', async (req: Request, res: Response) => { + console.log('Received POST request to /messages'); + + // Extract session ID from URL query parameter + // In the SSE protocol, this is added by the client based on the endpoint event + const sessionId = req.query.sessionId as string | undefined; + + if (!sessionId) { + console.error('No session ID provided in request URL'); + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = transports[sessionId]; + if (!transport) { + console.error(`No active transport found for session ID: ${sessionId}`); + res.status(404).send('Session not found'); + return; + } + + try { + // Handle the POST message with the transport + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + await server.close(); + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file From 80b0bab08b436b34cd3c0467236d62ea901ce283 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:20:55 +0100 Subject: [PATCH 39/42] backwards compatible client --- .../streamableHttpWithSseFallbackClient.ts | 190 ++++++++++++++++++ src/examples/server/simpleSseServer.ts | 2 +- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/examples/client/streamableHttpWithSseFallbackClient.ts diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 00000000..c3e5e345 --- /dev/null +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,190 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { SSEClientTransport } from '../../client/sse.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, +} from '../../types.js'; + +/** + * Simplified Backwards Compatible MCP Client + * + * This client demonstrates backward compatibility with both: + * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) + * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) + * + * Following the MCP specification for backwards compatibility: + * - Attempts to POST an initialize request to the server URL first (modern transport) + * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Backwards Compatible Client'); + console.log('==============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport | SSEClientTransport; + let transportType: 'streamable-http' | 'sse'; + + try { + // Try connecting with automatic transport detection + const connection = await connectWithBackwardsCompatibility(serverUrl); + client = connection.client; + transport = connection.transport; + transportType = connection.transportType; + + // Set up notification handler + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); + }); + + // DEMO WORKFLOW: + // 1. List available tools + console.log('\n=== Listing Available Tools ==='); + await listTools(client); + + // 2. Call the notification tool + console.log('\n=== Starting Notification Stream ==='); + await startNotificationTool(client); + + // 3. Wait for all notifications (5 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + + } catch (error) { + console.error('Error running client:', error); + process.exit(1); + } +} + +/** + * Connect to an MCP server with backwards compatibility + * Following the spec for client backward compatibility + */ +async function connectWithBackwardsCompatibility(url: string): Promise<{ + client: Client, + transport: StreamableHTTPClientTransport | SSEClientTransport, + transportType: 'streamable-http' | 'sse' +}> { + console.log('1. Trying Streamable HTTP transport first...'); + + // Step 1: Try Streamable HTTP transport first + const client = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + + client.onerror = (error) => { + console.error('Client error:', error); + }; + const baseUrl = new URL(url); + + try { + // Create modern transport + const streamableTransport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(streamableTransport); + + console.log('Successfully connected using modern Streamable HTTP transport.'); + return { + client, + transport: streamableTransport, + transportType: 'streamable-http' + }; + } catch (error) { + // Step 2: If transport fails, try the older SSE transport + console.log(`StreamableHttp transport connection failed: ${error}`); + console.log('2. Falling back to deprecated HTTP+SSE transport...'); + + try { + // Create SSE transport pointing to /sse endpoint + const sseTransport = new SSEClientTransport(baseUrl); + await client.connect(sseTransport); + + console.log('Successfully connected using deprecated HTTP+SSE transport.'); + return { + client, + transport: sseTransport, + transportType: 'sse' + }; + } catch (sseError) { + console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); + throw new Error('Could not connect to server with any available transport'); + } + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start a notification stream by calling the notification tool + */ +async function startNotificationTool(client: Client): Promise { + try { + // Call the notification tool using reasonable defaults + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1000, // 1 second between notifications + count: 5 // Send 5 notifications + } + } + }; + + console.log('Calling notification tool...'); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error) { + console.log(`Error calling notification tool: ${error}`); + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index 7b040955..cb2407d3 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -78,7 +78,7 @@ app.use(express.json()); const transports: Record = {}; // SSE endpoint for establishing the stream -app.get('/sse', async (req: Request, res: Response) => { +app.get('/mcp', async (req: Request, res: Response) => { console.log('Received GET request to /sse (establishing SSE stream)'); try { From 7341c9a288658d21209747363b5750b8cabda010 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 16 Apr 2025 14:44:45 +0100 Subject: [PATCH 40/42] fix lint --- src/examples/client/streamableHttpWithSseFallbackClient.ts | 2 -- src/examples/server/simpleSseServer.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index c3e5e345..86017403 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -32,14 +32,12 @@ async function main(): Promise { let client: Client; let transport: StreamableHTTPClientTransport | SSEClientTransport; - let transportType: 'streamable-http' | 'sse'; try { // Try connecting with automatic transport detection const connection = await connectWithBackwardsCompatibility(serverUrl); client = connection.client; transport = connection.transport; - transportType = connection.transportType; // Set up notification handler client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index cb2407d3..4c5aa446 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -1,5 +1,4 @@ import express, { Request, Response } from 'express'; -import { randomUUID } from "node:crypto"; import { McpServer } from '../../server/mcp.js'; import { SSEServerTransport } from '../../server/sse.js'; import { z } from 'zod'; From 5dba9477fff2781b3464efec4787b52a4eda39cd Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 17:34:15 +0100 Subject: [PATCH 41/42] small fixes after suggested changes --- src/client/streamableHttp.test.ts | 6 +----- src/client/streamableHttp.ts | 5 +---- src/server/streamableHttp.ts | 2 +- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index ac129e29..9e8efa52 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -169,11 +169,7 @@ describe("StreamableHTTPClientTransport", () => { headers: new Headers() }); - // This should not throw an error - await transport.terminateSession(); - - // The session ID should still be preserved since termination wasn't accepted - expect(transport.sessionId).toBe("test-session-id"); + await expect(transport.terminateSession()).resolves.not.toThrow(); }); it("should handle 404 response when session expires", async () => { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 72559f35..3462b2ab 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -505,10 +505,7 @@ export class StreamableHTTPClientTransport implements Transport { ); } - // If session termination was successful, clear our session ID - if (response.ok) { - this._sessionId = undefined; - } + this._sessionId = undefined; } catch (error) { this.onerror?.(error as Error); throw error; diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 82fcf321..ed52eb77 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -42,7 +42,7 @@ export interface StreamableHTTPServerTransportOptions { /** * A callback for session initialization events * This is called when the server initializes a new session. - * Usefult in cases when you need to register multiple mcp sessions + * Useful in cases when you need to register multiple mcp sessions * and need to keep track of them. * @param sessionId The generated session ID */ From 29ae6b8e49c9549198c84f8f6fe2aaa30f5106ff Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 17 Apr 2025 17:43:14 +0100 Subject: [PATCH 42/42] appying suggested changes --- .../client/streamableHttpWithSseFallbackClient.ts | 8 ++++++-- .../server/sseAndStreamableHttpCompatibleServer.ts | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts index 86017403..7646f0f7 100644 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -109,11 +109,15 @@ async function connectWithBackwardsCompatibility(url: string): Promise<{ try { // Create SSE transport pointing to /sse endpoint const sseTransport = new SSEClientTransport(baseUrl); - await client.connect(sseTransport); + const sseClient = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + await sseClient.connect(sseTransport); console.log('Successfully connected using deprecated HTTP+SSE transport.'); return { - client, + client: sseClient, transport: sseTransport, transportType: 'sse' }; diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 5a79a4e5..e53fcf5f 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -14,7 +14,7 @@ import { CallToolResult, isInitializeRequest, JSONRPCMessage } from '../../types * It maintains a single MCP server instance but exposes two transport options: * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) - * - /request: The deprecated POST endpoint for older clients (POST to send messages) + * - /messages: The deprecated POST endpoint for older clients (POST to send messages) */ // Simple in-memory event store for resumability @@ -288,10 +288,10 @@ SUPPORTED TRANSPORT OPTIONS: - Terminate session with DELETE to /mcp 2. Http + SSE (Protocol version: 2024-11-05) - Endpoints: /sse (GET) and /request (POST) + Endpoints: /sse (GET) and /messages (POST) Usage: - Establish SSE stream with GET to /sse - - Send requests with POST to /message?sessionId= + - Send requests with POST to /messages?sessionId= ============================================== `); });