From dad9eafe3e6aa356dcf2579386807fad85f99770 Mon Sep 17 00:00:00 2001 From: akazwz <50396286+akazwz@users.noreply.github.com> Date: Sun, 9 Mar 2025 10:01:07 +0800 Subject: [PATCH 01/60] Improve SSE endpoint sessionId parameter handling Add dynamic query parameter separator for sessionId to handle endpoints with existing query parameters more robustly --- src/server/sse.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 84c1cbb9..8aa01928 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -49,8 +49,17 @@ export class SSEServerTransport implements Transport { }); // Send the endpoint event + /** + * Determine the appropriate separator for adding the sessionId parameter. + * Uses '&' if the endpoint already contains a query parameter (has a '?'), + * otherwise uses '?' to start the query string. + * + * Note: This approach works for standard endpoints but doesn't handle + * the edge case where '?' might be part of the path itself. + */ + const separator = this._endpoint.includes('?') ? '&' : '?'; this.res.write( - `event: endpoint\ndata: ${encodeURI(this._endpoint)}?sessionId=${this._sessionId}\n\n`, + `event: endpoint\ndata: ${encodeURI(this._endpoint)}${separator}sessionId=${this._sessionId}\n\n`, ); this._sseResponse = this.res; From cc9ae5b4ac288f2d1586e07924f7a8b8da17ee2d Mon Sep 17 00:00:00 2001 From: Geng Yan Date: Fri, 28 Mar 2025 09:00:07 +0800 Subject: [PATCH 02/60] =?UTF-8?q?=E2=9C=A8=20feat:=20StreamableHTTPServerT?= =?UTF-8?q?ransport=20implement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/streamable-http.test.ts | 427 +++++++++++++++++++++++++++++ src/server/streamable-http.ts | 388 ++++++++++++++++++++++++++ 2 files changed, 815 insertions(+) create mode 100644 src/server/streamable-http.test.ts create mode 100644 src/server/streamable-http.ts diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts new file mode 100644 index 00000000..8dd5dea7 --- /dev/null +++ b/src/server/streamable-http.test.ts @@ -0,0 +1,427 @@ +import { IncomingMessage, ServerResponse } from "node:http"; +import { StreamableHTTPServerTransport } from "./streamable-http.js"; +import { JSONRPCMessage } from "../types.js"; +import { Readable } from "node:stream"; + +// 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); + } + + return Object.assign(readable, { + method: options.method, + headers: options.headers, + }) as IncomingMessage; +} + +// 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(), + } as unknown as jest.Mocked; + return response; +} + +describe("StreamableHTTPServerTransport", () => { + const endpoint = "/mcp"; + let transport: StreamableHTTPServerTransport; + let mockResponse: jest.Mocked; + + beforeEach(() => { + transport = new StreamableHTTPServerTransport(endpoint); + mockResponse = createMockResponse(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("Session Management", () => { + it("should generate a valid session ID", () => { + expect(transport.sessionId).toBeTruthy(); + expect(typeof transport.sessionId).toBe("string"); + }); + + it("should include session ID in response headers", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream" + }, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Mcp-Session-Id": transport.sessionId, + }) + ); + }); + + it("should reject invalid session ID", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + "mcp-session-id": "invalid-session-id", + }, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(404); + // check if the error response is a valid JSON-RPC error format + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"')); + }); + }); + + describe("Request Handling", () => { + it("should reject GET requests without Accept: text/event-stream header", async () => { + const req = createMockRequest({ + method: "GET", + headers: {}, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(406); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); + }); + + it("should properly handle GET requests with Accept header and establish SSE connection", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + }, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }) + ); + }); + + 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", + }, + 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": "text/event-stream", + }, + 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", + }, + 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 messages properly", 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", + }, + 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 reject unsupported Content-Type", async () => { + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "text/plain", + "accept": "application/json", + }, + 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 () => { + const req = createMockRequest({ + method: "DELETE", + headers: {}, + }); + + const onCloseMock = jest.fn(); + transport.onclose = onCloseMock; + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(200); + expect(onCloseMock).toHaveBeenCalled(); + }); + }); + + describe("Message Replay", () => { + it("should replay messages after specified Last-Event-ID", async () => { + // Establish first connection with Accept header + const req1 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream" + }, + }); + await transport.handleRequest(req1, mockResponse); + + // Send a message to first connection + const message1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test1", + params: {}, + id: 1 + }; + + await transport.send(message1); + + // Get message ID (captured from write call) + const writeCall = mockResponse.write.mock.calls[0][0] as string; + const idMatch = writeCall.match(/id: ([a-f0-9-]+)/); + if (!idMatch) { + throw new Error("Message ID not found in write call"); + } + const lastEventId = idMatch[1]; + + // Create a second connection with last-event-id + const mockResponse2 = createMockResponse(); + const req2 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "last-event-id": lastEventId, + }, + }); + + await transport.handleRequest(req2, mockResponse2); + + // Send a second message + const message2: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test2", + params: {}, + id: 2 + }; + + await transport.send(message2); + + // Verify the second message was received by both connections + expect(mockResponse.write).toHaveBeenCalledWith( + expect.stringContaining(JSON.stringify(message1)) + ); + expect(mockResponse2.write).toHaveBeenCalledWith( + expect.stringContaining(JSON.stringify(message2)) + ); + }); + }); + + describe("Message Targeting", () => { + it("should send response messages to the connection that sent the request", async () => { + // Create two connections + const mockResponse1 = createMockResponse(); + const req1 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + }, + }); + await transport.handleRequest(req1, mockResponse1); + + const mockResponse2 = createMockResponse(); + const req2 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + }, + }); + await transport.handleRequest(req2, mockResponse2); + + // Send a request through the first connection + const requestMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id", + }; + + const reqPost = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "text/event-stream", + }, + body: JSON.stringify(requestMessage), + }); + + await transport.handleRequest(reqPost, mockResponse1); + + // Send a response with matching ID + const responseMessage: JSONRPCMessage = { + jsonrpc: "2.0", + result: { success: true }, + id: "test-id", + }; + + await transport.send(responseMessage); + + // Verify response was sent to the right connection + expect(mockResponse1.write).toHaveBeenCalledWith( + expect.stringContaining(JSON.stringify(responseMessage)) + ); + + // Check if write was called with this exact message on the second connection + const writeCallsOnSecondConn = mockResponse2.write.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage)) + ); + + // Verify the response wasn't broadcast to all connections + expect(writeCallsOnSecondConn.length).toBe(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid JSON data", async () => { + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + }, + body: "invalid json", + }); + + 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(); + }); + + it("should handle invalid JSON-RPC messages", async () => { + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + }, + body: JSON.stringify({ invalid: "message" }), + }); + + 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(onErrorMock).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts new file mode 100644 index 00000000..2567240c --- /dev/null +++ b/src/server/streamable-http.ts @@ -0,0 +1,388 @@ +import { randomUUID } from "node:crypto"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { Transport } from "../shared/transport.js"; +import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import getRawBody from "raw-body"; +import contentType from "content-type"; + +const MAXIMUM_MESSAGE_SIZE = "4mb"; + +interface StreamConnection { + response: ServerResponse; + lastEventId?: string; + messages: Array<{ + id: string; + message: JSONRPCMessage; + }>; + // mark this connection as a response to a specific request + requestId?: string | null; +} + +/** + * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It supports both SSE streaming and direct HTTP responses, with session management and message resumability. + */ +export class StreamableHTTPServerTransport implements Transport { + private _connections: Map = new Map(); + private _sessionId: string; + private _messageHistory: Map = new Map(); + private _started: boolean = false; + private _requestConnections: Map = new Map(); // request ID to connection ID mapping + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(private _endpoint: string) { + this._sessionId = randomUUID(); + } + + /** + * Starts the transport. This is required by the Transport interface but is a no-op + * for the Streamable HTTP transport as connections are managed per-request. + */ + async start(): Promise { + if (this._started) { + throw new Error("Transport already started"); + } + this._started = true; + } + + /** + * Handles an incoming HTTP request, whether GET or POST + */ + async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + // validate the session ID + const sessionId = req.headers["mcp-session-id"]; + if (sessionId && (Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { + res.writeHead(404).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found" + }, + id: null + })); + return; + } + + if (req.method === "GET") { + await this.handleGetRequest(req, res); + } else if (req.method === "POST") { + await this.handlePostRequest(req, res); + } else if (req.method === "DELETE") { + await this.handleDeleteRequest(req, res); + } else { + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed" + }, + id: null + })); + } + } + + /** + * Handles GET requests to establish SSE connections + */ + private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { + // validate the Accept header + const acceptHeader = req.headers.accept; + if (!acceptHeader || !acceptHeader.includes("text/event-stream")) { + res.writeHead(406).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Not Acceptable: Client must accept text/event-stream" + }, + id: null + })); + return; + } + + const connectionId = randomUUID(); + const lastEventId = req.headers["last-event-id"]; + const lastEventIdStr = Array.isArray(lastEventId) ? lastEventId[0] : lastEventId; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Mcp-Session-Id": this._sessionId, + }); + + const connection: StreamConnection = { + response: res, + lastEventId: lastEventIdStr, + messages: [], + }; + + this._connections.set(connectionId, connection); + + // if there is a Last-Event-ID, replay messages on this connection + if (lastEventIdStr) { + this.replayMessages(connectionId, lastEventIdStr); + } + + res.on("close", () => { + this._connections.delete(connectionId); + // remove all request mappings associated with this connection + for (const [reqId, connId] of this._requestConnections.entries()) { + if (connId === connectionId) { + this._requestConnections.delete(reqId); + } + } + if (this._connections.size === 0) { + this.onclose?.(); + } + }); + } + + /** + * Handles POST requests containing JSON-RPC messages + */ + private async handlePostRequest(req: IncomingMessage, res: ServerResponse): Promise { + try { + // validate the Accept header + const acceptHeader = req.headers.accept; + if (!acceptHeader || + (!acceptHeader.includes("application/json") && !acceptHeader.includes("text/event-stream"))) { + res.writeHead(406).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Not Acceptable: Client must accept application/json and/or text/event-stream" + }, + id: null + })); + return; + } + + const ct = req.headers["content-type"]; + if (!ct || !ct.includes("application/json")) { + res.writeHead(415).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Unsupported Media Type: Content-Type must be application/json" + }, + id: null + })); + return; + } + + const parsedCt = contentType.parse(ct); + const body = await getRawBody(req, { + limit: MAXIMUM_MESSAGE_SIZE, + encoding: parsedCt.parameters.charset ?? "utf-8", + }); + + const rawMessage = JSON.parse(body.toString()); + let messages: JSONRPCMessage[]; + + // handle batch and single messages + if (Array.isArray(rawMessage)) { + messages = rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)); + } else { + messages = [JSONRPCMessageSchema.parse(rawMessage)]; + } + + // check if it contains requests + const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); + const hasOnlyNotificationsOrResponses = messages.every(msg => + ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); + + if (hasOnlyNotificationsOrResponses) { + // if it only contains notifications or responses, return 202 + res.writeHead(202).end(); + + // handle each message + for (const message of messages) { + this.onmessage?.(message); + } + } else if (hasRequests) { + // if it contains requests, you can choose to return an SSE stream or a JSON response + const useSSE = acceptHeader.includes("text/event-stream"); + + if (useSSE) { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Mcp-Session-Id": this._sessionId, + }); + + const connectionId = randomUUID(); + const connection: StreamConnection = { + response: res, + messages: [], + }; + + this._connections.set(connectionId, connection); + + // map each request to a connection ID + for (const message of messages) { + if ('method' in message && 'id' in message) { + this._requestConnections.set(String(message.id), connectionId); + } + this.onmessage?.(message); + } + + res.on("close", () => { + this._connections.delete(connectionId); + // remove all request mappings associated with this connection + for (const [reqId, connId] of this._requestConnections.entries()) { + if (connId === connectionId) { + this._requestConnections.delete(reqId); + } + } + if (this._connections.size === 0) { + this.onclose?.(); + } + }); + } else { + // use direct JSON response + res.writeHead(200, { + "Content-Type": "application/json", + "Mcp-Session-Id": this._sessionId, + }); + + // handle each message + for (const message of messages) { + this.onmessage?.(message); + } + + res.end(); + } + } + } catch (error) { + // return JSON-RPC formatted error + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error", + data: String(error) + }, + id: null + })); + this.onerror?.(error as Error); + } + } + + /** + * Handles DELETE requests to terminate sessions + */ + private async handleDeleteRequest(req: IncomingMessage, res: ServerResponse): Promise { + await this.close(); + res.writeHead(200).end(); + } + + /** + * Replays messages after the specified event ID for a specific connection + */ + private replayMessages(connectionId: string, lastEventId: string): void { + if (!lastEventId) return; + + // only replay messages that should be sent on this connection + const messages = Array.from(this._messageHistory.entries()) + .filter(([id, { connectionId: msgConnId }]) => + id > lastEventId && + (!msgConnId || msgConnId === connectionId)) // only replay messages that are not specified to a connection or specified to the current connection + .sort(([a], [b]) => a.localeCompare(b)); + + const connection = this._connections.get(connectionId); + if (!connection) return; + + for (const [id, { message }] of messages) { + connection.response.write( + `id: ${id}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` + ); + } + } + + async close(): Promise { + for (const connection of this._connections.values()) { + connection.response.end(); + } + this._connections.clear(); + this._messageHistory.clear(); + this._requestConnections.clear(); + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + if (this._connections.size === 0) { + throw new Error("No active connections"); + } + + let targetConnectionId = ""; + + // if it is a response, find the corresponding request connection + if ('id' in message && ('result' in message || 'error' in message)) { + const connId = this._requestConnections.get(String(message.id)); + + // if the corresponding connection is not found, the connection may be disconnected + if (!connId || !this._connections.has(connId)) { + // select an available connection + const firstConnId = this._connections.keys().next().value; + if (firstConnId) { + targetConnectionId = firstConnId; + } else { + throw new Error("No available connections"); + } + } else { + targetConnectionId = connId; + } + } else { + // for other messages, select an available connection + const firstConnId = this._connections.keys().next().value; + if (firstConnId) { + targetConnectionId = firstConnId; + } else { + throw new Error("No available connections"); + } + } + + const messageId = randomUUID(); + this._messageHistory.set(messageId, { + message, + connectionId: targetConnectionId + }); + + // keep the message history in a reasonable range + if (this._messageHistory.size > 1000) { + const oldestKey = Array.from(this._messageHistory.keys())[0]; + this._messageHistory.delete(oldestKey); + } + + // send the message to all active connections + for (const [connId, connection] of this._connections.entries()) { + // if it is a response message, only send to the target connection + if ('id' in message && ('result' in message || 'error' in message)) { + if (connId === targetConnectionId) { + connection.response.write( + `id: ${messageId}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` + ); + } + } else { + // for other messages, send to all connections + connection.response.write( + `id: ${messageId}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` + ); + } + } + } + + /** + * Returns the session ID for this transport + */ + get sessionId(): string { + return this._sessionId; + } +} \ No newline at end of file From 970cf9d5b168717459733d43b43e889121e501e6 Mon Sep 17 00:00:00 2001 From: Geng Yan Date: Tue, 1 Apr 2025 10:58:37 +0800 Subject: [PATCH 03/60] fix: Use lowercase header names --- src/server/streamable-http.test.ts | 2 +- src/server/streamable-http.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts index 8dd5dea7..fa5140c0 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamable-http.test.ts @@ -69,7 +69,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "Mcp-Session-Id": transport.sessionId, + "mcp-session-id": transport.sessionId, }) ); }); diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts index 2567240c..bf0096d1 100644 --- a/src/server/streamable-http.ts +++ b/src/server/streamable-http.ts @@ -113,7 +113,7 @@ export class StreamableHTTPServerTransport implements Transport { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Mcp-Session-Id": this._sessionId, + "mcp-session-id": this._sessionId, }); const connection: StreamConnection = { @@ -214,7 +214,7 @@ export class StreamableHTTPServerTransport implements Transport { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Mcp-Session-Id": this._sessionId, + "mcp-session-id": this._sessionId, }); const connectionId = randomUUID(); @@ -249,7 +249,7 @@ export class StreamableHTTPServerTransport implements Transport { // use direct JSON response res.writeHead(200, { "Content-Type": "application/json", - "Mcp-Session-Id": this._sessionId, + "mcp-session-id": this._sessionId, }); // handle each message From 4c7c434e7c2c8ae9d97334640f48b18c5930a9c0 Mon Sep 17 00:00:00 2001 From: Geng Yan Date: Wed, 2 Apr 2025 08:47:35 +0800 Subject: [PATCH 04/60] =?UTF-8?q?=E2=9C=A8=20feat:=20try=20add=20stateless?= =?UTF-8?q?=20mod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/streamable-http.test.ts | 272 +++++++++++++++++++++++++++-- src/server/streamable-http.ts | 131 +++++++++++--- 2 files changed, 370 insertions(+), 33 deletions(-) diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts index fa5140c0..960d3800 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamable-http.test.ts @@ -57,11 +57,24 @@ describe("StreamableHTTPServerTransport", () => { }); it("should include session ID in response headers", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - accept: "text/event-stream" + // Use POST with initialize method to avoid session ID requirement + const initializeMessage: JSONRPCMessage = { + 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", + }, + body: JSON.stringify(initializeMessage), }); await transport.handleRequest(req, mockResponse); @@ -79,6 +92,7 @@ describe("StreamableHTTPServerTransport", () => { method: "GET", headers: { "mcp-session-id": "invalid-session-id", + "accept": "text/event-stream" }, }); @@ -89,13 +103,241 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"')); }); + + it("should reject non-initialization requests without session ID with 400 Bad Request", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + // No mcp-session-id header + }, + }); + + 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 always include session ID in initialization response even in stateless mode", async () => { + // Create a stateless transport for this test + const statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false }); + + // Create an initialization request + const initializeMessage: JSONRPCMessage = { + 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", + }, + body: JSON.stringify(initializeMessage), + }); + + await statelessTransport.handleRequest(req, mockResponse); + + // In stateless mode, session ID should also be included for initialize responses + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "mcp-session-id": statelessTransport.sessionId, + }) + ); + }); + }); + + describe("Stateless Mode", () => { + let statelessTransport: StreamableHTTPServerTransport; + let mockResponse: jest.Mocked; + + beforeEach(() => { + statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false }); + mockResponse = createMockResponse(); + }); + + it("should not include session ID in response headers when in stateless mode", 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", + }, + body: JSON.stringify(message), + }); + + await statelessTransport.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 stateless mode", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": "invalid-session-id", // This would cause a 404 in stateful mode + }, + }); + + await statelessTransport.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 stateless mode", 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", + "mcp-session-id": "non-existent-session-id", // This would be rejected in stateful mode + }, + body: JSON.stringify(message), + }); + + const onMessageMock = jest.fn(); + statelessTransport.onmessage = onMessageMock; + + await statelessTransport.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 stateless mode", async () => { + // First request without session ID + const req1 = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + }, + }); + + await statelessTransport.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 stateful mode) + const req2 = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": "some-random-session-id", + }, + }); + + await statelessTransport.handleRequest(req2, mockResponse); + + // Should still succeed + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "text/event-stream", + }) + ); + }); + + it("should handle initialization requests properly in both modes", async () => { + // Initialize message that would typically be sent during initialization + const initializeMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-1", + }; + + // Test stateful transport (default) + const statefulReq = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + }, + body: JSON.stringify(initializeMessage), + }); + + await transport.handleRequest(statefulReq, mockResponse); + + // In stateful mode, session ID should be included in the response header + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "mcp-session-id": transport.sessionId, + }) + ); + + // Reset mocks for stateless test + mockResponse.writeHead.mockClear(); + + // Test stateless transport + const statelessReq = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + }, + body: JSON.stringify(initializeMessage), + }); + + await statelessTransport.handleRequest(statelessReq, mockResponse); + + // In stateless mode, session ID should also be included for initialize responses + const headers = mockResponse.writeHead.mock.calls[0][1]; + expect(headers).toHaveProperty("mcp-session-id", statelessTransport.sessionId); + }); }); describe("Request Handling", () => { it("should reject GET requests without Accept: text/event-stream header", async () => { const req = createMockRequest({ method: "GET", - headers: {}, + headers: { + "mcp-session-id": transport.sessionId, + }, }); await transport.handleRequest(req, mockResponse); @@ -108,7 +350,8 @@ describe("StreamableHTTPServerTransport", () => { const req = createMockRequest({ method: "GET", headers: { - accept: "text/event-stream", + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, }, }); @@ -127,7 +370,7 @@ describe("StreamableHTTPServerTransport", () => { it("should reject POST requests without proper Accept header", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", + method: "initialize", // Use initialize to bypass session ID check params: {}, id: 1, }; @@ -148,7 +391,7 @@ describe("StreamableHTTPServerTransport", () => { it("should properly handle JSON-RPC request messages in POST requests", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", + method: "initialize", // Use initialize to bypass session ID check params: {}, id: 1, }; @@ -188,6 +431,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "application/json", "accept": "application/json, text/event-stream", + "mcp-session-id": transport.sessionId, }, body: JSON.stringify(notification), }); @@ -212,6 +456,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "application/json", "accept": "application/json", + "mcp-session-id": transport.sessionId, }, body: JSON.stringify(batchMessages), }); @@ -231,6 +476,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "text/plain", "accept": "application/json", + "mcp-session-id": transport.sessionId, }, body: "test", }); @@ -244,7 +490,9 @@ describe("StreamableHTTPServerTransport", () => { it("should properly handle DELETE requests and close session", async () => { const req = createMockRequest({ method: "DELETE", - headers: {}, + headers: { + "mcp-session-id": transport.sessionId, + }, }); const onCloseMock = jest.fn(); @@ -259,11 +507,12 @@ describe("StreamableHTTPServerTransport", () => { describe("Message Replay", () => { it("should replay messages after specified Last-Event-ID", async () => { - // Establish first connection with Accept header + // Establish first connection with Accept header and session ID const req1 = createMockRequest({ method: "GET", headers: { - "accept": "text/event-stream" + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId }, }); await transport.handleRequest(req1, mockResponse); @@ -293,6 +542,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "accept": "text/event-stream", "last-event-id": lastEventId, + "mcp-session-id": transport.sessionId }, }); diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts index bf0096d1..73eae32b 100644 --- a/src/server/streamable-http.ts +++ b/src/server/streamable-http.ts @@ -18,9 +18,44 @@ interface StreamConnection { requestId?: string | null; } +/** + * Configuration options for StreamableHTTPServerTransport + */ +export interface StreamableHTTPServerTransportOptions { + /** + * Whether to enable session management through mcp-session-id headers + * When set to false, the transport operates in stateless mode without session validation + * @default true + */ + enableSessionManagement?: boolean; +} + /** * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It supports both SSE streaming and direct HTTP responses, with session management and message resumability. + * + * Usage example: + * + * ```typescript + * // Stateful mode (default) - with session management + * const statefulTransport = new StreamableHTTPServerTransport("/mcp"); + * + * // Stateless mode - without session management + * const statelessTransport = new StreamableHTTPServerTransport("/mcp", { + * enableSessionManagement: false + * }); + * ``` + * + * In stateful mode: + * - Session ID is generated and included in response headers + * - Session ID is always included in initialization responses + * - Requests with invalid session IDs are rejected with 404 Not Found + * - Non-initialization requests without a session ID are rejected with 400 Bad Request + * - State is maintained in-memory (connections, message history) + * + * In stateless mode: + * - Session ID is only included in initialization responses + * - No session validation is performed */ export class StreamableHTTPServerTransport implements Transport { private _connections: Map = new Map(); @@ -31,13 +66,15 @@ export class StreamableHTTPServerTransport implements Transport { }> = new Map(); private _started: boolean = false; private _requestConnections: Map = new Map(); // request ID to connection ID mapping + private _enableSessionManagement: boolean; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; - constructor(private _endpoint: string) { + constructor(private _endpoint: string, options?: StreamableHTTPServerTransportOptions) { this._sessionId = randomUUID(); + this._enableSessionManagement = options?.enableSessionManagement !== false; } /** @@ -55,18 +92,41 @@ export class StreamableHTTPServerTransport implements Transport { * Handles an incoming HTTP request, whether GET or POST */ async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - // validate the session ID - const sessionId = req.headers["mcp-session-id"]; - if (sessionId && (Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { - res.writeHead(404).end(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32001, - message: "Session not found" - }, - id: null - })); - return; + // Only validate session ID for non-initialization requests when session management is enabled + if (this._enableSessionManagement) { + const sessionId = req.headers["mcp-session-id"]; + + // Check if this might be an initialization request + const isInitializationRequest = req.method === "POST" && + req.headers["content-type"]?.includes("application/json"); + + if (isInitializationRequest) { + // For POST requests with JSON content, we need to check if it's an initialization request + // This will be done in handlePostRequest, as we need to parse the body + // Continue processing normally + } else if (!sessionId) { + // Non-initialization requests without a session ID should return 400 Bad Request + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Mcp-Session-Id header is required" + }, + id: null + })); + return; + } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { + // Reject requests with invalid session ID with 404 Not Found + res.writeHead(404).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found" + }, + id: null + })); + return; + } } if (req.method === "GET") { @@ -109,12 +169,19 @@ export class StreamableHTTPServerTransport implements Transport { const lastEventId = req.headers["last-event-id"]; const lastEventIdStr = Array.isArray(lastEventId) ? lastEventId[0] : lastEventId; - res.writeHead(200, { + // Prepare response headers + const headers: Record = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "mcp-session-id": this._sessionId, - }); + }; + + // Only include session ID header if session management is enabled + if (this._enableSessionManagement) { + headers["mcp-session-id"] = this._sessionId; + } + + res.writeHead(200, headers); const connection: StreamConnection = { response: res, @@ -192,6 +259,12 @@ export class StreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } + // 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' && 'id' in msg + ); + // check if it contains requests const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); const hasOnlyNotificationsOrResponses = messages.every(msg => @@ -210,12 +283,19 @@ export class StreamableHTTPServerTransport implements Transport { const useSSE = acceptHeader.includes("text/event-stream"); if (useSSE) { - res.writeHead(200, { + const headers: Record = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "mcp-session-id": this._sessionId, - }); + }; + + // Only include session ID header if session management is enabled + // Always include session ID for initialization requests + if (this._enableSessionManagement || isInitializationRequest) { + headers["mcp-session-id"] = this._sessionId; + } + + res.writeHead(200, headers); const connectionId = randomUUID(); const connection: StreamConnection = { @@ -247,10 +327,17 @@ export class StreamableHTTPServerTransport implements Transport { }); } else { // use direct JSON response - res.writeHead(200, { + const headers: Record = { "Content-Type": "application/json", - "mcp-session-id": this._sessionId, - }); + }; + + // Only include session ID header if session management is enabled + // Always include session ID for initialization requests + if (this._enableSessionManagement || isInitializationRequest) { + headers["mcp-session-id"] = this._sessionId; + } + + res.writeHead(200, headers); // handle each message for (const message of messages) { From e3a61095aed1b967d9164cc321d7b2b122465779 Mon Sep 17 00:00:00 2001 From: Geng Yan Date: Wed, 2 Apr 2025 08:57:21 +0800 Subject: [PATCH 05/60] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20pre-rarsed=20bo?= =?UTF-8?q?dy=20=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/streamable-http.test.ts | 114 +++++++++++++++++++++++++++++ src/server/streamable-http.ts | 28 ++++--- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts index 960d3800..13f70b59 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamable-http.test.ts @@ -674,4 +674,118 @@ describe("StreamableHTTPServerTransport", () => { expect(onErrorMock).toHaveBeenCalled(); }); }); + + describe("Handling Pre-Parsed Body", () => { + it("should accept pre-parsed request body", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "pre-parsed-test", + }; + + // Create a request without actual body content + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json", + }, + // No body provided here - it will be passed as parsedBody + }); + + const onMessageMock = jest.fn(); + transport.onmessage = onMessageMock; + + // Pass the pre-parsed body directly + await transport.handleRequest(req, mockResponse, message); + + // Verify the message was processed correctly + expect(onMessageMock).toHaveBeenCalledWith(message); + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "application/json", + }) + ); + }); + + 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": "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", + }, + 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); + }); + }); }); \ No newline at end of file diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts index 73eae32b..bec0a1ad 100644 --- a/src/server/streamable-http.ts +++ b/src/server/streamable-http.ts @@ -44,6 +44,11 @@ export interface StreamableHTTPServerTransportOptions { * const statelessTransport = new StreamableHTTPServerTransport("/mcp", { * enableSessionManagement: false * }); + * + * // Using with pre-parsed request body + * app.post('/mcp', (req, res) => { + * transport.handleRequest(req, res, req.body); + * }); * ``` * * In stateful mode: @@ -91,7 +96,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles an incoming HTTP request, whether GET or POST */ - async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { // Only validate session ID for non-initialization requests when session management is enabled if (this._enableSessionManagement) { const sessionId = req.headers["mcp-session-id"]; @@ -132,7 +137,7 @@ export class StreamableHTTPServerTransport implements Transport { if (req.method === "GET") { await this.handleGetRequest(req, res); } else if (req.method === "POST") { - await this.handlePostRequest(req, res); + await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { @@ -213,7 +218,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles POST requests containing JSON-RPC messages */ - private async handlePostRequest(req: IncomingMessage, res: ServerResponse): Promise { + private async handlePostRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { try { // validate the Accept header const acceptHeader = req.headers.accept; @@ -243,13 +248,18 @@ export class StreamableHTTPServerTransport implements Transport { return; } - const parsedCt = contentType.parse(ct); - const body = await getRawBody(req, { - limit: MAXIMUM_MESSAGE_SIZE, - encoding: parsedCt.parameters.charset ?? "utf-8", - }); + let rawMessage; + if (parsedBody !== undefined) { + rawMessage = parsedBody; + } else { + const parsedCt = contentType.parse(ct); + const body = await getRawBody(req, { + limit: MAXIMUM_MESSAGE_SIZE, + encoding: parsedCt.parameters.charset ?? "utf-8", + }); + rawMessage = JSON.parse(body.toString()); + } - const rawMessage = JSON.parse(body.toString()); let messages: JSONRPCMessage[]; // handle batch and single messages From e9caa5a12f8cba52b734102cd3cd28836f6cc113 Mon Sep 17 00:00:00 2001 From: Geng Yan Date: Thu, 3 Apr 2025 08:44:29 +0800 Subject: [PATCH 06/60] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20customHeaders?= =?UTF-8?q?=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/streamable-http.test.ts | 143 ++++++++++++++++++++++++++++- src/server/streamable-http.ts | 19 +++- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts index 13f70b59..74b92c1a 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamable-http.test.ts @@ -98,7 +98,7 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); + expect(mockResponse.writeHead).toHaveBeenCalledWith(404, {}); // check if the error response is a valid JSON-RPC error format expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"')); @@ -115,7 +115,7 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); + 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"')); }); @@ -342,7 +342,7 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); + expect(mockResponse.writeHead).toHaveBeenCalledWith(406, {}); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); }); @@ -788,4 +788,141 @@ describe("StreamableHTTPServerTransport", () => { expect(onMessageMock).not.toHaveBeenCalledWith(requestBodyMessage); }); }); + + describe("Custom Headers", () => { + const customHeaders = { + "X-Custom-Header": "custom-value", + "X-API-Version": "1.0", + "Access-Control-Allow-Origin": "*" + }; + + let transportWithHeaders: StreamableHTTPServerTransport; + let mockResponse: jest.Mocked; + + beforeEach(() => { + transportWithHeaders = new StreamableHTTPServerTransport(endpoint, { customHeaders }); + mockResponse = createMockResponse(); + }); + + it("should include custom headers in SSE response", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": transportWithHeaders.sessionId + }, + }); + + await transportWithHeaders.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + ...customHeaders, + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "mcp-session-id": transportWithHeaders.sessionId + }) + ); + }); + + it("should include custom headers in JSON response", 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", + "mcp-session-id": transportWithHeaders.sessionId + }, + body: JSON.stringify(message), + }); + + await transportWithHeaders.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + ...customHeaders, + "Content-Type": "application/json", + "mcp-session-id": transportWithHeaders.sessionId + }) + ); + }); + + it("should include custom headers in error responses", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": "invalid-session-id" + }, + }); + + await transportWithHeaders.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 404, + expect.objectContaining(customHeaders) + ); + }); + + it("should not override essential headers with custom headers", async () => { + const transportWithConflictingHeaders = new StreamableHTTPServerTransport(endpoint, { + customHeaders: { + "Content-Type": "text/plain", // 尝试覆盖必要的 Content-Type 头 + "X-Custom-Header": "custom-value" + } + }); + + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": transportWithConflictingHeaders.sessionId + }, + }); + + await transportWithConflictingHeaders.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "text/event-stream", // 应该保持原有的 Content-Type + "X-Custom-Header": "custom-value" + }) + ); + }); + + it("should work with empty custom headers", async () => { + const transportWithoutHeaders = new StreamableHTTPServerTransport(endpoint); + + const req = createMockRequest({ + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": transportWithoutHeaders.sessionId + }, + }); + + await transportWithoutHeaders.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "mcp-session-id": transportWithoutHeaders.sessionId + }) + ); + }); + }); }); \ No newline at end of file diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts index bec0a1ad..13d28968 100644 --- a/src/server/streamable-http.ts +++ b/src/server/streamable-http.ts @@ -28,6 +28,12 @@ export interface StreamableHTTPServerTransportOptions { * @default true */ enableSessionManagement?: boolean; + + /** + * Custom headers to be included in all responses + * These headers will be added to both SSE and regular HTTP responses + */ + customHeaders?: Record; } /** @@ -72,6 +78,7 @@ export class StreamableHTTPServerTransport implements Transport { private _started: boolean = false; private _requestConnections: Map = new Map(); // request ID to connection ID mapping private _enableSessionManagement: boolean; + private _customHeaders: Record; onclose?: () => void; onerror?: (error: Error) => void; @@ -80,6 +87,7 @@ export class StreamableHTTPServerTransport implements Transport { constructor(private _endpoint: string, options?: StreamableHTTPServerTransportOptions) { this._sessionId = randomUUID(); this._enableSessionManagement = options?.enableSessionManagement !== false; + this._customHeaders = options?.customHeaders || {}; } /** @@ -111,7 +119,7 @@ export class StreamableHTTPServerTransport implements Transport { // Continue processing normally } else if (!sessionId) { // Non-initialization requests without a session ID should return 400 Bad Request - res.writeHead(400).end(JSON.stringify({ + res.writeHead(400, this._customHeaders).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, @@ -122,7 +130,7 @@ export class StreamableHTTPServerTransport implements Transport { return; } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { // Reject requests with invalid session ID with 404 Not Found - res.writeHead(404).end(JSON.stringify({ + res.writeHead(404, this._customHeaders).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, @@ -141,7 +149,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { - res.writeHead(405).end(JSON.stringify({ + res.writeHead(405, this._customHeaders).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, @@ -159,7 +167,7 @@ export class StreamableHTTPServerTransport implements Transport { // validate the Accept header const acceptHeader = req.headers.accept; if (!acceptHeader || !acceptHeader.includes("text/event-stream")) { - res.writeHead(406).end(JSON.stringify({ + res.writeHead(406, this._customHeaders).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, @@ -176,6 +184,7 @@ export class StreamableHTTPServerTransport implements Transport { // Prepare response headers const headers: Record = { + ...this._customHeaders, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", @@ -294,6 +303,7 @@ export class StreamableHTTPServerTransport implements Transport { if (useSSE) { const headers: Record = { + ...this._customHeaders, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", @@ -338,6 +348,7 @@ export class StreamableHTTPServerTransport implements Transport { } else { // use direct JSON response const headers: Record = { + ...this._customHeaders, "Content-Type": "application/json", }; From bafd9e74f3b681c0bff38664c4399d55a4d258c0 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sat, 5 Apr 2025 19:52:29 +0100 Subject: [PATCH 07/60] Add ways to associate related requests and notifications --- src/server/mcp.test.ts | 8 ++-- src/server/mcp.ts | 16 ++++---- src/shared/protocol.ts | 82 ++++++++++++++++++++++++++++++----------- src/shared/transport.ts | 6 ++- 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 2e91a568..ae11279b 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -85,6 +85,8 @@ describe("ResourceTemplate", () => { const abortController = new AbortController(); const result = await template.listCallback?.({ signal: abortController.signal, + sendRequest: () => { throw new Error("Not implemented") }, + sendNotification: () => { throw new Error("Not implemented") } }); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); @@ -318,7 +320,7 @@ describe("tool()", () => { // This should succeed mcpServer.tool("tool1", () => ({ content: [] })); - + // This should also succeed and not throw about request handlers mcpServer.tool("tool2", () => ({ content: [] })); }); @@ -815,7 +817,7 @@ describe("resource()", () => { }, ], })); - + // This should also succeed and not throw about request handlers mcpServer.resource("resource2", "test://resource2", async () => ({ contents: [ @@ -1321,7 +1323,7 @@ describe("prompt()", () => { }, ], })); - + // This should also succeed and not throw about request handlers mcpServer.prompt("prompt2", async () => ({ messages: [ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 8f4a909c..484084fc 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -37,6 +37,8 @@ import { PromptArgument, GetPromptResult, ReadResourceResult, + ServerRequest, + ServerNotification, } from "../types.js"; import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; @@ -694,9 +696,9 @@ export type ToolCallback = Args extends ZodRawShape ? ( args: z.objectOutputType, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => CallToolResult | Promise - : (extra: RequestHandlerExtra) => CallToolResult | Promise; + : (extra: RequestHandlerExtra) => CallToolResult | Promise; type RegisteredTool = { description?: string; @@ -717,7 +719,7 @@ export type ResourceMetadata = Omit; * Callback to list all resources matching a given template. */ export type ListResourcesCallback = ( - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => ListResourcesResult | Promise; /** @@ -725,7 +727,7 @@ export type ListResourcesCallback = ( */ export type ReadResourceCallback = ( uri: URL, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; type RegisteredResource = { @@ -740,7 +742,7 @@ type RegisteredResource = { export type ReadResourceTemplateCallback = ( uri: URL, variables: Variables, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; type RegisteredResourceTemplate = { @@ -760,9 +762,9 @@ export type PromptCallback< > = Args extends PromptArgsRawShape ? ( args: z.objectOutputType, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => GetPromptResult | Promise - : (extra: RequestHandlerExtra) => GetPromptResult | Promise; + : (extra: RequestHandlerExtra) => GetPromptResult | Promise; type RegisteredPrompt = { description?: string; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index a6e47184..b072e578 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -78,22 +78,52 @@ 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; }; +/** + * Options that can be given per notification. + */ +export type NotificationOptions = { + /** + * May be used to indicate to the transport which incoming request to associate this outgoing notification with. + */ + relatedRequestId?: RequestId; +} + /** * Extra data given to request handlers. */ -export type RequestHandlerExtra = { - /** - * An abort signal used to communicate if the request was cancelled from the sender's side. - */ - signal: AbortSignal; +export type RequestHandlerExtra = { + /** + * An abort signal used to communicate if the request was cancelled from the sender's side. + */ + signal: AbortSignal; - /** - * The session ID from the transport, if available. - */ - sessionId?: string; -}; + /** + * The session ID from the transport, if available. + */ + sessionId?: string; + + /** + * Sends a notification that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + sendNotification: (notification: SendNotificationT) => Promise; + + /** + * Sends a request that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + sendRequest: >(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; + }; /** * Information about a request's timeout state @@ -122,7 +152,7 @@ export abstract class Protocol< string, ( request: JSONRPCRequest, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => Promise > = new Map(); private _requestHandlerAbortControllers: Map = @@ -316,9 +346,14 @@ export abstract class Protocol< this._requestHandlerAbortControllers.set(request.id, abortController); // Create extra object with both abort signal and sessionId from transport - const extra: RequestHandlerExtra = { + const extra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, + sendNotification: + (notification) => + this.notification(notification, { relatedRequestId: request.id }), + sendRequest: (r, resultSchema, options?) => + this.request(r, resultSchema, { ...options, relatedRequestId: request.id }) }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. @@ -364,7 +399,7 @@ export abstract class Protocol< private _onprogress(notification: ProgressNotification): void { const { progressToken, ...params } = notification.params; const messageId = Number(progressToken); - + const handler = this._progressHandlers.get(messageId); if (!handler) { this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); @@ -373,7 +408,7 @@ export abstract class Protocol< const responseHandler = this._responseHandlers.get(messageId); const timeoutInfo = this._timeoutInfo.get(messageId); - + if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { try { this._resetTimeout(messageId); @@ -460,6 +495,8 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { + const { relatedRequestId } = options ?? {}; + return new Promise((resolve, reject) => { if (!this._transport) { reject(new Error("Not connected")); @@ -500,7 +537,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }) + }, { relatedRequestId }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -538,7 +575,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); @@ -548,7 +585,7 @@ export abstract class Protocol< /** * Emits a notification, which is a one-way message that does not expect a response. */ - async notification(notification: SendNotificationT): Promise { + async notification(notification: SendNotificationT, options?: NotificationOptions): Promise { if (!this._transport) { throw new Error("Not connected"); } @@ -560,7 +597,7 @@ export abstract class Protocol< jsonrpc: "2.0", }; - await this._transport.send(jsonrpcNotification); + await this._transport.send(jsonrpcNotification, options); } /** @@ -576,14 +613,15 @@ export abstract class Protocol< requestSchema: T, handler: ( request: z.infer, - extra: RequestHandlerExtra, + extra: RequestHandlerExtra, ) => SendResultT | Promise, ): void { const method = requestSchema.shape.method.value; this.assertRequestHandlerCapability(method); - this._requestHandlers.set(method, (request, extra) => - Promise.resolve(handler(requestSchema.parse(request), extra)), - ); + + this._requestHandlers.set(method, (request, extra) => { + return Promise.resolve(handler(requestSchema.parse(request), extra)); + }); } /** diff --git a/src/shared/transport.ts b/src/shared/transport.ts index b80e2a51..84d2c829 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from "../types.js"; +import { JSONRPCMessage, RequestId } from "../types.js"; /** * Describes the minimal contract for a MCP transport that a client or server can communicate over. @@ -15,8 +15,10 @@ export interface Transport { /** * Sends a JSON-RPC message (request or response). + * + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage): Promise; + send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; /** * Closes the connection. From b0697195a78a1f7b00f4e37f0e053985f5b4ec1d Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 6 Apr 2025 16:09:15 +0100 Subject: [PATCH 08/60] add test to cover nested logging withing a tool call --- src/server/mcp.test.ts | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index ae11279b..0c136e29 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -11,6 +11,7 @@ import { ListPromptsResultSchema, GetPromptResultSchema, CompleteResultSchema, + LoggingMessageNotificationSchema, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -378,6 +379,70 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + test("should provide sendNotification withing tool call", async () => { + const mcpServer = new McpServer( + { + name: "test server", + version: "1.0", + }, + { capabilities: { logging: {} } }, + ); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + let receivedLogMessage: string | undefined; + + const loggingMessage = "hello here is log message 1" + + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + receivedLogMessage = notification.params.data as string; + + }); + + mcpServer.tool("test-tool", async ({ sendNotification }) => { + await sendNotification({ method: "notifications/message", params: { level: "debug", data: loggingMessage } }); + return { + content: [ + { + type: "text", + text: "Test response", + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Set a test sessionId on the server transport + serverTransport.sessionId = "test-session-123"; + + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + await client.request( + { + method: "tools/call", + params: { + name: "test-tool", + }, + }, + CallToolResultSchema, + ); + expect(receivedLogMessage).toBe(loggingMessage); + }); + test("should allow client to call server tools", async () => { const mcpServer = new McpServer({ name: "test server", From e3bb99cd5c9f25e079f678b3d82c05765d02e12f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 6 Apr 2025 17:27:00 +0100 Subject: [PATCH 09/60] remove the use of enableSessionManagement --- src/server/streamable-http.test.ts | 141 ++++++++++++----------------- src/server/streamable-http.ts | 90 +++++++++--------- 2 files changed, 104 insertions(+), 127 deletions(-) diff --git a/src/server/streamable-http.test.ts b/src/server/streamable-http.test.ts index 74b92c1a..b888b0ec 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamable-http.test.ts @@ -2,7 +2,7 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { StreamableHTTPServerTransport } from "./streamable-http.js"; import { JSONRPCMessage } from "../types.js"; import { Readable } from "node:stream"; - +import { randomUUID } from "node:crypto"; // Mock IncomingMessage function createMockRequest(options: { method: string; @@ -10,7 +10,7 @@ function createMockRequest(options: { body?: string; }): IncomingMessage { const readable = new Readable(); - readable._read = () => {}; + readable._read = () => { }; if (options.body) { readable.push(options.body); readable.push(null); @@ -37,12 +37,13 @@ function createMockResponse(): jest.Mocked { } describe("StreamableHTTPServerTransport", () => { - const endpoint = "/mcp"; let transport: StreamableHTTPServerTransport; let mockResponse: jest.Mocked; beforeEach(() => { - transport = new StreamableHTTPServerTransport(endpoint); + transport = new StreamableHTTPServerTransport({ + sessionId: randomUUID(), + }); mockResponse = createMockResponse(); }); @@ -61,7 +62,7 @@ describe("StreamableHTTPServerTransport", () => { const initializeMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "initialize", - params: { + params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, @@ -119,49 +120,13 @@ describe("StreamableHTTPServerTransport", () => { 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 always include session ID in initialization response even in stateless mode", async () => { - // Create a stateless transport for this test - const statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false }); - - // Create an initialization request - const initializeMessage: JSONRPCMessage = { - 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", - }, - body: JSON.stringify(initializeMessage), - }); - - await statelessTransport.handleRequest(req, mockResponse); - - // In stateless mode, session ID should also be included for initialize responses - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "mcp-session-id": statelessTransport.sessionId, - }) - ); - }); }); - describe("Stateless Mode", () => { let statelessTransport: StreamableHTTPServerTransport; let mockResponse: jest.Mocked; beforeEach(() => { - statelessTransport = new StreamableHTTPServerTransport(endpoint, { enableSessionManagement: false }); + statelessTransport = new StreamableHTTPServerTransport({ sessionId: undefined }); mockResponse = createMockResponse(); }); @@ -268,7 +233,7 @@ describe("StreamableHTTPServerTransport", () => { }); await statelessTransport.handleRequest(req2, mockResponse); - + // Should still succeed expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, @@ -278,12 +243,12 @@ describe("StreamableHTTPServerTransport", () => { ); }); - it("should handle initialization requests properly in both modes", async () => { + it("should handle initialization requests properly in statefull mode", async () => { // Initialize message that would typically be sent during initialization const initializeMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "initialize", - params: { + params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, @@ -301,7 +266,7 @@ describe("StreamableHTTPServerTransport", () => { }); await transport.handleRequest(statefulReq, mockResponse); - + // In stateful mode, session ID should be included in the response header expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, @@ -309,9 +274,19 @@ describe("StreamableHTTPServerTransport", () => { "mcp-session-id": transport.sessionId, }) ); + }); - // Reset mocks for stateless test - mockResponse.writeHead.mockClear(); + it("should handle initialization requests properly in stateless mode", async () => { + // Initialize message that would typically be sent during initialization + const initializeMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-1", + }; // Test stateless transport const statelessReq = createMockRequest({ @@ -324,10 +299,11 @@ describe("StreamableHTTPServerTransport", () => { }); await statelessTransport.handleRequest(statelessReq, mockResponse); - + // In stateless mode, session ID should also be included for initialize responses const headers = mockResponse.writeHead.mock.calls[0][1]; - expect(headers).toHaveProperty("mcp-session-id", statelessTransport.sessionId); + expect(headers).not.toHaveProperty("mcp-session-id"); + }); }); @@ -519,14 +495,14 @@ describe("StreamableHTTPServerTransport", () => { // Send a message to first connection const message1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test1", - params: {}, + jsonrpc: "2.0", + method: "test1", + params: {}, id: 1 }; - + await transport.send(message1); - + // Get message ID (captured from write call) const writeCall = mockResponse.write.mock.calls[0][0] as string; const idMatch = writeCall.match(/id: ([a-f0-9-]+)/); @@ -550,12 +526,12 @@ describe("StreamableHTTPServerTransport", () => { // Send a second message const message2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test2", - params: {}, + jsonrpc: "2.0", + method: "test2", + params: {}, id: 2 }; - + await transport.send(message2); // Verify the second message was received by both connections @@ -596,7 +572,7 @@ describe("StreamableHTTPServerTransport", () => { params: {}, id: "test-id", }; - + const reqPost = createMockRequest({ method: "POST", headers: { @@ -605,28 +581,28 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(requestMessage), }); - + await transport.handleRequest(reqPost, mockResponse1); - + // Send a response with matching ID const responseMessage: JSONRPCMessage = { jsonrpc: "2.0", result: { success: true }, id: "test-id", }; - + await transport.send(responseMessage); - + // Verify response was sent to the right connection expect(mockResponse1.write).toHaveBeenCalledWith( expect.stringContaining(JSON.stringify(responseMessage)) ); - + // Check if write was called with this exact message on the second connection - const writeCallsOnSecondConn = mockResponse2.write.mock.calls.filter(call => + const writeCallsOnSecondConn = mockResponse2.write.mock.calls.filter(call => typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage)) ); - + // Verify the response wasn't broadcast to all connections expect(writeCallsOnSecondConn.length).toBe(0); }); @@ -680,7 +656,7 @@ describe("StreamableHTTPServerTransport", () => { const message: JSONRPCMessage = { jsonrpc: "2.0", method: "initialize", - params: { + params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, @@ -715,17 +691,17 @@ describe("StreamableHTTPServerTransport", () => { it("should handle pre-parsed batch messages", async () => { const batchMessages: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "method1", + { + jsonrpc: "2.0", + method: "method1", params: { data: "test1" }, - id: "batch1" + id: "batch1" }, - { - jsonrpc: "2.0", - method: "method2", + { + jsonrpc: "2.0", + method: "method2", params: { data: "test2" }, - id: "batch2" + id: "batch2" }, ]; @@ -800,7 +776,7 @@ describe("StreamableHTTPServerTransport", () => { let mockResponse: jest.Mocked; beforeEach(() => { - transportWithHeaders = new StreamableHTTPServerTransport(endpoint, { customHeaders }); + transportWithHeaders = new StreamableHTTPServerTransport({ sessionId: randomUUID(), customHeaders }); mockResponse = createMockResponse(); }); @@ -875,9 +851,10 @@ describe("StreamableHTTPServerTransport", () => { }); it("should not override essential headers with custom headers", async () => { - const transportWithConflictingHeaders = new StreamableHTTPServerTransport(endpoint, { + const transportWithConflictingHeaders = new StreamableHTTPServerTransport({ + sessionId: randomUUID(), customHeaders: { - "Content-Type": "text/plain", // 尝试覆盖必要的 Content-Type 头 + "Content-Type": "text/plain", "X-Custom-Header": "custom-value" } }); @@ -902,8 +879,10 @@ describe("StreamableHTTPServerTransport", () => { }); it("should work with empty custom headers", async () => { - const transportWithoutHeaders = new StreamableHTTPServerTransport(endpoint); - + const transportWithoutHeaders = new StreamableHTTPServerTransport({ + sessionId: randomUUID(), + }); + const req = createMockRequest({ method: "GET", headers: { diff --git a/src/server/streamable-http.ts b/src/server/streamable-http.ts index 13d28968..ef654f85 100644 --- a/src/server/streamable-http.ts +++ b/src/server/streamable-http.ts @@ -23,11 +23,11 @@ interface StreamConnection { */ export interface StreamableHTTPServerTransportOptions { /** - * Whether to enable session management through mcp-session-id headers - * When set to false, the transport operates in stateless mode without session validation - * @default true + * The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash) + * + * When sessionId is not set, the transport will be in stateless mode. */ - enableSessionManagement?: boolean; + sessionId: string | undefined; /** * Custom headers to be included in all responses @@ -38,17 +38,19 @@ export interface StreamableHTTPServerTransportOptions { /** * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. - * It supports both SSE streaming and direct HTTP responses, with session management and message resumability. + * It supports both SSE streaming and direct HTTP responses. * * Usage example: * * ```typescript - * // Stateful mode (default) - with session management - * const statefulTransport = new StreamableHTTPServerTransport("/mcp"); + * // Stateful mode - server sets the session ID + * const statefulTransport = new StreamableHTTPServerTransport({ + * sessionId: randomUUID(), + * }); * - * // Stateless mode - without session management - * const statelessTransport = new StreamableHTTPServerTransport("/mcp", { - * enableSessionManagement: false + * // Stateless mode - explisitly set session ID to undefined + * const statelessTransport = new StreamableHTTPServerTransport({ + * sessionId: undefined, * }); * * // Using with pre-parsed request body @@ -70,23 +72,22 @@ export interface StreamableHTTPServerTransportOptions { */ export class StreamableHTTPServerTransport implements Transport { private _connections: Map = new Map(); - private _sessionId: string; + // when sessionID is not set, it means the transport is in stateless mode + private _sessionId: string | undefined; private _messageHistory: Map = new Map(); private _started: boolean = false; private _requestConnections: Map = new Map(); // request ID to connection ID mapping - private _enableSessionManagement: boolean; private _customHeaders: Record; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; - constructor(private _endpoint: string, options?: StreamableHTTPServerTransportOptions) { - this._sessionId = randomUUID(); - this._enableSessionManagement = options?.enableSessionManagement !== false; + constructor(options: StreamableHTTPServerTransportOptions) { + this._sessionId = options?.sessionId; this._customHeaders = options?.customHeaders || {}; } @@ -106,13 +107,13 @@ export class StreamableHTTPServerTransport implements Transport { */ async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { // Only validate session ID for non-initialization requests when session management is enabled - if (this._enableSessionManagement) { + if (this._sessionId !== undefined) { const sessionId = req.headers["mcp-session-id"]; - + // Check if this might be an initialization request - const isInitializationRequest = req.method === "POST" && + const isInitializationRequest = req.method === "POST" && req.headers["content-type"]?.includes("application/json"); - + if (isInitializationRequest) { // For POST requests with JSON content, we need to check if it's an initialization request // This will be done in handlePostRequest, as we need to parse the body @@ -189,9 +190,9 @@ export class StreamableHTTPServerTransport implements Transport { "Cache-Control": "no-cache", Connection: "keep-alive", }; - - // Only include session ID header if session management is enabled - if (this._enableSessionManagement) { + + // Only include session ID header if session management is enabled by assigning a value to _sessionId + if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } @@ -231,8 +232,8 @@ export class StreamableHTTPServerTransport implements Transport { try { // validate the Accept header const acceptHeader = req.headers.accept; - if (!acceptHeader || - (!acceptHeader.includes("application/json") && !acceptHeader.includes("text/event-stream"))) { + if (!acceptHeader || + (!acceptHeader.includes("application/json") && !acceptHeader.includes("text/event-stream"))) { res.writeHead(406).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -270,7 +271,7 @@ export class StreamableHTTPServerTransport implements Transport { } let messages: JSONRPCMessage[]; - + // handle batch and single messages if (Array.isArray(rawMessage)) { messages = rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)); @@ -286,7 +287,7 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); - const hasOnlyNotificationsOrResponses = messages.every(msg => + const hasOnlyNotificationsOrResponses = messages.every(msg => ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); if (hasOnlyNotificationsOrResponses) { @@ -300,7 +301,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (hasRequests) { // if it contains requests, you can choose to return an SSE stream or a JSON response const useSSE = acceptHeader.includes("text/event-stream"); - + if (useSSE) { const headers: Record = { ...this._customHeaders, @@ -308,10 +309,8 @@ export class StreamableHTTPServerTransport implements Transport { "Cache-Control": "no-cache", Connection: "keep-alive", }; - - // Only include session ID header if session management is enabled - // Always include session ID for initialization requests - if (this._enableSessionManagement || isInitializationRequest) { + + if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } @@ -351,20 +350,19 @@ export class StreamableHTTPServerTransport implements Transport { ...this._customHeaders, "Content-Type": "application/json", }; - - // Only include session ID header if session management is enabled - // Always include session ID for initialization requests - if (this._enableSessionManagement || isInitializationRequest) { + + + if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } res.writeHead(200, headers); - + // handle each message for (const message of messages) { this.onmessage?.(message); } - + res.end(); } } @@ -396,11 +394,11 @@ export class StreamableHTTPServerTransport implements Transport { */ private replayMessages(connectionId: string, lastEventId: string): void { if (!lastEventId) return; - + // only replay messages that should be sent on this connection const messages = Array.from(this._messageHistory.entries()) - .filter(([id, { connectionId: msgConnId }]) => - id > lastEventId && + .filter(([id, { connectionId: msgConnId }]) => + id > lastEventId && (!msgConnId || msgConnId === connectionId)) // only replay messages that are not specified to a connection or specified to the current connection .sort(([a], [b]) => a.localeCompare(b)); @@ -430,11 +428,11 @@ export class StreamableHTTPServerTransport implements Transport { } let targetConnectionId = ""; - + // if it is a response, find the corresponding request connection if ('id' in message && ('result' in message || 'error' in message)) { const connId = this._requestConnections.get(String(message.id)); - + // if the corresponding connection is not found, the connection may be disconnected if (!connId || !this._connections.has(connId)) { // select an available connection @@ -458,9 +456,9 @@ export class StreamableHTTPServerTransport implements Transport { } const messageId = randomUUID(); - this._messageHistory.set(messageId, { - message, - connectionId: targetConnectionId + this._messageHistory.set(messageId, { + message, + connectionId: targetConnectionId }); // keep the message history in a reasonable range @@ -490,7 +488,7 @@ export class StreamableHTTPServerTransport implements Transport { /** * Returns the session ID for this transport */ - get sessionId(): string { + get sessionId(): string | undefined { return this._sessionId; } } \ No newline at end of file From 00135707f6afb9c42fe406856e6c592d2a006922 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 6 Apr 2025 17:28:46 +0100 Subject: [PATCH 10/60] rename files to use conventioanal typescript names --- src/server/{streamable-http.test.ts => streamableHttp.test.ts} | 2 +- src/server/{streamable-http.ts => streamableHttp.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/server/{streamable-http.test.ts => streamableHttp.test.ts} (99%) rename src/server/{streamable-http.ts => streamableHttp.ts} (100%) diff --git a/src/server/streamable-http.test.ts b/src/server/streamableHttp.test.ts similarity index 99% rename from src/server/streamable-http.test.ts rename to src/server/streamableHttp.test.ts index b888b0ec..baf4d80a 100644 --- a/src/server/streamable-http.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,5 +1,5 @@ import { IncomingMessage, ServerResponse } from "node:http"; -import { StreamableHTTPServerTransport } from "./streamable-http.js"; +import { StreamableHTTPServerTransport } from "./streamableHttp.js"; import { JSONRPCMessage } from "../types.js"; import { Readable } from "node:stream"; import { randomUUID } from "node:crypto"; diff --git a/src/server/streamable-http.ts b/src/server/streamableHttp.ts similarity index 100% rename from src/server/streamable-http.ts rename to src/server/streamableHttp.ts From 268c9f7469ccd3c89e5e5c92cc5e39a7f10e6328 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Sun, 6 Apr 2025 18:42:46 +0100 Subject: [PATCH 11/60] fix test --- src/client/index.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 1209b60c..e153687c 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -66,6 +66,9 @@ test("should initialize with matching protocol version", async () => { protocolVersion: LATEST_PROTOCOL_VERSION, }), }), + expect.objectContaining({ + relatedRequestId: undefined, + }), ); // Should have the instructions returned From 6c1b9ba06a403f4498efa50b29a0cfa9db1f0f3f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 12:12:43 +0100 Subject: [PATCH 12/60] remove local storage for replaying and remove support for sse connection thought get --- src/server/streamableHttp.test.ts | 337 +++++++++++++++++++----------- src/server/streamableHttp.ts | 304 +++++++++------------------ src/shared/transport.ts | 2 +- 3 files changed, 314 insertions(+), 329 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index baf4d80a..8fb55e79 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -52,7 +52,7 @@ describe("StreamableHTTPServerTransport", () => { }); describe("Session Management", () => { - it("should generate a valid session ID", () => { + it("should store a valid session ID", () => { expect(transport.sessionId).toBeTruthy(); expect(typeof transport.sessionId).toBe("string"); }); @@ -158,11 +158,18 @@ describe("StreamableHTTPServerTransport", () => { it("should not validate session ID in stateless mode", async () => { const req = createMockRequest({ - method: "GET", + method: "POST", headers: { - accept: "text/event-stream", + "content-type": "application/json", + "accept": "application/json", "mcp-session-id": "invalid-session-id", // This would cause a 404 in stateful mode }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: 1 + }), }); await statelessTransport.handleRequest(req, mockResponse); @@ -206,10 +213,17 @@ describe("StreamableHTTPServerTransport", () => { it("should work with a mix of requests with and without session IDs in stateless mode", async () => { // First request without session ID const req1 = createMockRequest({ - method: "GET", + method: "POST", headers: { + "content-type": "application/json", accept: "text/event-stream", }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }) }); await statelessTransport.handleRequest(req1, mockResponse); @@ -225,11 +239,18 @@ describe("StreamableHTTPServerTransport", () => { // Second request with a session ID (which would be invalid in stateful mode) const req2 = createMockRequest({ - method: "GET", + method: "POST", headers: { + "content-type": "application/json", accept: "text/event-stream", "mcp-session-id": "some-random-session-id", }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test2", + params: {}, + id: "test-id-2" + }) }); await statelessTransport.handleRequest(req2, mockResponse); @@ -308,21 +329,7 @@ describe("StreamableHTTPServerTransport", () => { }); describe("Request Handling", () => { - it("should reject GET requests without Accept: text/event-stream header", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "mcp-session-id": transport.sessionId, - }, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(406, {}); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - }); - - it("should properly handle GET requests with Accept header and establish SSE connection", async () => { + it("should reject GET requests for SSE with 405 Method Not Allowed", async () => { const req = createMockRequest({ method: "GET", headers: { @@ -333,14 +340,11 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }) - ); + expect(mockResponse.writeHead).toHaveBeenCalledWith(405, expect.objectContaining({ + "Allow": "POST, DELETE" + })); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Server does not offer an SSE stream at this endpoint')); }); it("should reject POST requests without proper Accept header", async () => { @@ -421,7 +425,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith(202); }); - it("should handle batch messages properly", async () => { + 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: {} }, @@ -446,6 +450,39 @@ describe("StreamableHTTPServerTransport", () => { 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; + + 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", @@ -481,130 +518,170 @@ describe("StreamableHTTPServerTransport", () => { }); }); - describe("Message Replay", () => { - it("should replay messages after specified Last-Event-ID", async () => { - // Establish first connection with Accept header and session ID - const req1 = createMockRequest({ - method: "GET", + describe("SSE Response Handling", () => { + 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" + }; + + const req = createMockRequest({ + method: "POST", headers: { + "content-type": "application/json", "accept": "text/event-stream", "mcp-session-id": transport.sessionId }, - }); - await transport.handleRequest(req1, mockResponse); - - // Send a message to first connection - const message1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test1", - params: {}, - id: 1 - }; - - await transport.send(message1); - - // Get message ID (captured from write call) - const writeCall = mockResponse.write.mock.calls[0][0] as string; - const idMatch = writeCall.match(/id: ([a-f0-9-]+)/); - if (!idMatch) { - throw new Error("Message ID not found in write call"); - } - const lastEventId = idMatch[1]; - - // Create a second connection with last-event-id - const mockResponse2 = createMockResponse(); - const req2 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - "last-event-id": lastEventId, - "mcp-session-id": transport.sessionId - }, + body: JSON.stringify(requestMessage) }); - await transport.handleRequest(req2, mockResponse2); + await transport.handleRequest(req, mockResponse); - // Send a second message - const message2: JSONRPCMessage = { + // Send a response to the request + const responseMessage: JSONRPCMessage = { jsonrpc: "2.0", - method: "test2", - params: {}, - id: 2 + result: { value: "test-result" }, + id: "test-req-id" }; - await transport.send(message2); + await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); - // Verify the second message was received by both connections + // Verify response was sent as SSE event expect(mockResponse.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(message1)) - ); - expect(mockResponse2.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(message2)) + expect.stringContaining(`event: message\ndata: ${JSON.stringify(responseMessage)}\n\n`) ); + + // Stream should be closed after sending response + expect(mockResponse.end).toHaveBeenCalled(); + }); + + 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" + }; + + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "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(); }); }); describe("Message Targeting", () => { it("should send response messages to the connection that sent the request", async () => { - // Create two connections - const mockResponse1 = createMockResponse(); - const req1 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - }, - }); - await transport.handleRequest(req1, mockResponse1); - - const mockResponse2 = createMockResponse(); - const req2 = createMockRequest({ - method: "GET", - headers: { - "accept": "text/event-stream", - }, - }); - await transport.handleRequest(req2, mockResponse2); - - // Send a request through the first connection - const requestMessage: JSONRPCMessage = { + // Create request with two separate connections + const requestMessage1: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", + method: "test1", params: {}, - id: "test-id", + id: "req-id-1", }; - const reqPost = createMockRequest({ + const mockResponse1 = createMockResponse(); + const req1 = createMockRequest({ method: "POST", headers: { "content-type": "application/json", "accept": "text/event-stream", + "mcp-session-id": transport.sessionId }, - body: JSON.stringify(requestMessage), + body: JSON.stringify(requestMessage1), }); + await transport.handleRequest(req1, mockResponse1); - await transport.handleRequest(reqPost, mockResponse1); - - // Send a response with matching ID - const responseMessage: JSONRPCMessage = { + const requestMessage2: JSONRPCMessage = { jsonrpc: "2.0", - result: { success: true }, - id: "test-id", + method: "test2", + params: {}, + id: "req-id-2", }; - await transport.send(responseMessage); + const mockResponse2 = createMockResponse(); + const req2 = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId + }, + body: JSON.stringify(requestMessage2), + }); + await transport.handleRequest(req2, mockResponse2); - // Verify response was sent to the right connection + // 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(responseMessage)) + expect.stringContaining(JSON.stringify(responseMessage1)) ); - // Check if write was called with this exact message on the second connection - const writeCallsOnSecondConn = mockResponse2.write.mock.calls.filter(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage)) + expect(mockResponse2.write).toHaveBeenCalledWith( + expect.stringContaining(JSON.stringify(responseMessage2)) ); - // Verify the response wasn't broadcast to all connections - expect(writeCallsOnSecondConn.length).toBe(0); + // 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); }); }); @@ -749,6 +826,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "application/json", "accept": "application/json", + "mcp-session-id": transport.sessionId, }, body: JSON.stringify(requestBodyMessage), }); @@ -782,11 +860,18 @@ describe("StreamableHTTPServerTransport", () => { it("should include custom headers in SSE response", async () => { const req = createMockRequest({ - method: "GET", + method: "POST", headers: { + "content-type": "application/json", accept: "text/event-stream", "mcp-session-id": transportWithHeaders.sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-headers-id" + }) }); await transportWithHeaders.handleRequest(req, mockResponse); @@ -860,11 +945,18 @@ describe("StreamableHTTPServerTransport", () => { }); const req = createMockRequest({ - method: "GET", + method: "POST", headers: { + "content-type": "application/json", accept: "text/event-stream", "mcp-session-id": transportWithConflictingHeaders.sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-conflict-id" + }) }); await transportWithConflictingHeaders.handleRequest(req, mockResponse); @@ -872,7 +964,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "Content-Type": "text/event-stream", // 应该保持原有的 Content-Type + "Content-Type": "text/event-stream", "X-Custom-Header": "custom-value" }) ); @@ -884,11 +976,18 @@ describe("StreamableHTTPServerTransport", () => { }); const req = createMockRequest({ - method: "GET", + method: "POST", headers: { + "content-type": "application/json", accept: "text/event-stream", "mcp-session-id": transportWithoutHeaders.sessionId }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-empty-headers-id" + }) }); await transportWithoutHeaders.handleRequest(req, mockResponse); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index ef654f85..6afe826a 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,23 +1,11 @@ -import { randomUUID } from "node:crypto"; import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; const MAXIMUM_MESSAGE_SIZE = "4mb"; -interface StreamConnection { - response: ServerResponse; - lastEventId?: string; - messages: Array<{ - id: string; - message: JSONRPCMessage; - }>; - // mark this connection as a response to a specific request - requestId?: string | null; -} - /** * Configuration options for StreamableHTTPServerTransport */ @@ -34,6 +22,7 @@ export interface StreamableHTTPServerTransportOptions { * These headers will be added to both SSE and regular HTTP responses */ customHeaders?: Record; + } /** @@ -71,16 +60,11 @@ export interface StreamableHTTPServerTransportOptions { * - No session validation is performed */ export class StreamableHTTPServerTransport implements Transport { - private _connections: Map = new Map(); // when sessionID is not set, it means the transport is in stateless mode private _sessionId: string | undefined; - private _messageHistory: Map = new Map(); private _started: boolean = false; - private _requestConnections: Map = new Map(); // request ID to connection ID mapping private _customHeaders: Record; + private _sseResponseMapping: Map = new Map(); onclose?: () => void; onerror?: (error: Error) => void; @@ -108,37 +92,10 @@ export class StreamableHTTPServerTransport implements Transport { async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { // Only validate session ID for non-initialization requests when session management is enabled if (this._sessionId !== undefined) { - const sessionId = req.headers["mcp-session-id"]; - - // Check if this might be an initialization request const isInitializationRequest = req.method === "POST" && req.headers["content-type"]?.includes("application/json"); - if (isInitializationRequest) { - // For POST requests with JSON content, we need to check if it's an initialization request - // This will be done in handlePostRequest, as we need to parse the body - // Continue processing normally - } else if (!sessionId) { - // Non-initialization requests without a session ID should return 400 Bad Request - res.writeHead(400, this._customHeaders).end(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Bad Request: Mcp-Session-Id header is required" - }, - id: null - })); - return; - } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { - // Reject requests with invalid session ID with 404 Not Found - res.writeHead(404, this._customHeaders).end(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32001, - message: "Session not found" - }, - id: null - })); + if (!isInitializationRequest && !this.validateSession(req, res)) { return; } } @@ -163,66 +120,22 @@ export class StreamableHTTPServerTransport implements Transport { /** * Handles GET requests to establish SSE connections + * According to the MCP Streamable HTTP transport spec, the server MUST either return SSE or 405. + * We choose to return 405 Method Not Allowed as we don't support GET SSE connections yet. */ private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { - // validate the Accept header - const acceptHeader = req.headers.accept; - if (!acceptHeader || !acceptHeader.includes("text/event-stream")) { - res.writeHead(406, this._customHeaders).end(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Not Acceptable: Client must accept text/event-stream" - }, - id: null - })); - return; - } - - const connectionId = randomUUID(); - const lastEventId = req.headers["last-event-id"]; - const lastEventIdStr = Array.isArray(lastEventId) ? lastEventId[0] : lastEventId; - - // Prepare response headers - const headers: Record = { + // Return 405 Method Not Allowed + res.writeHead(405, { ...this._customHeaders, - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }; - - // Only include session ID header if session management is enabled by assigning a value to _sessionId - if (this._sessionId !== undefined) { - headers["mcp-session-id"] = this._sessionId; - } - - res.writeHead(200, headers); - - const connection: StreamConnection = { - response: res, - lastEventId: lastEventIdStr, - messages: [], - }; - - this._connections.set(connectionId, connection); - - // if there is a Last-Event-ID, replay messages on this connection - if (lastEventIdStr) { - this.replayMessages(connectionId, lastEventIdStr); - } - - res.on("close", () => { - this._connections.delete(connectionId); - // remove all request mappings associated with this connection - for (const [reqId, connId] of this._requestConnections.entries()) { - if (connId === connectionId) { - this._requestConnections.delete(reqId); - } - } - if (this._connections.size === 0) { - this.onclose?.(); - } - }); + "Allow": "POST, DELETE" + }).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed: Server does not offer an SSE stream at this endpoint" + }, + id: null + })); } /** @@ -279,11 +192,18 @@ export class StreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } - // 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' && 'id' in msg - ); + if (this._sessionId !== undefined) { + // 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' && 'id' in msg + ); + + if (!isInitializationRequest && !this.validateSession(req, res)) { + return; + } + } + // check if it contains requests const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); @@ -310,40 +230,29 @@ export class StreamableHTTPServerTransport implements Transport { Connection: "keep-alive", }; + // For initialization requests, always include the session ID if we have one + // even if we're in stateless mode if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } res.writeHead(200, headers); - const connectionId = randomUUID(); - const connection: StreamConnection = { - response: res, - messages: [], - }; - - this._connections.set(connectionId, connection); - - // map each request to a connection ID + // Store the response for this request to send messages back through this connection + // We need to track by request ID to maintain the connection for (const message of messages) { if ('method' in message && 'id' in message) { - this._requestConnections.set(String(message.id), connectionId); + this._sseResponseMapping.set(message.id, res); } + } + + // handle each message + for (const message of messages) { this.onmessage?.(message); } - res.on("close", () => { - this._connections.delete(connectionId); - // remove all request mappings associated with this connection - for (const [reqId, connId] of this._requestConnections.entries()) { - if (connId === connectionId) { - this._requestConnections.delete(reqId); - } - } - if (this._connections.size === 0) { - this.onclose?.(); - } - }); + // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses + // This will be handled by the send() method when responses are ready } else { // use direct JSON response const headers: Record = { @@ -351,7 +260,8 @@ export class StreamableHTTPServerTransport implements Transport { "Content-Type": "application/json", }; - + // For initialization requests, always include the session ID if we have one + // even if we're in stateless mode if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } @@ -390,97 +300,73 @@ export class StreamableHTTPServerTransport implements Transport { } /** - * Replays messages after the specified event ID for a specific connection + * Validates session ID for non-initialization requests when session management is enabled + * Returns true if the session is valid, false otherwise */ - private replayMessages(connectionId: string, lastEventId: string): void { - if (!lastEventId) return; + private validateSession(req: IncomingMessage, res: ServerResponse): boolean { + const sessionId = req.headers["mcp-session-id"]; - // only replay messages that should be sent on this connection - const messages = Array.from(this._messageHistory.entries()) - .filter(([id, { connectionId: msgConnId }]) => - id > lastEventId && - (!msgConnId || msgConnId === connectionId)) // only replay messages that are not specified to a connection or specified to the current connection - .sort(([a], [b]) => a.localeCompare(b)); - - const connection = this._connections.get(connectionId); - if (!connection) return; - - for (const [id, { message }] of messages) { - connection.response.write( - `id: ${id}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` - ); + if (!sessionId) { + // Non-initialization requests without a session ID should return 400 Bad Request + res.writeHead(400, this._customHeaders).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Mcp-Session-Id header is required" + }, + id: null + })); + return false; + } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { + // Reject requests with invalid session ID with 404 Not Found + res.writeHead(404, this._customHeaders).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found" + }, + id: null + })); + return false; } + + return true; } + async close(): Promise { - for (const connection of this._connections.values()) { - connection.response.end(); - } - this._connections.clear(); - this._messageHistory.clear(); - this._requestConnections.clear(); + // Close all SSE connections + this._sseResponseMapping.forEach((response) => { + response.end(); + }); + this._sseResponseMapping.clear(); this.onclose?.(); } - async send(message: JSONRPCMessage): Promise { - if (this._connections.size === 0) { - throw new Error("No active connections"); + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { + const relatedRequestId = options?.relatedRequestId; + if (relatedRequestId === undefined) { + throw new Error("relatedRequestId is required"); } - let targetConnectionId = ""; - - // if it is a response, find the corresponding request connection - if ('id' in message && ('result' in message || 'error' in message)) { - const connId = this._requestConnections.get(String(message.id)); - - // if the corresponding connection is not found, the connection may be disconnected - if (!connId || !this._connections.has(connId)) { - // select an available connection - const firstConnId = this._connections.keys().next().value; - if (firstConnId) { - targetConnectionId = firstConnId; - } else { - throw new Error("No available connections"); - } - } else { - targetConnectionId = connId; - } - } else { - // for other messages, select an available connection - const firstConnId = this._connections.keys().next().value; - if (firstConnId) { - targetConnectionId = firstConnId; - } else { - throw new Error("No available connections"); - } + const sseResponse = this._sseResponseMapping.get(relatedRequestId); + if (!sseResponse) { + throw new Error("No SSE connection established"); } - const messageId = randomUUID(); - this._messageHistory.set(messageId, { - message, - connectionId: targetConnectionId - }); + // Send the message as an SSE event + sseResponse.write( + `event: message\ndata: ${JSON.stringify(message)}\n\n`, + ); - // keep the message history in a reasonable range - if (this._messageHistory.size > 1000) { - const oldestKey = Array.from(this._messageHistory.keys())[0]; - this._messageHistory.delete(oldestKey); - } - - // send the message to all active connections - for (const [connId, connection] of this._connections.entries()) { - // if it is a response message, only send to the target connection - if ('id' in message && ('result' in message || 'error' in message)) { - if (connId === targetConnectionId) { - connection.response.write( - `id: ${messageId}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` - ); - } - } else { - // for other messages, send to all connections - connection.response.write( - `id: ${messageId}\nevent: message\ndata: ${JSON.stringify(message)}\n\n` - ); + // If this is a response message with the same ID as the request, we can check + // if we need to close the stream after sending the response + if ('result' in message || 'error' in message) { + if (message.id === relatedRequestId) { + // This is a response to the original request, we can close the stream + // after sending all related responses + this._sseResponseMapping.delete(relatedRequestId); + sseResponse.end(); } } } diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 72db1da8..d855ceab 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -16,7 +16,7 @@ export interface Transport { /** * Sends a JSON-RPC message (request or response). * - * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + * 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; From 4068e6f9a3da7f5e00dec06901fddee01abc5a65 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 14:22:11 +0100 Subject: [PATCH 13/60] cleanup initialization --- src/server/streamableHttp.test.ts | 90 ++++++------------ src/server/streamableHttp.ts | 152 ++++++++++++++---------------- 2 files changed, 100 insertions(+), 142 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 8fb55e79..129b499e 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -73,7 +73,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: JSON.stringify(initializeMessage), }); @@ -93,7 +93,7 @@ describe("StreamableHTTPServerTransport", () => { method: "GET", headers: { "mcp-session-id": "invalid-session-id", - "accept": "text/event-stream" + "accept": "application/json, text/event-stream" }, }); @@ -109,7 +109,7 @@ describe("StreamableHTTPServerTransport", () => { const req = createMockRequest({ method: "GET", headers: { - accept: "text/event-stream", + accept: "application/json, text/event-stream", // No mcp-session-id header }, }); @@ -143,7 +143,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: JSON.stringify(message), }); @@ -161,7 +161,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", "mcp-session-id": "invalid-session-id", // This would cause a 404 in stateful mode }, body: JSON.stringify({ @@ -195,7 +195,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", "mcp-session-id": "non-existent-session-id", // This would be rejected in stateful mode }, body: JSON.stringify(message), @@ -216,7 +216,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - accept: "text/event-stream", + accept: "application/json, text/event-stream", }, body: JSON.stringify({ jsonrpc: "2.0", @@ -242,7 +242,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - accept: "text/event-stream", + accept: "application/json, text/event-stream", "mcp-session-id": "some-random-session-id", }, body: JSON.stringify({ @@ -264,7 +264,7 @@ describe("StreamableHTTPServerTransport", () => { ); }); - it("should handle initialization requests properly in statefull mode", async () => { + it("should handle initialization requests properly in stateful mode", async () => { // Initialize message that would typically be sent during initialization const initializeMessage: JSONRPCMessage = { jsonrpc: "2.0", @@ -281,7 +281,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: JSON.stringify(initializeMessage), }); @@ -314,7 +314,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: JSON.stringify(initializeMessage), }); @@ -333,7 +333,7 @@ describe("StreamableHTTPServerTransport", () => { const req = createMockRequest({ method: "GET", headers: { - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId, }, }); @@ -380,7 +380,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", }, body: JSON.stringify(message), }); @@ -394,7 +394,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "Content-Type": "text/event-stream", + "mcp-session-id": transport.sessionId, }) ); }); @@ -435,7 +435,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId, }, body: JSON.stringify(batchMessages), @@ -488,7 +488,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "text/plain", - "accept": "application/json", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId, }, body: "test", @@ -532,7 +532,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId }, body: JSON.stringify(requestMessage) @@ -571,7 +571,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId }, body: JSON.stringify(requestMessage) @@ -620,7 +620,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId }, body: JSON.stringify(requestMessage1), @@ -639,7 +639,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId }, body: JSON.stringify(requestMessage2), @@ -691,7 +691,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: "invalid json", }); @@ -712,7 +712,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, body: JSON.stringify({ invalid: "message" }), }); @@ -745,7 +745,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", }, // No body provided here - it will be passed as parsedBody }); @@ -761,7 +761,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "Content-Type": "application/json", + "mcp-session-id": transport.sessionId, }) ); }); @@ -787,7 +787,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "text/event-stream", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId, }, // No body provided here - it will be passed as parsedBody @@ -825,7 +825,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", "mcp-session-id": transport.sessionId, }, body: JSON.stringify(requestBodyMessage), @@ -863,7 +863,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - accept: "text/event-stream", + accept: "application/json, text/event-stream", "mcp-session-id": transportWithHeaders.sessionId }, body: JSON.stringify({ @@ -888,41 +888,11 @@ describe("StreamableHTTPServerTransport", () => { ); }); - it("should include custom headers in JSON response", 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", - "mcp-session-id": transportWithHeaders.sessionId - }, - body: JSON.stringify(message), - }); - - await transportWithHeaders.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - ...customHeaders, - "Content-Type": "application/json", - "mcp-session-id": transportWithHeaders.sessionId - }) - ); - }); - it("should include custom headers in error responses", async () => { const req = createMockRequest({ method: "GET", headers: { - accept: "text/event-stream", + accept: "application/json, text/event-stream", "mcp-session-id": "invalid-session-id" }, }); @@ -948,7 +918,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - accept: "text/event-stream", + accept: "application/json, text/event-stream", "mcp-session-id": transportWithConflictingHeaders.sessionId }, body: JSON.stringify({ @@ -979,7 +949,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", - accept: "text/event-stream", + accept: "application/json, text/event-stream", "mcp-session-id": transportWithoutHeaders.sessionId }, body: JSON.stringify({ diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 6afe826a..984b2c19 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -37,7 +37,7 @@ export interface StreamableHTTPServerTransportOptions { * sessionId: randomUUID(), * }); * - * // Stateless mode - explisitly set session ID to undefined + * // Stateless mode - explicitly set session ID to undefined * const statelessTransport = new StreamableHTTPServerTransport({ * sessionId: undefined, * }); @@ -60,7 +60,7 @@ export interface StreamableHTTPServerTransportOptions { * - No session validation is performed */ export class StreamableHTTPServerTransport implements Transport { - // when sessionID is not set, it means the transport is in stateless mode + // when sessionId is not set (undefined), it means the transport is in stateless mode private _sessionId: string | undefined; private _started: boolean = false; private _customHeaders: Record; @@ -71,8 +71,8 @@ export class StreamableHTTPServerTransport implements Transport { onmessage?: (message: JSONRPCMessage) => void; constructor(options: StreamableHTTPServerTransportOptions) { - this._sessionId = options?.sessionId; - this._customHeaders = options?.customHeaders || {}; + this._sessionId = options.sessionId; + this._customHeaders = options.customHeaders || {}; } /** @@ -90,16 +90,6 @@ export class StreamableHTTPServerTransport implements Transport { * Handles an incoming HTTP request, whether GET or POST */ async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { - // Only validate session ID for non-initialization requests when session management is enabled - if (this._sessionId !== undefined) { - const isInitializationRequest = req.method === "POST" && - req.headers["content-type"]?.includes("application/json"); - - if (!isInitializationRequest && !this.validateSession(req, res)) { - return; - } - } - if (req.method === "GET") { await this.handleGetRequest(req, res); } else if (req.method === "POST") { @@ -124,7 +114,11 @@ export class StreamableHTTPServerTransport implements Transport { * We choose to return 405 Method Not Allowed as we don't support GET SSE connections yet. */ private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { - // Return 405 Method Not Allowed + // Check session validity first for GET requests when session management is enabled + if (this._sessionId !== undefined && !this.validateSession(req, res)) { + return; + } + res.writeHead(405, { ...this._customHeaders, "Allow": "POST, DELETE" @@ -143,15 +137,16 @@ export class StreamableHTTPServerTransport implements Transport { */ private async handlePostRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { try { - // validate the Accept header + // Validate the Accept header const acceptHeader = req.headers.accept; + // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if (!acceptHeader || - (!acceptHeader.includes("application/json") && !acceptHeader.includes("text/event-stream"))) { + !acceptHeader.includes("application/json") || !acceptHeader.includes("text/event-stream")) { res.writeHead(406).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, - message: "Not Acceptable: Client must accept application/json and/or text/event-stream" + message: "Not Acceptable: Client must accept both application/json and text/event-stream" }, id: null })); @@ -192,16 +187,33 @@ export class StreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } - if (this._sessionId !== undefined) { - // 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' && 'id' in msg - ); + // 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' + ); + if (isInitializationRequest) { + const headers: Record = { + ...this._customHeaders + }; - if (!isInitializationRequest && !this.validateSession(req, res)) { - return; + if (this._sessionId !== undefined) { + headers["mcp-session-id"] = this._sessionId; } + + // Process initialization messages before responding + for (const message of messages) { + this.onmessage?.(message); + } + + res.writeHead(200, headers).end(); + return; + } + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (this._sessionId !== undefined && !isInitializationRequest && !this.validateSession(req, res)) { + return; } @@ -219,62 +231,35 @@ export class StreamableHTTPServerTransport implements Transport { this.onmessage?.(message); } } else if (hasRequests) { - // if it contains requests, you can choose to return an SSE stream or a JSON response - const useSSE = acceptHeader.includes("text/event-stream"); + const headers: Record = { + ...this._customHeaders, + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; - if (useSSE) { - const headers: Record = { - ...this._customHeaders, - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }; - - // For initialization requests, always include the session ID if we have one - // even if we're in stateless mode - if (this._sessionId !== undefined) { - headers["mcp-session-id"] = this._sessionId; - } - - res.writeHead(200, headers); - - // Store the response for this request to send messages back through this connection - // We need to track by request ID to maintain the connection - for (const message of messages) { - if ('method' in message && 'id' in message) { - this._sseResponseMapping.set(message.id, res); - } - } - - // handle each message - for (const message of messages) { - this.onmessage?.(message); - } - - // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses - // This will be handled by the send() method when responses are ready - } else { - // use direct JSON response - const headers: Record = { - ...this._customHeaders, - "Content-Type": "application/json", - }; - - // For initialization requests, always include the session ID if we have one - // even if we're in stateless mode - if (this._sessionId !== undefined) { - headers["mcp-session-id"] = this._sessionId; - } - - res.writeHead(200, headers); - - // handle each message - for (const message of messages) { - this.onmessage?.(message); - } - - res.end(); + // For initialization requests, always include the session ID if we have one + // even if we're in stateless mode + if (this._sessionId !== undefined) { + headers["mcp-session-id"] = this._sessionId; } + + res.writeHead(200, headers); + + // Store the response for this request to send messages back through this connection + // We need to track by request ID to maintain the connection + for (const message of messages) { + if ('method' in message && 'id' in message) { + this._sseResponseMapping.set(message.id, res); + } + } + + // handle each message + for (const message of messages) { + this.onmessage?.(message); + } + // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses + // This will be handled by the send() method when responses are ready } } catch (error) { // return JSON-RPC formatted error @@ -295,6 +280,9 @@ export class StreamableHTTPServerTransport implements Transport { * Handles DELETE requests to terminate sessions */ private async handleDeleteRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (!this.validateSession(req, res)) { + return; + } await this.close(); res.writeHead(200).end(); } @@ -346,12 +334,12 @@ export class StreamableHTTPServerTransport implements Transport { async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { const relatedRequestId = options?.relatedRequestId; if (relatedRequestId === undefined) { - throw new Error("relatedRequestId is required"); + throw new Error("relatedRequestId is required for Streamable HTTP transport"); } const sseResponse = this._sseResponseMapping.get(relatedRequestId); if (!sseResponse) { - throw new Error("No SSE connection established"); + throw new Error(`No SSE connection established for request ID: ${String(relatedRequestId)}`); } // Send the message as an SSE event From af64a0e18cccc536cc3c35061ff1d0955cc102c8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 16:03:31 +0100 Subject: [PATCH 14/60] remove custom headers --- src/server/mcp.test.ts | 11 +-- src/server/streamableHttp.test.ts | 135 +----------------------------- src/server/streamableHttp.ts | 25 ++---- 3 files changed, 11 insertions(+), 160 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 0c136e29..f33c669f 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -379,7 +379,7 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); - test("should provide sendNotification withing tool call", async () => { + test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { name: "test server", @@ -401,12 +401,10 @@ describe("tool()", () => { ); let receivedLogMessage: string | undefined; - - const loggingMessage = "hello here is log message 1" + const loggingMessage = "hello here is log message 1"; client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { receivedLogMessage = notification.params.data as string; - }); mcpServer.tool("test-tool", async ({ sendNotification }) => { @@ -422,15 +420,10 @@ describe("tool()", () => { }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set a test sessionId on the server transport - serverTransport.sessionId = "test-session-123"; - - await Promise.all([ client.connect(clientTransport), mcpServer.server.connect(serverTransport), ]); - await client.request( { method: "tools/call", diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 129b499e..6c5c8705 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -99,7 +99,7 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(404, {}); + expect(mockResponse.writeHead).toHaveBeenCalledWith(404); // check if the error response is a valid JSON-RPC error format expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"')); @@ -116,7 +116,7 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(400, {}); + 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"')); }); @@ -842,135 +842,4 @@ describe("StreamableHTTPServerTransport", () => { expect(onMessageMock).not.toHaveBeenCalledWith(requestBodyMessage); }); }); - - describe("Custom Headers", () => { - const customHeaders = { - "X-Custom-Header": "custom-value", - "X-API-Version": "1.0", - "Access-Control-Allow-Origin": "*" - }; - - let transportWithHeaders: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - - beforeEach(() => { - transportWithHeaders = new StreamableHTTPServerTransport({ sessionId: randomUUID(), customHeaders }); - mockResponse = createMockResponse(); - }); - - it("should include custom headers in SSE response", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": transportWithHeaders.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-headers-id" - }) - }); - - await transportWithHeaders.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - ...customHeaders, - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "mcp-session-id": transportWithHeaders.sessionId - }) - ); - }); - - it("should include custom headers in error responses", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - accept: "application/json, text/event-stream", - "mcp-session-id": "invalid-session-id" - }, - }); - - await transportWithHeaders.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 404, - expect.objectContaining(customHeaders) - ); - }); - - it("should not override essential headers with custom headers", async () => { - const transportWithConflictingHeaders = new StreamableHTTPServerTransport({ - sessionId: randomUUID(), - customHeaders: { - "Content-Type": "text/plain", - "X-Custom-Header": "custom-value" - } - }); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": transportWithConflictingHeaders.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-conflict-id" - }) - }); - - await transportWithConflictingHeaders.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - "X-Custom-Header": "custom-value" - }) - ); - }); - - it("should work with empty custom headers", async () => { - const transportWithoutHeaders = new StreamableHTTPServerTransport({ - sessionId: randomUUID(), - }); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": transportWithoutHeaders.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-empty-headers-id" - }) - }); - - await transportWithoutHeaders.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "mcp-session-id": transportWithoutHeaders.sessionId - }) - ); - }); - }); }); \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 984b2c19..d7f44160 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -13,15 +13,11 @@ export interface StreamableHTTPServerTransportOptions { /** * The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash) * - * When sessionId is not set, the transport will be in stateless mode. + * When there is no sessionId, the transport will not perform session management. */ sessionId: string | undefined; - /** - * Custom headers to be included in all responses - * These headers will be added to both SSE and regular HTTP responses - */ - customHeaders?: Record; + } @@ -63,7 +59,6 @@ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private _sessionId: string | undefined; private _started: boolean = false; - private _customHeaders: Record; private _sseResponseMapping: Map = new Map(); onclose?: () => void; @@ -72,7 +67,6 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this._sessionId = options.sessionId; - this._customHeaders = options.customHeaders || {}; } /** @@ -97,7 +91,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { - res.writeHead(405, this._customHeaders).end(JSON.stringify({ + res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, @@ -120,7 +114,6 @@ export class StreamableHTTPServerTransport implements Transport { } res.writeHead(405, { - ...this._customHeaders, "Allow": "POST, DELETE" }).end(JSON.stringify({ jsonrpc: "2.0", @@ -193,9 +186,7 @@ export class StreamableHTTPServerTransport implements Transport { msg => 'method' in msg && msg.method === 'initialize' ); if (isInitializationRequest) { - const headers: Record = { - ...this._customHeaders - }; + const headers: Record = {}; if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; @@ -232,14 +223,12 @@ export class StreamableHTTPServerTransport implements Transport { } } else if (hasRequests) { const headers: Record = { - ...this._customHeaders, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }; - // For initialization requests, always include the session ID if we have one - // even if we're in stateless mode + // After initialization, always include the session ID if we have one if (this._sessionId !== undefined) { headers["mcp-session-id"] = this._sessionId; } @@ -296,7 +285,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!sessionId) { // Non-initialization requests without a session ID should return 400 Bad Request - res.writeHead(400, this._customHeaders).end(JSON.stringify({ + res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, @@ -307,7 +296,7 @@ export class StreamableHTTPServerTransport implements Transport { return false; } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { // Reject requests with invalid session ID with 404 Not Found - res.writeHead(404, this._customHeaders).end(JSON.stringify({ + res.writeHead(404).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, From 8c2086ec975a94b778662ca792c6ef7ecc9c616b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 16:15:09 +0100 Subject: [PATCH 15/60] refactor handle request types --- src/server/streamableHttp.test.ts | 2 +- src/server/streamableHttp.ts | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 6c5c8705..981ae17b 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -344,7 +344,7 @@ describe("StreamableHTTPServerTransport", () => { "Allow": "POST, DELETE" })); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Server does not offer an SSE stream at this endpoint')); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Method not allowed')); }); it("should reject POST requests without proper Accept header", async () => { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index d7f44160..a58370f1 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -84,31 +84,20 @@ export class StreamableHTTPServerTransport implements Transport { * Handles an incoming HTTP request, whether GET or POST */ async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { - if (req.method === "GET") { - await this.handleGetRequest(req, res); - } else if (req.method === "POST") { + if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { - res.writeHead(405).end(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Method not allowed" - }, - id: null - })); + await this.handleUnsupportedRequest(req, res); } } /** - * Handles GET requests to establish SSE connections - * According to the MCP Streamable HTTP transport spec, the server MUST either return SSE or 405. - * We choose to return 405 Method Not Allowed as we don't support GET SSE connections yet. + * Handles unsupported requests (GET, PUT, PATCH, etc.) + * For now we support only POST and DELETE requests. Support for GET for SSE connections will be added later. */ - private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { - // Check session validity first for GET requests when session management is enabled + private async handleUnsupportedRequest(req: IncomingMessage, res: ServerResponse): Promise { if (this._sessionId !== undefined && !this.validateSession(req, res)) { return; } @@ -119,7 +108,7 @@ export class StreamableHTTPServerTransport implements Transport { jsonrpc: "2.0", error: { code: -32000, - message: "Method not allowed: Server does not offer an SSE stream at this endpoint" + message: "Method not allowed." }, id: null })); @@ -322,6 +311,8 @@ export class StreamableHTTPServerTransport implements Transport { async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { const relatedRequestId = options?.relatedRequestId; + // SSE connections are established per POST request, for now we don't support it through the GET + // this will be changed when we implement the GET SSE connection if (relatedRequestId === undefined) { throw new Error("relatedRequestId is required for Streamable HTTP transport"); } From c275a0d24eaed78eba86f013c5e5463ff4ce882a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 16:27:28 +0100 Subject: [PATCH 16/60] remove session validation in handleUnsupportedRequest --- src/server/streamableHttp.test.ts | 19 +++++++++++++++---- src/server/streamableHttp.ts | 3 --- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 981ae17b..6062a6c9 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -39,12 +39,19 @@ function createMockResponse(): jest.Mocked { describe("StreamableHTTPServerTransport", () => { let transport: StreamableHTTPServerTransport; let mockResponse: jest.Mocked; + let mockRequest: string; beforeEach(() => { transport = new StreamableHTTPServerTransport({ sessionId: randomUUID(), }); mockResponse = createMockResponse(); + mockRequest = JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: {}, + id: 1, + }); }); afterEach(() => { @@ -90,11 +97,13 @@ describe("StreamableHTTPServerTransport", () => { it("should reject invalid session ID", async () => { const req = createMockRequest({ - method: "GET", + method: "POST", headers: { "mcp-session-id": "invalid-session-id", - "accept": "application/json, text/event-stream" + "accept": "application/json, text/event-stream", + "content-type": "application/json", }, + body: mockRequest, }); await transport.handleRequest(req, mockResponse); @@ -107,11 +116,13 @@ describe("StreamableHTTPServerTransport", () => { it("should reject non-initialization requests without session ID with 400 Bad Request", async () => { const req = createMockRequest({ - method: "GET", + method: "POST", headers: { - accept: "application/json, text/event-stream", + "accept": "application/json, text/event-stream", + "content-type": "application/json", // No mcp-session-id header }, + body: mockRequest }); await transport.handleRequest(req, mockResponse); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index a58370f1..d0d75a65 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -98,9 +98,6 @@ export class StreamableHTTPServerTransport implements Transport { * For now we support only POST and DELETE requests. Support for GET for SSE connections will be added later. */ private async handleUnsupportedRequest(req: IncomingMessage, res: ServerResponse): Promise { - if (this._sessionId !== undefined && !this.validateSession(req, res)) { - return; - } res.writeHead(405, { "Allow": "POST, DELETE" From f29cbe7e06153092dc8afdfd37169a2553b51dfb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 16:49:46 +0100 Subject: [PATCH 17/60] reject session as an array --- src/server/streamableHttp.ts | 39 +++++++++++++++++++++++++++++------- src/shared/protocol.ts | 8 ++++---- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index d0d75a65..8ad821de 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -89,7 +89,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { - await this.handleUnsupportedRequest(req, res); + await this.handleUnsupportedRequest(res); } } @@ -97,8 +97,7 @@ export class StreamableHTTPServerTransport implements Transport { * Handles unsupported requests (GET, PUT, PATCH, etc.) * For now we support only POST and DELETE requests. Support for GET for SSE connections will be added later. */ - private async handleUnsupportedRequest(req: IncomingMessage, res: ServerResponse): Promise { - + private async handleUnsupportedRequest(res: ServerResponse): Promise { res.writeHead(405, { "Allow": "POST, DELETE" }).end(JSON.stringify({ @@ -119,8 +118,7 @@ export class StreamableHTTPServerTransport implements Transport { // Validate the Accept header const acceptHeader = req.headers.accept; // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. - if (!acceptHeader || - !acceptHeader.includes("application/json") || !acceptHeader.includes("text/event-stream")) { + if (!acceptHeader?.includes("application/json") || !acceptHeader.includes("text/event-stream")) { res.writeHead(406).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -172,6 +170,17 @@ export class StreamableHTTPServerTransport implements Transport { msg => 'method' in msg && msg.method === 'initialize' ); if (isInitializationRequest) { + if (messages.length > 1) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32600, + message: "Invalid Request: Only one initialization request is allowed" + }, + id: null + })); + return; + } const headers: Record = {}; if (this._sessionId !== undefined) { @@ -280,7 +289,18 @@ export class StreamableHTTPServerTransport implements Transport { id: null })); return false; - } else if ((Array.isArray(sessionId) ? sessionId[0] : sessionId) !== this._sessionId) { + } else if (Array.isArray(sessionId)) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Mcp-Session-Id header must be a single value" + }, + id: null + })); + return false; + } + else if (sessionId !== this._sessionId) { // Reject requests with invalid session ID with 404 Not Found res.writeHead(404).end(JSON.stringify({ jsonrpc: "2.0", @@ -331,7 +351,12 @@ export class StreamableHTTPServerTransport implements Transport { // This is a response to the original request, we can close the stream // after sending all related responses this._sseResponseMapping.delete(relatedRequestId); - sseResponse.end(); + + // Only close the connection if it's not needed by other requests + const canCloseConnection = ![...this._sseResponseMapping.entries()].some(([id, res]) => res === sseResponse && id !== relatedRequestId); + if (canCloseConnection) { + sseResponse.end(); + } } } } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 0a760fec..b072e578 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -111,10 +111,10 @@ export type RequestHandlerExtra Promise; /** From 88dc565f372fa8fbcdd7c692708272b9f6639429 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Mon, 7 Apr 2025 17:29:49 +0100 Subject: [PATCH 18/60] pass sessionIdGenerator instead of sessionId --- src/server/streamableHttp.test.ts | 524 +++++++++++++++++++++++++----- src/server/streamableHttp.ts | 61 +++- 2 files changed, 490 insertions(+), 95 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 6062a6c9..aff9e511 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -43,7 +43,7 @@ describe("StreamableHTTPServerTransport", () => { beforeEach(() => { transport = new StreamableHTTPServerTransport({ - sessionId: randomUUID(), + sessionIdGenerator: () => randomUUID(), }); mockResponse = createMockResponse(); mockRequest = JSON.stringify({ @@ -59,13 +59,7 @@ describe("StreamableHTTPServerTransport", () => { }); describe("Session Management", () => { - it("should store a valid session ID", () => { - expect(transport.sessionId).toBeTruthy(); - expect(typeof transport.sessionId).toBe("string"); - }); - - it("should include session ID in response headers", async () => { - // Use POST with initialize method to avoid session ID requirement + it("should generate session ID during initialization", async () => { const initializeMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "initialize", @@ -85,8 +79,13 @@ describe("StreamableHTTPServerTransport", () => { 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({ @@ -95,7 +94,122 @@ describe("StreamableHTTPServerTransport", () => { ); }); + 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" + }, + id: "init-2", + }; + + const req2 = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + body: JSON.stringify(initMessage2), + }); + + await transport.handleRequest(req2, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(400); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Server already initialized"')); + }); + + 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" + }, + 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: { @@ -109,12 +223,35 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, mockResponse); expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - // check if the error response is a valid JSON-RPC error format expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"error"')); + 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: { @@ -131,17 +268,103 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header is required"')); }); - }); - describe("Stateless Mode", () => { - let statelessTransport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - beforeEach(() => { - statelessTransport = new StreamableHTTPServerTransport({ sessionId: undefined }); - mockResponse = createMockResponse(); + 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 not include session ID in response headers when in stateless mode", async () => { + 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", @@ -159,7 +382,7 @@ describe("StreamableHTTPServerTransport", () => { body: JSON.stringify(message), }); - await statelessTransport.handleRequest(req, mockResponse); + await transportWithoutState.handleRequest(req, mockResponse); expect(mockResponse.writeHead).toHaveBeenCalled(); // Extract the headers from writeHead call @@ -167,13 +390,13 @@ describe("StreamableHTTPServerTransport", () => { expect(headers).not.toHaveProperty("mcp-session-id"); }); - it("should not validate session ID in stateless mode", async () => { + 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 stateful mode + "mcp-session-id": "invalid-session-id", // This would cause a 404 in mode with state management }, body: JSON.stringify({ jsonrpc: "2.0", @@ -183,7 +406,7 @@ describe("StreamableHTTPServerTransport", () => { }), }); - await statelessTransport.handleRequest(req, mockResponse); + await transportWithoutState.handleRequest(req, mockResponse); // Should still get 200 OK, not 404 Not Found expect(mockResponse.writeHead).toHaveBeenCalledWith( @@ -194,7 +417,7 @@ describe("StreamableHTTPServerTransport", () => { ); }); - it("should handle POST requests without session validation in stateless mode", async () => { + it("should handle POST requests without session validation in mode without state management", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", method: "test", @@ -207,21 +430,21 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "application/json", "accept": "application/json, text/event-stream", - "mcp-session-id": "non-existent-session-id", // This would be rejected in stateful mode + "mcp-session-id": "non-existent-session-id", // This would be rejected in mode with state management }, body: JSON.stringify(message), }); const onMessageMock = jest.fn(); - statelessTransport.onmessage = onMessageMock; + transportWithoutState.onmessage = onMessageMock; - await statelessTransport.handleRequest(req, mockResponse); + 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 stateless mode", async () => { + 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", @@ -237,7 +460,7 @@ describe("StreamableHTTPServerTransport", () => { }) }); - await statelessTransport.handleRequest(req1, mockResponse); + await transportWithoutState.handleRequest(req1, mockResponse); expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ @@ -248,7 +471,7 @@ describe("StreamableHTTPServerTransport", () => { // Reset mock for second request mockResponse.writeHead.mockClear(); - // Second request with a session ID (which would be invalid in stateful mode) + // Second request with a session ID (which would be invalid in mode with state management) const req2 = createMockRequest({ method: "POST", headers: { @@ -264,7 +487,7 @@ describe("StreamableHTTPServerTransport", () => { }) }); - await statelessTransport.handleRequest(req2, mockResponse); + await transportWithoutState.handleRequest(req2, mockResponse); // Should still succeed expect(mockResponse.writeHead).toHaveBeenCalledWith( @@ -275,8 +498,10 @@ describe("StreamableHTTPServerTransport", () => { ); }); - it("should handle initialization requests properly in stateful mode", async () => { - // Initialize message that would typically be sent during initialization + 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", @@ -287,8 +512,10 @@ describe("StreamableHTTPServerTransport", () => { id: "init-1", }; - // Test stateful transport (default) - const statefulReq = createMockRequest({ + expect(transportWithoutState.sessionId).toBeUndefined(); + expect(transportWithoutState["_initialized"]).toBe(false); + + const req = createMockRequest({ method: "POST", headers: { "content-type": "application/json", @@ -297,49 +524,46 @@ describe("StreamableHTTPServerTransport", () => { body: JSON.stringify(initializeMessage), }); - await transport.handleRequest(statefulReq, mockResponse); + const newResponse = createMockResponse(); + await transportWithoutState.handleRequest(req, newResponse); - // In stateful mode, session ID should be included in the response header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "mcp-session-id": transport.sessionId, - }) - ); - }); + // After initialization, the sessionId should still be undefined + expect(transportWithoutState.sessionId).toBeUndefined(); + expect(transportWithoutState["_initialized"]).toBe(true); - it("should handle initialization requests properly in stateless mode", async () => { - // Initialize message that would typically be sent during initialization - const initializeMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - // Test stateless transport - const statelessReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - await statelessTransport.handleRequest(statelessReq, mockResponse); - - // In stateless mode, session ID should also be included for initialize responses - const headers = mockResponse.writeHead.mock.calls[0][1]; + // 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"); - }); }); 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", + }; + + 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 reject GET requests for SSE with 405 Method Not Allowed", async () => { const req = createMockRequest({ method: "GET", @@ -361,7 +585,7 @@ describe("StreamableHTTPServerTransport", () => { it("should reject POST requests without proper Accept header", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", - method: "initialize", // Use initialize to bypass session ID check + method: "test", params: {}, id: 1, }; @@ -370,6 +594,7 @@ describe("StreamableHTTPServerTransport", () => { method: "POST", headers: { "content-type": "application/json", + "mcp-session-id": transport.sessionId, }, body: JSON.stringify(message), }); @@ -382,7 +607,7 @@ describe("StreamableHTTPServerTransport", () => { it("should properly handle JSON-RPC request messages in POST requests", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", - method: "initialize", // Use initialize to bypass session ID check + method: "test", params: {}, id: 1, }; @@ -392,6 +617,7 @@ describe("StreamableHTTPServerTransport", () => { headers: { "content-type": "application/json", "accept": "application/json, text/event-stream", + "mcp-session-id": transport.sessionId, }, body: JSON.stringify(message), }); @@ -405,7 +631,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "mcp-session-id": transport.sessionId, + "Content-Type": "text/event-stream", }) ); }); @@ -480,6 +706,7 @@ describe("StreamableHTTPServerTransport", () => { const onMessageMock = jest.fn(); transport.onmessage = onMessageMock; + mockResponse = createMockResponse(); // Create fresh mock await transport.handleRequest(req, mockResponse); // Should establish SSE connection @@ -512,6 +739,30 @@ describe("StreamableHTTPServerTransport", () => { }); 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: { @@ -527,9 +778,75 @@ describe("StreamableHTTPServerTransport", () => { 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(); + }); }); 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", + }; + + 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 as SSE events", async () => { // Setup a POST request with JSON-RPC request that accepts SSE const requestMessage: JSONRPCMessage = { @@ -578,6 +895,9 @@ describe("StreamableHTTPServerTransport", () => { id: "test-req-id" }; + // Create fresh response for this test + mockResponse = createMockResponse(); + const req = createMockRequest({ method: "POST", headers: { @@ -617,6 +937,31 @@ describe("StreamableHTTPServerTransport", () => { }); 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 = { @@ -697,7 +1042,7 @@ describe("StreamableHTTPServerTransport", () => { }); describe("Error Handling", () => { - it("should handle invalid JSON data", async () => { + it("should return 400 error for invalid JSON data", async () => { const req = createMockRequest({ method: "POST", headers: { @@ -718,7 +1063,7 @@ describe("StreamableHTTPServerTransport", () => { expect(onErrorMock).toHaveBeenCalled(); }); - it("should handle invalid JSON-RPC messages", async () => { + it("should return 400 error for invalid JSON-RPC messages", async () => { const req = createMockRequest({ method: "POST", headers: { @@ -740,14 +1085,36 @@ describe("StreamableHTTPServerTransport", () => { }); describe("Handling Pre-Parsed Body", () => { - it("should accept pre-parsed request body", async () => { - const message: JSONRPCMessage = { + beforeEach(async () => { + // Initialize the transport + const initMessage: JSONRPCMessage = { jsonrpc: "2.0", method: "initialize", params: { clientInfo: { name: "test-client", version: "1.0" }, protocolVersion: "2025-03-26" }, + id: "init-1", + }; + + const initReq = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + }, + body: JSON.stringify(initMessage), + }); + + await transport.handleRequest(initReq, mockResponse); + mockResponse.writeHead.mockClear(); + }); + + it("should accept pre-parsed request body", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, id: "pre-parsed-test", }; @@ -757,6 +1124,7 @@ describe("StreamableHTTPServerTransport", () => { 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 }); @@ -772,7 +1140,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, expect.objectContaining({ - "mcp-session-id": transport.sessionId, + "Content-Type": "text/event-stream", }) ); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 8ad821de..34b4fd95 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -11,11 +11,12 @@ const MAXIMUM_MESSAGE_SIZE = "4mb"; */ export interface StreamableHTTPServerTransportOptions { /** + * Function that generates a session ID for the transport. * The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash) * - * When there is no sessionId, the transport will not perform session management. + * Return undefined to disable session management. */ - sessionId: string | undefined; + sessionIdGenerator: () => string | undefined; @@ -57,16 +58,18 @@ export interface StreamableHTTPServerTransportOptions { */ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode - private _sessionId: string | undefined; + private sessionIdGenerator: () => string | undefined; private _started: boolean = false; private _sseResponseMapping: Map = new Map(); + private _initialized: boolean = false; + sessionId?: string | undefined; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; constructor(options: StreamableHTTPServerTransportOptions) { - this._sessionId = options.sessionId; + this.sessionIdGenerator = options.sessionIdGenerator; } /** @@ -170,6 +173,17 @@ export class StreamableHTTPServerTransport implements Transport { msg => 'method' in msg && msg.method === 'initialize' ); if (isInitializationRequest) { + if (this._initialized) { + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32600, + message: "Invalid Request: Server already initialized" + }, + id: null + })); + return; + } if (messages.length > 1) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", @@ -181,10 +195,12 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } + this.sessionId = this.sessionIdGenerator(); + this._initialized = true; const headers: Record = {}; - if (this._sessionId !== undefined) { - headers["mcp-session-id"] = this._sessionId; + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; } // Process initialization messages before responding @@ -198,7 +214,7 @@ export class StreamableHTTPServerTransport implements Transport { // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it // in the Mcp-Session-Id header on all of their subsequent HTTP requests. - if (this._sessionId !== undefined && !isInitializationRequest && !this.validateSession(req, res)) { + if (!isInitializationRequest && !this.validateSession(req, res)) { return; } @@ -224,8 +240,8 @@ export class StreamableHTTPServerTransport implements Transport { }; // After initialization, always include the session ID if we have one - if (this._sessionId !== undefined) { - headers["mcp-session-id"] = this._sessionId; + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; } res.writeHead(200, headers); @@ -272,10 +288,27 @@ export class StreamableHTTPServerTransport implements Transport { } /** - * Validates session ID for non-initialization requests when session management is enabled + * Validates session ID for non-initialization requests * Returns true if the session is valid, false otherwise */ private validateSession(req: IncomingMessage, res: ServerResponse): boolean { + if (!this._initialized) { + // If the server has not been initialized yet, reject all requests + res.writeHead(400).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Server not initialized" + }, + id: null + })); + return false; + } + if (this.sessionId === undefined) { + // If the session ID is not set, the session management is disabled + // and we don't need to validate the session ID + return true; + } const sessionId = req.headers["mcp-session-id"]; if (!sessionId) { @@ -300,7 +333,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return false; } - else if (sessionId !== this._sessionId) { + else if (sessionId !== this.sessionId) { // Reject requests with invalid session ID with 404 Not Found res.writeHead(404).end(JSON.stringify({ jsonrpc: "2.0", @@ -361,10 +394,4 @@ export class StreamableHTTPServerTransport implements Transport { } } - /** - * Returns the session ID for this transport - */ - get sessionId(): string | undefined { - return this._sessionId; - } } \ No newline at end of file From 640ae5a863938b849176caf02c914d3661f3847f Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 09:43:22 +0100 Subject: [PATCH 19/60] initial client implementation --- src/client/streamableHttp.test.ts | 194 +++++++++++++++++ src/client/streamableHttp.ts | 351 ++++++++++++++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 src/client/streamableHttp.test.ts create mode 100644 src/client/streamableHttp.ts diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts new file mode 100644 index 00000000..e4ba7b1d --- /dev/null +++ b/src/client/streamableHttp.test.ts @@ -0,0 +1,194 @@ +import { StreamableHTTPClientTransport } from "./streamableHttp.js"; +import { JSONRPCMessage } from "../types.js"; + + +describe("StreamableHTTPClientTransport", () => { + let transport: StreamableHTTPClientTransport; + + beforeEach(() => { + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + jest.spyOn(global, "fetch"); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should send JSON-RPC messages via POST", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers(), + }); + + await transport.send(message); + + expect(global.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + body: JSON.stringify(message) + }) + ); + }); + + it("should send batch messages", async () => { + const messages: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "test1", params: {}, id: "id1" }, + { jsonrpc: "2.0", method: "test2", params: {}, id: "id2" } + ]; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: null + }); + + await transport.send(messages); + + expect(global.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + body: JSON.stringify(messages) + }) + ); + }); + + it("should store session ID received during initialization", async () => { + 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({ "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + + // Send a second message that should include the session ID + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + + // Check that second request included session ID header + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].headers).toBeDefined(); + expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); + }); + + it("should handle 404 response when session expires", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + text: () => Promise.resolve("Session not found"), + headers: new Headers() + }); + + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + + await expect(transport.send(message)).rejects.toThrow("Error POSTing to endpoint (HTTP 404)"); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("should handle session termination via DELETE request", async () => { + // First set the session ID by mocking initialization + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "mcp-session-id": "session-to-terminate" }), + }); + + await transport.send({ + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + } as JSONRPCMessage); + + // Mock DELETE request for session termination + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers() + }); + + const closeSpy = jest.fn(); + transport.onclose = closeSpy; + + await transport.close(); + + // Check that DELETE request was sent + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("DELETE"); + // The headers may be a plain object in tests + expect(lastCall[1].headers["mcp-session-id"]).toBe("session-to-terminate"); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it("should handle non-streaming JSON response", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + const responseMessage: JSONRPCMessage = { + jsonrpc: "2.0", + result: { success: true }, + id: "test-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => Promise.resolve(responseMessage) + }); + + const messageSpy = jest.fn(); + transport.onmessage = messageSpy; + + await transport.send(message); + + expect(messageSpy).toHaveBeenCalledWith(responseMessage); + }); +}); \ No newline at end of file diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts new file mode 100644 index 00000000..ffe1b5b4 --- /dev/null +++ b/src/client/streamableHttp.ts @@ -0,0 +1,351 @@ +import { Transport } from "../shared/transport.js"; +import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; + +export class StreamableHTTPError extends Error { + constructor( + public readonly code: number | undefined, + message: string | undefined, + public readonly event: ErrorEvent, + ) { + super(`Streamable HTTP error: ${message}`); + } +} + +/** + * Configuration options for the `StreamableHTTPClientTransport`. + */ +export type StreamableHTTPClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * When an `authProvider` is specified and the connection is started: + * 1. The connection is attempted with any existing access token from the `authProvider`. + * 2. If the access token has expired, the `authProvider` is used to refresh the token. + * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. + * + * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection. + * + * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. + * + * `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected. + */ + authProvider?: OAuthClientProvider; + + /** + * Customizes the initial SSE request to the server (the request that begins the stream). + */ + eventSourceInit?: EventSourceInit; + + /** + * Customizes recurring POST requests to the server. + */ + requestInit?: RequestInit; +}; + +/** + * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events + * for receiving messages. + */ +export class StreamableHTTPClientTransport implements Transport { + private _eventSource?: EventSource; + private _abortController?: AbortController; + private _url: URL; + private _eventSourceInit?: EventSourceInit; + private _requestInit?: RequestInit; + private _authProvider?: OAuthClientProvider; + private _sessionId?: string; + private _lastEventId?: string; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor( + url: URL, + opts?: StreamableHTTPClientTransportOptions, + ) { + this._url = url; + this._eventSourceInit = opts?.eventSourceInit; + this._requestInit = opts?.requestInit; + this._authProvider = opts?.authProvider; + } + + private async _authThenStart(): Promise { + if (!this._authProvider) { + throw new UnauthorizedError("No auth provider"); + } + + let result: AuthResult; + try { + result = await auth(this._authProvider, { serverUrl: this._url }); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + + if (result !== "AUTHORIZED") { + throw new UnauthorizedError(); + } + + return await this._startOrAuth(); + } + + private async _commonHeaders(): Promise { + const headers: HeadersInit = {}; + if (this._authProvider) { + const tokens = await this._authProvider.tokens(); + if (tokens) { + headers["Authorization"] = `Bearer ${tokens.access_token}`; + } + } + + if (this._sessionId) { + headers["mcp-session-id"] = this._sessionId; + } + + return headers; + } + + private _startOrAuth(): Promise { + return new Promise((resolve, reject) => { + // Try to open an SSE stream with GET to listen for server messages + // This is optional according to the spec - server may not support it + + const headers: HeadersInit = { + 'Accept': 'text/event-stream' + }; + + // Include Last-Event-ID header for resumable streams + if (this._lastEventId) { + headers['last-event-id'] = this._lastEventId; + } + + fetch(this._url, { + method: 'GET', + headers + }).then(response => { + if (response.status === 405) { + // Server doesn't support GET for SSE, which is allowed by the spec + // We'll rely on SSE responses to POST requests for communication + resolve(); + return; + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + // Need to authenticate + this._authThenStart().then(resolve, reject); + return; + } + + const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`); + reject(error); + this.onerror?.(error); + return; + } + + // Successful connection, handle the SSE stream + this._handleSseStream(response.body); + resolve(); + }).catch(error => { + reject(error); + this.onerror?.(error); + }); + }); + } + + async start() { + if (this._eventSource) { + throw new Error( + "StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.", + ); + } + + return await this._startOrAuth(); + } + + /** + * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + */ + async finishAuth(authorizationCode: string): Promise { + if (!this._authProvider) { + throw new UnauthorizedError("No auth provider"); + } + + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); + if (result !== "AUTHORIZED") { + throw new UnauthorizedError("Failed to authorize"); + } + } + + async close(): Promise { + this._abortController?.abort(); + this._eventSource?.close(); + + // If we have a session ID, send a DELETE request to explicitly terminate the session + if (this._sessionId) { + try { + const commonHeaders = await this._commonHeaders(); + const response = await fetch(this._url, { + method: "DELETE", + headers: commonHeaders, + signal: this._abortController?.signal, + }); + + if (!response.ok) { + // Server might respond with 405 if it doesn't support explicit session termination + // We don't throw an error in that case + if (response.status !== 405) { + const text = await response.text().catch(() => null); + throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`); + } + } + } catch (error) { + // We still want to invoke onclose even if the session termination fails + this.onerror?.(error as Error); + } + } + + this.onclose?.(); + } + + async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + try { + const commonHeaders = await this._commonHeaders(); + const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + headers.set("content-type", "application/json"); + headers.set("accept", "application/json, text/event-stream"); + + const init = { + ...this._requestInit, + method: "POST", + headers, + body: JSON.stringify(message), + signal: this._abortController?.signal, + }; + + const response = await fetch(this._url, init); + + // Handle session ID received during initialization + const sessionId = response.headers.get("mcp-session-id"); + if (sessionId) { + this._sessionId = sessionId; + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + const result = await auth(this._authProvider, { serverUrl: this._url }); + if (result !== "AUTHORIZED") { + throw new UnauthorizedError(); + } + + // Purposely _not_ awaited, so we don't call onerror twice + return this.send(message); + } + + const text = await response.text().catch(() => null); + throw new Error( + `Error POSTing to endpoint (HTTP ${response.status}): ${text}`, + ); + } + + // If the response is 202 Accepted, there's no body to process + if (response.status === 202) { + return; + } + + // Determine if the message contains any requests + const containsRequests = Array.isArray(message) + ? message.some(msg => 'method' in msg && 'id' in msg) + : ('method' in message && 'id' in message); + + if (containsRequests) { + // Check if we got text/event-stream in response - if so, handle SSE stream + const contentType = response.headers.get("content-type"); + if (contentType?.includes("text/event-stream")) { + // Handle SSE stream to receive responses + this._handleSseStream(response.body); + } else if (contentType?.includes("application/json")) { + // For non-streaming servers, we might get direct JSON responses + const data = await response.json(); + const responseMessages = Array.isArray(data) + ? data.map(msg => JSONRPCMessageSchema.parse(msg)) + : [JSONRPCMessageSchema.parse(data)]; + + for (const msg of responseMessages) { + this.onmessage?.(msg); + } + } + } + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + private _handleSseStream(stream: ReadableStream | null): void { + if (!stream) { + return; + } + + // Set up stream handling for server-sent events + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process SSE messages in the buffer + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; + + for (const event of events) { + const lines = event.split('\n'); + let id: string | undefined; + let eventType: string | undefined; + let data: string | undefined; + + // Parse SSE message according to the format + for (const line of lines) { + if (line.startsWith('id:')) { + id = line.slice(3).trim(); + } else if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + data = line.slice(5).trim(); + } + } + + // Update last event ID if provided by server + if (id) { + this._lastEventId = id; + } + + // Handle message event + if (eventType === 'message' && data) { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(data)); + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + } + } catch (error) { + this.onerror?.(error as Error); + } + }; + + processStream(); + } +} \ No newline at end of file From c3b446d4245d706cb54ebb04322a9468a1342705 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 11:11:09 +0100 Subject: [PATCH 20/60] multiple streams and initial stream with get --- src/client/streamableHttp.test.ts | 154 +++++++++++++++++++++++++++++- src/client/streamableHttp.ts | 153 ++++++++++++++++------------- 2 files changed, 239 insertions(+), 68 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index e4ba7b1d..ef35cacc 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -10,7 +10,8 @@ describe("StreamableHTTPClientTransport", () => { jest.spyOn(global, "fetch"); }); - afterEach(() => { + afterEach(async () => { + await transport.close().catch(() => { }); jest.clearAllMocks(); }); @@ -191,4 +192,155 @@ describe("StreamableHTTPClientTransport", () => { expect(messageSpy).toHaveBeenCalledWith(responseMessage); }); + + it("should attempt initial GET connection and handle 405 gracefully", async () => { + // Mock the server not supporting GET for SSE (returning 405) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: "Method Not Allowed" + }); + + await transport.start(); + + // Check that GET was attempted + expect(global.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: "GET", + headers: expect.any(Headers) + }) + ); + + // Verify transport still works after 405 + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("should handle successful initial GET connection for SSE", async () => { + // Set up readable stream for SSE events + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send a server notification via SSE + const event = 'event: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n'; + controller.enqueue(encoder.encode(event)); + } + }); + + // Mock successful GET connection + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: stream + }); + + const messageSpy = jest.fn(); + transport.onmessage = messageSpy; + + await transport.start(); + + // Give time for the SSE event to be processed + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: "2.0", + method: "serverNotification", + params: {} + }) + ); + }); + + it("should handle multiple concurrent SSE streams", async () => { + // Mock two POST requests that return SSE streams + const makeStream = (id: string) => { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + const event = `event: message\ndata: {"jsonrpc": "2.0", "result": {"id": "${id}"}, "id": "${id}"}\n\n`; + controller.enqueue(encoder.encode(event)); + } + }); + }; + + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: makeStream("request1") + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: makeStream("request2") + }); + + const messageSpy = jest.fn(); + transport.onmessage = messageSpy; + + // Send two concurrent requests + await Promise.all([ + transport.send({ jsonrpc: "2.0", method: "test1", params: {}, id: "request1" }), + transport.send({ jsonrpc: "2.0", method: "test2", params: {}, id: "request2" }) + ]); + + // Give time for SSE processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Both streams should have delivered their messages + expect(messageSpy).toHaveBeenCalledTimes(2); + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ result: { id: "request1" }, id: "request1" }) + ); + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ result: { id: "request2" }, id: "request2" }) + ); + }); + + it("should include last-event-id header when resuming a broken connection", async () => { + // First make a successful connection that provides an event ID + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const event = 'id: event-123\nevent: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n'; + controller.enqueue(encoder.encode(event)); + controller.close(); + } + }); + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: stream + }); + + await transport.start(); + await new Promise(resolve => setTimeout(resolve, 50)); + + // Now simulate attempting to reconnect + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body: null + }); + + await transport.start(); + + // Check that Last-Event-ID was included + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); + }); }); \ No newline at end of file diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ffe1b5b4..9fa65c85 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,7 +1,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; +import { type ErrorEvent } from "eventsource"; export class StreamableHTTPError extends Error { constructor( @@ -34,12 +34,7 @@ export type StreamableHTTPClientTransportOptions = { authProvider?: OAuthClientProvider; /** - * Customizes the initial SSE request to the server (the request that begins the stream). - */ - eventSourceInit?: EventSourceInit; - - /** - * Customizes recurring POST requests to the server. + * Customizes HTTP requests to the server. */ requestInit?: RequestInit; }; @@ -50,10 +45,9 @@ export type StreamableHTTPClientTransportOptions = { * for receiving messages. */ export class StreamableHTTPClientTransport implements Transport { - private _eventSource?: EventSource; + private _activeStreams: Map> = new Map(); private _abortController?: AbortController; private _url: URL; - private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; @@ -68,7 +62,6 @@ export class StreamableHTTPClientTransport implements Transport { opts?: StreamableHTTPClientTransportOptions, ) { this._url = url; - this._eventSourceInit = opts?.eventSourceInit; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; } @@ -109,61 +102,59 @@ export class StreamableHTTPClientTransport implements Transport { return headers; } - private _startOrAuth(): Promise { - return new Promise((resolve, reject) => { - // Try to open an SSE stream with GET to listen for server messages + private async _startOrAuth(): 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: HeadersInit = { - 'Accept': 'text/event-stream' - }; + const commonHeaders = await this._commonHeaders(); + const headers = new Headers(commonHeaders); + headers.set('Accept', 'text/event-stream'); // Include Last-Event-ID header for resumable streams if (this._lastEventId) { - headers['last-event-id'] = this._lastEventId; + headers.set('last-event-id', this._lastEventId); } - fetch(this._url, { + const response = await fetch(this._url, { method: 'GET', - headers - }).then(response => { - if (response.status === 405) { - // Server doesn't support GET for SSE, which is allowed by the spec - // We'll rely on SSE responses to POST requests for communication - resolve(); - return; - } - - if (!response.ok) { - if (response.status === 401 && this._authProvider) { - // Need to authenticate - this._authThenStart().then(resolve, reject); - return; - } - - const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`); - reject(error); - this.onerror?.(error); - return; - } - - // Successful connection, handle the SSE stream - this._handleSseStream(response.body); - resolve(); - }).catch(error => { - reject(error); - this.onerror?.(error); + headers, + signal: this._abortController?.signal, }); - }); + + if (response.status === 405) { + // Server doesn't support GET for SSE, which is allowed by the spec + // We'll rely on SSE responses to POST requests for communication + return; + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + // Need to authenticate + return await this._authThenStart(); + } + + const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`); + this.onerror?.(error); + throw error; + } + + // Successful connection, handle the SSE stream as a standalone listener + const streamId = `initial-${Date.now()}`; + this._handleSseStream(response.body, streamId); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } } async start() { - if (this._eventSource) { + if (this._activeStreams.size > 0) { throw new Error( "StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.", ); } + this._abortController = new AbortController(); return await this._startOrAuth(); } @@ -182,8 +173,18 @@ export class StreamableHTTPClientTransport implements Transport { } async close(): Promise { + // Close all active streams + for (const reader of this._activeStreams.values()) { + try { + reader.cancel(); + } catch (error) { + this.onerror?.(error as Error); + } + } + this._activeStreams.clear(); + + // Abort any pending requests this._abortController?.abort(); - this._eventSource?.close(); // If we have a session ID, send a DELETE request to explicitly terminate the session if (this._sessionId) { @@ -257,17 +258,25 @@ export class StreamableHTTPClientTransport implements Transport { return; } - // Determine if the message contains any requests - const containsRequests = Array.isArray(message) - ? message.some(msg => 'method' in msg && 'id' in msg) - : ('method' in message && 'id' in message); + // Get original message(s) for detecting request IDs + const messages = Array.isArray(message) ? message : [message]; - if (containsRequests) { - // Check if we got text/event-stream in response - if so, handle SSE stream - const contentType = response.headers.get("content-type"); + // Extract IDs from request messages for tracking responses + const requestIds = messages.filter(msg => 'method' in msg && 'id' in msg) + .map(msg => 'id' in msg ? msg.id : undefined) + .filter(id => id !== undefined); + + // If we have request IDs and an SSE response, create a unique stream ID + const hasRequests = requestIds.length > 0; + + // Check the response type + const contentType = response.headers.get("content-type"); + + if (hasRequests) { if (contentType?.includes("text/event-stream")) { - // Handle SSE stream to receive responses - this._handleSseStream(response.body); + // For streaming responses, create a unique stream ID based on request IDs + const streamId = `req-${requestIds.join('-')}-${Date.now()}`; + this._handleSseStream(response.body, streamId); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -286,13 +295,14 @@ export class StreamableHTTPClientTransport implements Transport { } } - private _handleSseStream(stream: ReadableStream | null): void { + private _handleSseStream(stream: ReadableStream | null, streamId: string): void { if (!stream) { return; } // Set up stream handling for server-sent events const reader = stream.getReader(); + this._activeStreams.set(streamId, reader); const decoder = new TextDecoder(); let buffer = ''; @@ -300,7 +310,11 @@ export class StreamableHTTPClientTransport implements Transport { try { while (true) { const { done, value } = await reader.read(); - if (done) break; + if (done) { + // Stream closed by server + this._activeStreams.delete(streamId); + break; + } buffer += decoder.decode(value, { stream: true }); @@ -326,22 +340,27 @@ export class StreamableHTTPClientTransport implements Transport { } // Update last event ID if provided by server + // As per spec: the ID MUST be globally unique across all streams within that session if (id) { this._lastEventId = id; } // Handle message event - if (eventType === 'message' && data) { - try { - const message = JSONRPCMessageSchema.parse(JSON.parse(data)); - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); + if (data) { + // Default event type is 'message' per SSE spec if not specified + if (!eventType || eventType === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(data)); + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } } } } } } catch (error) { + this._activeStreams.delete(streamId); this.onerror?.(error as Error); } }; From 389088e6677a93ee18ea60485a3aa12f772507dc Mon Sep 17 00:00:00 2001 From: akazwz Date: Tue, 8 Apr 2025 19:41:55 +0800 Subject: [PATCH 21/60] Improve URL parameter handling in SSE using URL/URLSearchParams APIs --- src/server/sse.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/server/sse.ts b/src/server/sse.ts index 8aa01928..701b9e91 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 { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -49,17 +50,17 @@ export class SSEServerTransport implements Transport { }); // Send the endpoint event - /** - * Determine the appropriate separator for adding the sessionId parameter. - * Uses '&' if the endpoint already contains a query parameter (has a '?'), - * otherwise uses '?' to start the query string. - * - * Note: This approach works for standard endpoints but doesn't handle - * the edge case where '?' might be part of the path itself. - */ - const separator = this._endpoint.includes('?') ? '&' : '?'; + // Use a dummy base URL because this._endpoint is relative. + // This allows using URL/URLSearchParams for robust parameter handling. + const dummyBase = 'http://localhost'; // Any valid base works + const endpointUrl = new URL(this._endpoint, dummyBase); + endpointUrl.searchParams.set('sessionId', this._sessionId); + + // Reconstruct the relative URL string (pathname + search + hash) + const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; + this.res.write( - `event: endpoint\ndata: ${encodeURI(this._endpoint)}${separator}sessionId=${this._sessionId}\n\n`, + `event: endpoint\ndata: ${relativeUrlWithSession}\n\n`, ); this._sseResponse = this.res; From 7c4407144cc4fc928864f0a224365fcd3a3f7313 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 13:29:03 +0100 Subject: [PATCH 22/60] fix closing connection when there is response --- src/server/streamableHttp.test.ts | 130 ++++++++++++++++++++++++++++++ src/server/streamableHttp.ts | 49 ++++++----- 2 files changed, 159 insertions(+), 20 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index aff9e511..26f8b6da 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -934,6 +934,136 @@ describe("StreamableHTTPServerTransport", () => { // Now stream should be closed expect(mockResponse.end).toHaveBeenCalled(); }); + + it("should keep stream open when multiple requests share the same connection", async () => { + // Create a fresh response for this test + const sharedResponse = createMockResponse(); + + // 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" } + ]; + + 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(); + }); + + 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" + }) + }); + + const response = createMockResponse(); + await transport.handleRequest(req, response); + + // Verify that the request is tracked in the SSE map + expect(transport["_sseResponseMapping"].size).toBe(1); + expect(transport["_sseResponseMapping"].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["_sseResponseMapping"].size).toBe(0); + expect(transport["_sseResponseMapping"].has("cleanup-test")).toBe(false); + }); + + 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" } + ]) + }); + + const response = createMockResponse(); + + // 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; + } + return response; + }); + + await transport.handleRequest(req, response); + + // Both requests should be mapped to the same response + expect(transport["_sseResponseMapping"].size).toBe(2); + expect(transport["_sseResponseMapping"].get("req1")).toBe(response); + expect(transport["_sseResponseMapping"].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["_sseResponseMapping"].size).toBe(0); + expect(transport["_sseResponseMapping"].has("req1")).toBe(false); + expect(transport["_sseResponseMapping"].has("req2")).toBe(false); + }); }); describe("Message Targeting", () => { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 34b4fd95..a6e14801 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -254,6 +254,16 @@ export class StreamableHTTPServerTransport implements Transport { } } + // Set up close handler for client disconnects + res.on("close", () => { + // Remove all entries that reference this response + for (const [id, storedRes] of this._sseResponseMapping.entries()) { + if (storedRes === res) { + this._sseResponseMapping.delete(id); + } + } + }); + // handle each message for (const message of messages) { this.onmessage?.(message); @@ -360,16 +370,22 @@ export class StreamableHTTPServerTransport implements Transport { } async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { - const relatedRequestId = options?.relatedRequestId; - // SSE connections are established per POST request, for now we don't support it through the GET - // this will be changed when we implement the GET SSE connection - if (relatedRequestId === undefined) { - throw new Error("relatedRequestId is required for Streamable HTTP transport"); + let requestId = options?.relatedRequestId; + let shouldCloseConnection = false; + if ('result' in message || 'error' in message) { + // If the message is a response, use the request ID from the message + requestId = message.id; + // This is a response to the original request, we can close the stream + // after sending all related responses + shouldCloseConnection = true; + } + if (!requestId) { + throw new Error("No request ID provided for the message"); } - const sseResponse = this._sseResponseMapping.get(relatedRequestId); + const sseResponse = this._sseResponseMapping.get(requestId); if (!sseResponse) { - throw new Error(`No SSE connection established for request ID: ${String(relatedRequestId)}`); + throw new Error(`No SSE connection established for request ID: ${String(requestId)}`); } // Send the message as an SSE event @@ -377,19 +393,12 @@ export class StreamableHTTPServerTransport implements Transport { `event: message\ndata: ${JSON.stringify(message)}\n\n`, ); - // If this is a response message with the same ID as the request, we can check - // if we need to close the stream after sending the response - if ('result' in message || 'error' in message) { - if (message.id === relatedRequestId) { - // This is a response to the original request, we can close the stream - // after sending all related responses - this._sseResponseMapping.delete(relatedRequestId); - - // Only close the connection if it's not needed by other requests - const canCloseConnection = ![...this._sseResponseMapping.entries()].some(([id, res]) => res === sseResponse && id !== relatedRequestId); - if (canCloseConnection) { - sseResponse.end(); - } + if (shouldCloseConnection) { + this._sseResponseMapping.delete(requestId); + // Only close the connection if it's not needed by other requests + const canCloseConnection = ![...this._sseResponseMapping.entries()].some(([id, res]) => res === sseResponse && id !== requestId); + if (canCloseConnection) { + sseResponse?.end(); } } } From f1827ced109c78685ca3348c9deee5d74715904a Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 15:04:07 +0100 Subject: [PATCH 23/60] handle not implemented get --- src/client/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9fa65c85..4ec89af8 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -121,7 +121,7 @@ export class StreamableHTTPClientTransport implements Transport { signal: this._abortController?.signal, }); - if (response.status === 405) { + if (response.status === 405 || response.status === 404) { // Server doesn't support GET for SSE, which is allowed by the spec // We'll rely on SSE responses to POST requests for communication return; From 4e0c2837f5f8ac492c3849e7ccc9a45a25d9f007 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 15:11:56 +0100 Subject: [PATCH 24/60] request id might be 0 --- src/server/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index a6e14801..659912cc 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -379,7 +379,7 @@ export class StreamableHTTPServerTransport implements Transport { // after sending all related responses shouldCloseConnection = true; } - if (!requestId) { + if (requestId === undefined) { throw new Error("No request ID provided for the message"); } From 84ca647d0d1e8e2f91359daeb3aaf648b8118ee1 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 15:22:29 +0100 Subject: [PATCH 25/60] init message to follow normal request path --- src/server/streamableHttp.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 659912cc..5aff0c45 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -197,19 +197,7 @@ export class StreamableHTTPServerTransport implements Transport { } this.sessionId = this.sessionIdGenerator(); this._initialized = true; - const headers: Record = {}; - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; - } - - // Process initialization messages before responding - for (const message of messages) { - this.onmessage?.(message); - } - - res.writeHead(200, headers).end(); - return; } // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it From c645ec9ad8507dede5120bab01d880f7a6958ed9 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 13:13:58 +0100 Subject: [PATCH 26/60] streamable client and server examples --- src/examples/README.md | 123 ++++++++++++++ .../client/simpleStreamableHttpClient.ts | 84 ++++++++++ src/examples/server/simpleStreamableHttp.ts | 157 ++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 src/examples/README.md create mode 100644 src/examples/client/simpleStreamableHttpClient.ts create mode 100644 src/examples/server/simpleStreamableHttp.ts diff --git a/src/examples/README.md b/src/examples/README.md new file mode 100644 index 00000000..878a2fef --- /dev/null +++ b/src/examples/README.md @@ -0,0 +1,123 @@ +# MCP TypeScript SDK Examples + +This directory contains example implementations of MCP clients and servers using the TypeScript SDK. + +## Streamable HTTP Examples + +### List Tool Request Example + +Using `curl` to list available tools: + +```bash +# First initialize the server and save the session ID to a variable +SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') +echo "Session ID: $SESSION_ID" + +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ + http://localhost:3000/mcp +``` + +Using the TypeScript client (session management is handled automatically): + +```typescript +const toolsRequest = { method: 'tools/list', params: {} }; +const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); +console.log('Available tools:', toolsResult.tools); +``` + +### Call Tool Request Example + +Using `curl` to call a tool: + +```bash +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"greet","arguments":{"name":"User"}},"id":"3"}' \ + http://localhost:3000/mcp +``` + +Using the TypeScript client: + +```typescript +const greetRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } +}; +const greetResult = await client.request(greetRequest, CallToolResultSchema); +``` + +### Get Prompt Request Example + +Using `curl` to get a prompt: + +```bash +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"greeting-template","arguments":{"name":"User"}},"id":"4"}' \ + http://localhost:3000/mcp +``` + +Using the TypeScript client: + +```typescript +const promptRequest = { + method: 'prompts/get', + params: { + name: 'greeting-template', + arguments: { name: 'MCP User' } + } +}; +const promptResult = await client.request(promptRequest, GetPromptResultSchema); +``` + +### Server (`server/simpleStreamableHttp.ts`) + +A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: + +- A simple `greet` tool that returns a greeting for a name +- A `greeting-template` prompt that generates a greeting template +- A static `greeting-resource` resource + +#### Running the server + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts +``` + +The server will start on port 3000. You can test the initialization with: + +```bash +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ + http://localhost:3000/mcp +``` + +### Client (`client/simpleStreamableHttpClient.ts`) + +A client that connects to the server, initializes it, and demonstrates how to: + +- List available tools and call the `greet` tool +- List available prompts and get the `greeting-template` prompt +- List available resources + +#### Running the client + +```bash +npx tsx src/examples/client/simpleStreamableHttpClient.ts +``` + +Make sure the server is running before starting the client. + +## Notes + +- These examples demonstrate the basic usage of the Streamable HTTP transport +- The server manages sessions for stateful connections +- The client handles both direct HTTP responses and SSE streaming responses \ No newline at end of file diff --git a/src/examples/client/simpleStreamableHttpClient.ts b/src/examples/client/simpleStreamableHttpClient.ts new file mode 100644 index 00000000..9bf43ce8 --- /dev/null +++ b/src/examples/client/simpleStreamableHttpClient.ts @@ -0,0 +1,84 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + ListPromptsRequest, + ListPromptsResultSchema, + GetPromptRequest, + GetPromptResultSchema, + ListResourcesRequest, + ListResourcesResultSchema +} from '../../types.js'; + +async function main(): Promise { + // Create a new client with streamable HTTP transport + const client = new Client({ + name: 'example-client', + version: '1.0.0' + }); + const transport = new StreamableHTTPClientTransport( + new URL('http://localhost:3000/mcp') + ); + + // Connect the client using the transport and initialize the server + await client.connect(transport); + + console.log('Connected to MCP server'); + + // List available tools + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + console.log('Available tools:', toolsResult.tools); + + // Call the 'greet' tool + const greetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } + }; + const greetResult = await client.request(greetRequest, CallToolResultSchema); + console.log('Greeting result:', greetResult.content[0].text); + + // List available prompts + const promptsRequest: ListPromptsRequest = { + method: 'prompts/list', + params: {} + }; + const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); + console.log('Available prompts:', promptsResult.prompts); + + // Get a prompt + 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); + + // List available resources + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); + console.log('Available resources:', resourcesResult.resources); + + // Close the connection + await client.close(); +} + +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/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts new file mode 100644 index 00000000..8db0e236 --- /dev/null +++ b/src/examples/server/simpleStreamableHttp.ts @@ -0,0 +1,157 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { z } from 'zod'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'simple-streamable-http-server', + version: '1.0.0', +}); + +// Register a simple tool that returns a greeting +server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + } +); + +// Register a simple prompt +server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, + }, + ], + }; + } +); + +// Create a simple resource at a fixed URI +server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } +); + +const app = express(); +app.use(express.json()); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + // Connect the transport to the MCP server 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 + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + 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, + }); + } + } +}); + +// Helper function to detect initialize requests +function isInitializeRequest(body: any): boolean { + if (Array.isArray(body)) { + return body.some(msg => msg.method === 'initialize'); + } + return body.method === 'initialize'; +} + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); + console.log(`Test with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' http://localhost:${PORT}/mcp`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + await server.close(); + process.exit(0); +}); \ No newline at end of file From 6c6df5e0111ef8973409284c17ee16c181464997 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 15:34:27 +0100 Subject: [PATCH 27/60] improve readme --- src/examples/README.md | 92 +++++++----------------------------------- 1 file changed, 14 insertions(+), 78 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 878a2fef..40898578 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -2,81 +2,9 @@ This directory contains example implementations of MCP clients and servers using the TypeScript SDK. -## Streamable HTTP Examples +## Streamable HTTP - single node deployment with basic session state management -### List Tool Request Example - -Using `curl` to list available tools: - -```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID" - -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp -``` - -Using the TypeScript client (session management is handled automatically): - -```typescript -const toolsRequest = { method: 'tools/list', params: {} }; -const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); -console.log('Available tools:', toolsResult.tools); -``` - -### Call Tool Request Example - -Using `curl` to call a tool: - -```bash -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"greet","arguments":{"name":"User"}},"id":"3"}' \ - http://localhost:3000/mcp -``` - -Using the TypeScript client: - -```typescript -const greetRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } -}; -const greetResult = await client.request(greetRequest, CallToolResultSchema); -``` - -### Get Prompt Request Example - -Using `curl` to get a prompt: - -```bash -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"greeting-template","arguments":{"name":"User"}},"id":"4"}' \ - http://localhost:3000/mcp -``` - -Using the TypeScript client: - -```typescript -const promptRequest = { - method: 'prompts/get', - params: { - name: 'greeting-template', - arguments: { name: 'MCP User' } - } -}; -const promptResult = await client.request(promptRequest, GetPromptResultSchema); -``` +Multi node with stete management example will be added soon after we add support. ### Server (`server/simpleStreamableHttp.ts`) @@ -92,11 +20,19 @@ A simple MCP server that uses the Streamable HTTP transport, implemented with Ex npx tsx src/examples/server/simpleStreamableHttp.ts ``` -The server will start on port 3000. You can test the initialization with: +The server will start on port 3000. You can test the initialization and tool listing: ```bash -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ +# First initialize the server and save the session ID to a variable +SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') +echo "Session ID: $SESSION_ID" + +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ http://localhost:3000/mcp ``` @@ -119,5 +55,5 @@ Make sure the server is running before starting the client. ## Notes - These examples demonstrate the basic usage of the Streamable HTTP transport -- The server manages sessions for stateful connections -- The client handles both direct HTTP responses and SSE streaming responses \ No newline at end of file +- The server manages sessions between the calls +- The client handles both direct HTTP responses and SSE streaming responses From cc7f8e4e1be5f41e2e98b299b9febd5e948a9605 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 16:05:28 +0100 Subject: [PATCH 28/60] fix tests after init fix --- src/server/streamableHttp.test.ts | 68 +++++++++++++++---------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 26f8b6da..bdbfb6ba 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -842,8 +842,8 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(initMessage), }); - - await transport.handleRequest(initReq, mockResponse); + const initResponse = createMockResponse(); + await transport.handleRequest(initReq, initResponse); mockResponse.writeHead.mockClear(); }); @@ -934,17 +934,17 @@ describe("StreamableHTTPServerTransport", () => { // Now stream should be closed expect(mockResponse.end).toHaveBeenCalled(); }); - + it("should keep stream open when multiple requests share the same connection", async () => { // Create a fresh response for this test const sharedResponse = createMockResponse(); - + // 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" } ]; - + const req = createMockRequest({ method: "POST", headers: { @@ -954,40 +954,40 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify(batchRequests) }); - + await transport.handleRequest(req, sharedResponse); - + // Respond to first request const response1: JSONRPCMessage = { - jsonrpc: "2.0", + 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.stringContaining(`event: message\ndata: ${JSON.stringify(response2)}\n\n`) ); expect(sharedResponse.end).toHaveBeenCalled(); }); - + it("should clean up connection tracking when a response is sent", async () => { const req = createMockRequest({ method: "POST", @@ -997,32 +997,32 @@ describe("StreamableHTTPServerTransport", () => { "mcp-session-id": transport.sessionId }, body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, + jsonrpc: "2.0", + method: "test", + params: {}, id: "cleanup-test" }) }); - + const response = createMockResponse(); await transport.handleRequest(req, response); - + // Verify that the request is tracked in the SSE map - expect(transport["_sseResponseMapping"].size).toBe(1); + expect(transport["_sseResponseMapping"].size).toBe(2); expect(transport["_sseResponseMapping"].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["_sseResponseMapping"].size).toBe(0); + expect(transport["_sseResponseMapping"].size).toBe(1); expect(transport["_sseResponseMapping"].has("cleanup-test")).toBe(false); }); - + it("should clean up connection tracking when client disconnects", async () => { // Setup two requests that share a connection const req = createMockRequest({ @@ -1034,12 +1034,12 @@ describe("StreamableHTTPServerTransport", () => { }, body: JSON.stringify([ { jsonrpc: "2.0", method: "longRunning1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "longRunning2", params: {}, id: "req2" } + { jsonrpc: "2.0", method: "longRunning2", params: {}, id: "req2" } ]) }); - + const response = createMockResponse(); - + // We need to manually store the callback to trigger it later let closeCallback: (() => void) | undefined; response.on.mockImplementation((event, callback: () => void) => { @@ -1048,19 +1048,19 @@ describe("StreamableHTTPServerTransport", () => { } return response; }); - + await transport.handleRequest(req, response); - + // Both requests should be mapped to the same response - expect(transport["_sseResponseMapping"].size).toBe(2); + expect(transport["_sseResponseMapping"].size).toBe(3); expect(transport["_sseResponseMapping"].get("req1")).toBe(response); expect(transport["_sseResponseMapping"].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["_sseResponseMapping"].size).toBe(0); + expect(transport["_sseResponseMapping"].size).toBe(1); expect(transport["_sseResponseMapping"].has("req1")).toBe(false); expect(transport["_sseResponseMapping"].has("req2")).toBe(false); }); From 161b8da3e08790120d264f8c51bf2be08d6363aa Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 16:16:51 +0100 Subject: [PATCH 29/60] fix lint and rename --- src/examples/README.md | 4 ++-- ...impleStreamableHttpClient.ts => simpleStreamableHttp.ts} | 0 src/examples/server/simpleStreamableHttp.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/examples/client/{simpleStreamableHttpClient.ts => simpleStreamableHttp.ts} (100%) diff --git a/src/examples/README.md b/src/examples/README.md index 40898578..6e53fdec 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -36,7 +36,7 @@ curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, t http://localhost:3000/mcp ``` -### Client (`client/simpleStreamableHttpClient.ts`) +### Client (`client/simpleStreamableHttp.ts`) A client that connects to the server, initializes it, and demonstrates how to: @@ -47,7 +47,7 @@ A client that connects to the server, initializes it, and demonstrates how to: #### Running the client ```bash -npx tsx src/examples/client/simpleStreamableHttpClient.ts +npx tsx src/examples/client/simpleStreamableHttp.ts ``` Make sure the server is running before starting the client. diff --git a/src/examples/client/simpleStreamableHttpClient.ts b/src/examples/client/simpleStreamableHttp.ts similarity index 100% rename from src/examples/client/simpleStreamableHttpClient.ts rename to src/examples/client/simpleStreamableHttp.ts diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 8db0e236..e6ebe4b9 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -135,11 +135,11 @@ app.post('/mcp', async (req: Request, res: Response) => { }); // Helper function to detect initialize requests -function isInitializeRequest(body: any): boolean { +function isInitializeRequest(body: unknown): boolean { if (Array.isArray(body)) { - return body.some(msg => msg.method === 'initialize'); + return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); } - return body.method === 'initialize'; + return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; } // Start the server From d4ad9764018f6e03fc25ca4d82f3d46748645295 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 21:39:49 +0100 Subject: [PATCH 30/60] use EventSourceParserStream --- src/client/streamableHttp.test.ts | 40 -------------- src/client/streamableHttp.ts | 92 +++++++------------------------ 2 files changed, 21 insertions(+), 111 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index ef35cacc..8a1b9044 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -124,46 +124,6 @@ describe("StreamableHTTPClientTransport", () => { expect(errorSpy).toHaveBeenCalled(); }); - it("should handle session termination via DELETE request", async () => { - // First set the session ID by mocking initialization - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers({ "mcp-session-id": "session-to-terminate" }), - }); - - await transport.send({ - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-id" - } as JSONRPCMessage); - - // Mock DELETE request for session termination - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers() - }); - - const closeSpy = jest.fn(); - transport.onclose = closeSpy; - - await transport.close(); - - // Check that DELETE request was sent - const calls = (global.fetch as jest.Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].method).toBe("DELETE"); - // The headers may be a plain object in tests - expect(lastCall[1].headers["mcp-session-id"]).toBe("session-to-terminate"); - - expect(closeSpy).toHaveBeenCalled(); - }); - it("should handle non-streaming JSON response", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 4ec89af8..0a0d39e7 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { type ErrorEvent } from "eventsource"; - +import { EventSourceParserStream } from 'eventsource-parser/stream'; export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -45,7 +45,7 @@ export type StreamableHTTPClientTransportOptions = { * for receiving messages. */ export class StreamableHTTPClientTransport implements Transport { - private _activeStreams: Map> = new Map(); + private _activeStreams: Map> = new Map(); private _abortController?: AbortController; private _url: URL; private _requestInit?: RequestInit; @@ -186,30 +186,6 @@ export class StreamableHTTPClientTransport implements Transport { // Abort any pending requests this._abortController?.abort(); - // If we have a session ID, send a DELETE request to explicitly terminate the session - if (this._sessionId) { - try { - const commonHeaders = await this._commonHeaders(); - const response = await fetch(this._url, { - method: "DELETE", - headers: commonHeaders, - signal: this._abortController?.signal, - }); - - if (!response.ok) { - // Server might respond with 405 if it doesn't support explicit session termination - // We don't throw an error in that case - if (response.status !== 405) { - const text = await response.text().catch(() => null); - throw new Error(`Error terminating session (HTTP ${response.status}): ${text}`); - } - } - } catch (error) { - // We still want to invoke onclose even if the session termination fails - this.onerror?.(error as Error); - } - } - this.onclose?.(); } @@ -300,62 +276,36 @@ export class StreamableHTTPClientTransport implements Transport { return; } - // Set up stream handling for server-sent events - const reader = stream.getReader(); + // Create a pipeline: binary stream -> text decoder -> SSE parser + const eventStream = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()); + + const reader = eventStream.getReader(); this._activeStreams.set(streamId, reader); - const decoder = new TextDecoder(); - let buffer = ''; const processStream = async () => { try { while (true) { - const { done, value } = await reader.read(); + const { done, value: event } = await reader.read(); if (done) { - // Stream closed by server this._activeStreams.delete(streamId); break; } - buffer += decoder.decode(value, { stream: true }); + // Update last event ID if provided + if (event.id) { + this._lastEventId = event.id; + } - // Process SSE messages in the buffer - const events = buffer.split('\n\n'); - buffer = events.pop() || ''; - - for (const event of events) { - const lines = event.split('\n'); - let id: string | undefined; - let eventType: string | undefined; - let data: string | undefined; - - // Parse SSE message according to the format - for (const line of lines) { - if (line.startsWith('id:')) { - id = line.slice(3).trim(); - } else if (line.startsWith('event:')) { - eventType = line.slice(6).trim(); - } else if (line.startsWith('data:')) { - data = line.slice(5).trim(); - } - } - - // Update last event ID if provided by server - // As per spec: the ID MUST be globally unique across all streams within that session - if (id) { - this._lastEventId = id; - } - - // Handle message event - if (data) { - // Default event type is 'message' per SSE spec if not specified - if (!eventType || eventType === 'message') { - try { - const message = JSONRPCMessageSchema.parse(JSON.parse(data)); - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); - } - } + // Handle message events (default event type is undefined per docs) + // or explicit 'message' event type + if (!event.event || event.event === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); } } } From 15c10a58833ecce01958733611d4e30a963d07ef Mon Sep 17 00:00:00 2001 From: ihrpr Date: Tue, 8 Apr 2025 22:30:15 +0100 Subject: [PATCH 31/60] fix lint --- src/client/streamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 0a0d39e7..38da5bcb 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { type ErrorEvent } from "eventsource"; -import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { EventSourceMessage, EventSourceParserStream } from 'eventsource-parser/stream'; export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -45,7 +45,7 @@ export type StreamableHTTPClientTransportOptions = { * for receiving messages. */ export class StreamableHTTPClientTransport implements Transport { - private _activeStreams: Map> = new Map(); + private _activeStreams: Map> = new Map(); private _abortController?: AbortController; private _url: URL; private _requestInit?: RequestInit; From 84447c2999126c0c996fea3a15b361cab5f4ab8b Mon Sep 17 00:00:00 2001 From: akazwz Date: Wed, 9 Apr 2025 09:25:27 +0800 Subject: [PATCH 32/60] Add tests for SSE server transport endpoint handling --- src/server/sse.test.ts | 109 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/server/sse.test.ts diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts new file mode 100644 index 00000000..28474bad --- /dev/null +++ b/src/server/sse.test.ts @@ -0,0 +1,109 @@ +import http from 'http'; +import { jest } from '@jest/globals'; +import { SSEServerTransport } from './sse.js'; + +const createMockResponse = () => { + const res = { + writeHead: jest.fn(), + write: jest.fn().mockReturnValue(true), + on: jest.fn(), + }; + res.writeHead.mockReturnThis(); + res.on.mockReturnThis(); + + return res as unknown as http.ServerResponse; +}; + +describe('SSEServerTransport', () => { + describe('start method', () => { + it('should correctly append sessionId to a simple relative endpoint', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n` + ); + }); + + it('should correctly append sessionId to an endpoint with existing query parameters', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages?foo=bar&baz=qux'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n` + ); + }); + + it('should correctly append sessionId to an endpoint with a hash fragment', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages#section1'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n` + ); + }); + + it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages?key=value#section2'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n` + ); + }); + + it('should correctly handle the root path endpoint "/"', async () => { + const mockRes = createMockResponse(); + const endpoint = '/'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` + ); + }); + + it('should correctly handle an empty string endpoint ""', async () => { + const mockRes = createMockResponse(); + const endpoint = ''; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = (transport as any)._sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` + ); + }); + }); +}); From 976ff74ba1c9c35c3bafa663a8bedec483a7d8e0 Mon Sep 17 00:00:00 2001 From: akazwz Date: Wed, 9 Apr 2025 09:28:46 +0800 Subject: [PATCH 33/60] Replace type casting with direct sessionId property access in SSE tests --- src/server/sse.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 28474bad..2fd2c042 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -20,7 +20,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = '/messages'; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); @@ -35,7 +35,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = '/messages?foo=bar&baz=qux'; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); @@ -50,7 +50,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = '/messages#section1'; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); @@ -65,7 +65,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = '/messages?key=value#section2'; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); @@ -80,7 +80,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = '/'; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); @@ -95,7 +95,7 @@ describe('SSEServerTransport', () => { const mockRes = createMockResponse(); const endpoint = ''; const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = (transport as any)._sessionId; + const expectedSessionId = transport.sessionId; await transport.start(); From 9f9c2448573da6f5650a7297b5933e238c2951f6 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 28 Mar 2025 16:34:30 +1100 Subject: [PATCH 34/60] Added mutation methods to RegisteredTool/Resource/ResourceTemplate/Prompt This allows these objects to be enabled/disabled, or updated on the fly. Corresponding listChanged notifications are automatically emitted. --- README.md | 62 +++ src/server/mcp.test.ts | 872 ++++++++++++++++++++++++++++++++++++++++- src/server/mcp.ts | 240 ++++++++++-- 3 files changed, 1138 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index a6ec645c..4fcdff17 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,68 @@ server.tool( ## Advanced Usage +### Dynamic Servers + +If you want to offer an initial set of tools/prompts/resources, but later add additional ones based on user action or external state change, you can add/update/remove them _after_ the Server is connected. This will automatically emit the corresponding `listChanged` notificaions: + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "Dynamic Example", + version: "1.0.0" +}); + +const listMessageTool = server.tool( + "listMessages", + { channel: z.string() }, + async ({ channel }) => ({ + content: [{ type: "text", text: await listMessages(channel) }] + }) +); + +const putMessageTool = server.tool( + "putMessage", + { channel: z.string(), message: z.string() }, + async ({ channel, message }) => ({ + content: [{ type: "text", text: await putMessage(channel, string) }] + }) +); +// Until we upgrade auth, `putMessage` is disabled (won't show up in listTools) +putMessageTool.disable() + +const upgradeAuthTool = server.tool( + "upgradeAuth", + { permission: z.enum(["write', vadmin"])}, + // Any mutations here will automatically emit `listChanged` notifications + async ({ permission }) => { + const { ok, err, previous } = await upgradeAuthAndStoreToken(permission) + if (!ok) return {content: [{ type: "text", text: `Error: ${err}` }]} + + // If we previously had read-only access, 'putMessage' is now available + if (previous === "read") { + putMessageTool.enable() + } + + if (permission === 'write') { + // If we've just upgraded to 'write' permissions, we can still call 'upgradeAuth' + // but can only upgrade to 'admin'. + upgradeAuthTool.update({ + paramSchema: { permission: z.enum(["admin"]) }, // change validation rules + }) + } else { + // If we're now an admin, we no longer have anywhere to upgrade to, so fully remove that tool + upgradeAuthTool.remove() + } + } +) + +// Connect as normal +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + ### Low-Level Server For more control, you can use the low-level Server class directly: diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index f33c669f..eaac5c71 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -12,6 +12,7 @@ import { GetPromptResultSchema, CompleteResultSchema, LoggingMessageNotificationSchema, + Notification, } from "../types.js"; import { ResourceTemplate } from "./mcp.js"; import { completable } from "./completable.js"; @@ -36,10 +37,14 @@ describe("McpServer", () => { { capabilities: { logging: {} } }, ); + const notifications: Notification[] = [] const client = new Client({ name: "test client", version: "1.0", }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -56,6 +61,16 @@ describe("McpServer", () => { data: "Test log message", }), ).resolves.not.toThrow(); + + expect(notifications).toMatchObject([ + { + "method": "notifications/message", + params: { + level: "info", + data: "Test log message", + } + } + ]) }); }); @@ -100,10 +115,14 @@ describe("tool()", () => { name: "test server", version: "1.0", }); + const notifications: Notification[] = [] const client = new Client({ name: "test client", version: "1.0", }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } mcpServer.tool("test", async () => ({ content: [ @@ -119,7 +138,7 @@ describe("tool()", () => { await Promise.all([ client.connect(clientTransport), - mcpServer.server.connect(serverTransport), + mcpServer.connect(serverTransport), ]); const result = await client.request( @@ -134,6 +153,254 @@ describe("tool()", () => { expect(result.tools[0].inputSchema).toEqual({ type: "object", }); + + // Adding the tool before the connection was established means no notification was sent + expect(notifications).toHaveLength(0) + + // Adding another tool triggers the update notification + mcpServer.tool("test2", async () => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + })); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick) + + expect(notifications).toMatchObject([ + { + method: "notifications/tools/list_changed", + } + ]) + }); + + test("should update existing tool", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = [] + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } + + // Register initial tool + const tool = mcpServer.tool("test", async () => ({ + content: [ + { + type: "text", + text: "Initial response", + }, + ], + })); + + // Update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: "text", + text: "Updated response", + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Call the tool and verify we get the updated response + const result = await client.request( + { + method: "tools/call", + params: { + name: "test", + }, + }, + CallToolResultSchema, + ); + + expect(result.content).toEqual([ + { + type: "text", + text: "Updated response", + }, + ]); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0) + }); + + test("should update tool with schema", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = [] + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } + + // Register initial tool + const tool = mcpServer.tool( + "test", + { + name: z.string(), + }, + async ({ name }) => ({ + content: [ + { + type: "text", + text: `Initial: ${name}`, + }, + ], + }), + ); + + // Update the tool with a different schema + tool.update({ + paramsSchema: { + name: z.string(), + value: z.number(), + }, + callback: async ({name, value}) => ({ + content: [ + { + type: "text", + text: `Updated: ${name}, ${value}`, + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Verify the schema was updated + const listResult = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(listResult.tools[0].inputSchema).toMatchObject({ + properties: { + name: { type: "string" }, + value: { type: "number" }, + }, + }); + + // Call the tool with the new schema + const callResult = await client.request( + { + method: "tools/call", + params: { + name: "test", + arguments: { + name: "test", + value: 42, + }, + }, + }, + CallToolResultSchema, + ); + + expect(callResult.content).toEqual([ + { + type: "text", + text: "Updated: test, 42", + }, + ]); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0) + }); + + test("should send tool list changed notifications when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = [] + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } + + // Register initial tool + const tool = mcpServer.tool("test", async () => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + expect(notifications).toHaveLength(0) + + // Now update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: "text", + text: "Updated response", + }, + ], + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick) + + expect(notifications).toMatchObject([ + { method: "notifications/tools/list_changed" } + ]) + + // Now delete the tool + tool.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick) + + expect(notifications).toMatchObject([ + { method: "notifications/tools/list_changed" }, + { method: "notifications/tools/list_changed" }, + ]) }); test("should register tool with args schema", async () => { @@ -637,6 +904,312 @@ describe("resource()", () => { expect(result.resources[0].uri).toBe("test://resource"); }); + test("should update resource with uri", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.resource("test", "test://resource", async () => ({ + contents: [ + { + uri: "test://resource", + text: "Initial content", + }, + ], + })); + + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: "test://resource", + text: "Updated content", + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: "resources/read", + params: { + uri: "test://resource", + }, + }, + ReadResourceResultSchema, + ); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe("Updated content"); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + test("should update resource template", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial resource template + const resourceTemplate = mcpServer.resource( + "test", + new ResourceTemplate("test://resource/{id}", { list: undefined }), + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Initial content", + }, + ], + }), + ); + + // Update the resource template + resourceTemplate.update({ + callback: async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Updated content", + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: "resources/read", + params: { + uri: "test://resource/123", + }, + }, + ReadResourceResultSchema, + ); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe("Updated content"); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + test("should send resource list changed notification when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.resource("test", "test://resource", async () => ({ + contents: [ + { + uri: "test://resource", + text: "Test content", + }, + ], + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + expect(notifications).toHaveLength(0); + + // Now update the resource while connected + resource.update({ + callback: async () => ({ + contents: [ + { + uri: "test://resource", + text: "Updated content", + }, + ], + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { method: "notifications/resources/list_changed" } + ]); + }); + + test("should remove resource and send notification when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial resources + const resource1 = mcpServer.resource("resource1", "test://resource1", async () => ({ + contents: [{ uri: "test://resource1", text: "Resource 1 content" }], + })); + + mcpServer.resource("resource2", "test://resource2", async () => ({ + contents: [{ uri: "test://resource2", text: "Resource 2 content" }], + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Verify both resources are registered + let result = await client.request( + { method: "resources/list" }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(2); + + expect(notifications).toHaveLength(0); + + // Remove a resource + resource1.remove() + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([ + { method: "notifications/resources/list_changed" } + ]); + + // Verify the resource was removed + result = await client.request( + { method: "resources/list" }, + ListResourcesResultSchema, + ); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].uri).toBe("test://resource2"); + }); + + test("should remove resource template and send notification when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register resource template + const resourceTemplate = mcpServer.resource( + "template", + new ResourceTemplate("test://resource/{id}", { list: undefined }), + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: "Template content", + }, + ], + }), + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Verify template is registered + const result = await client.request( + { method: "resources/templates/list" }, + ListResourceTemplatesResultSchema, + ); + + expect(result.resourceTemplates).toHaveLength(1); + expect(notifications).toHaveLength(0); + + // Remove the template + resourceTemplate.remove() + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([ + { method: "notifications/resources/list_changed" } + ]); + + // Verify the template was removed + const result2 = await client.request( + { method: "resources/templates/list" }, + ListResourceTemplatesResultSchema, + ); + + expect(result2.resourceTemplates).toHaveLength(0); + }); + test("should register resource with metadata", async () => { const mcpServer = new McpServer({ name: "test server", @@ -1173,6 +1746,303 @@ describe("prompt()", () => { expect(result.prompts[0].name).toBe("test"); expect(result.prompts[0].arguments).toBeUndefined(); }); + test("should update existing prompt", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.prompt("test", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Initial response", + }, + }, + ], + })); + + // Update the prompt + prompt.update({ + callback: async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Updated response", + }, + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Call the prompt and verify we get the updated response + const result = await client.request( + { + method: "prompts/get", + params: { + name: "test", + }, + }, + GetPromptResultSchema, + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content.text).toBe("Updated response"); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + test("should update prompt with schema", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.prompt( + "test", + { + name: z.string(), + }, + async ({ name }) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Initial: ${name}`, + }, + }, + ], + }), + ); + + // Update the prompt with a different schema + prompt.update({ + argsSchema: { + name: z.string(), + value: z.string(), + }, + callback: async ({name, value}) => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Updated: ${name}, ${value}`, + }, + }, + ], + }) + }); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Verify the schema was updated + const listResult = await client.request( + { + method: "prompts/list", + }, + ListPromptsResultSchema, + ); + + expect(listResult.prompts[0].arguments).toHaveLength(2); + expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(["name", "value"]); + + // Call the prompt with the new schema + const getResult = await client.request( + { + method: "prompts/get", + params: { + name: "test", + arguments: { + name: "test", + value: "value", + }, + }, + }, + GetPromptResultSchema, + ); + + expect(getResult.messages).toHaveLength(1); + expect(getResult.messages[0].content.text).toBe("Updated: test, value"); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + test("should send prompt list changed notification when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.prompt("test", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Test response", + }, + }, + ], + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + expect(notifications).toHaveLength(0); + + // Now update the prompt while connected + prompt.update({ + callback: async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Updated response", + }, + }, + ], + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { method: "notifications/prompts/list_changed" } + ]); + }); + + test("should remove prompt and send notification when connected", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const notifications: Notification[] = []; + const client = new Client({ + name: "test client", + version: "1.0", + }); + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification); + }; + + // Register initial prompts + const prompt1 = mcpServer.prompt("prompt1", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Prompt 1 response", + }, + }, + ], + })); + + mcpServer.prompt("prompt2", async () => ({ + messages: [ + { + role: "assistant", + content: { + type: "text", + text: "Prompt 2 response", + }, + }, + ], + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.connect(serverTransport), + ]); + + // Verify both prompts are registered + let result = await client.request( + { method: "prompts/list" }, + ListPromptsResultSchema, + ); + + expect(result.prompts).toHaveLength(2); + expect(result.prompts.map(p => p.name).sort()).toEqual(["prompt1", "prompt2"]); + + expect(notifications).toHaveLength(0); + + // Remove a prompt + prompt1.remove() + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([ + { method: "notifications/prompts/list_changed" } + ]); + + // Verify the prompt was removed + result = await client.request( + { method: "prompts/list" }, + ListPromptsResultSchema, + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe("prompt2"); + }); test("should register prompt with args schema", async () => { const mcpServer = new McpServer({ diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 484084fc..652f9774 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -98,13 +98,17 @@ export class McpServer { ); this.server.registerCapabilities({ - tools: {}, - }); + tools: { + listChanged: true + } + }) this.server.setRequestHandler( ListToolsRequestSchema, (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools).map( + tools: Object.entries(this._registeredTools).filter( + ([, tool]) => tool.enabled, + ).map( ([name, tool]): Tool => { return { name, @@ -131,6 +135,13 @@ export class McpServer { ); } + if (!tool.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} disabled`, + ); + } + if (tool.inputSchema) { const parseResult = await tool.inputSchema.safeParseAsync( request.params.arguments, @@ -220,7 +231,14 @@ export class McpServer { if (!prompt) { throw new McpError( ErrorCode.InvalidParams, - `Prompt ${request.params.ref.name} not found`, + `Prompt ${ref.name} not found`, + ); + } + + if (!prompt.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Prompt ${ref.name} disabled`, ); } @@ -287,13 +305,17 @@ export class McpServer { ); this.server.registerCapabilities({ - resources: {}, - }); + resources: { + listChanged: true + } + }) this.server.setRequestHandler( ListResourcesRequestSchema, async (request, extra) => { - const resources = Object.entries(this._registeredResources).map( + const resources = Object.entries(this._registeredResources).filter( + ([_, resource]) => resource.enabled, + ).map( ([uri, resource]) => ({ uri, name: resource.name, @@ -345,6 +367,12 @@ export class McpServer { // First check for exact resource match const resource = this._registeredResources[uri.toString()]; if (resource) { + if (!resource.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Resource ${uri} disabled`, + ); + } return resource.readCallback(uri, extra); } @@ -387,13 +415,17 @@ export class McpServer { ); this.server.registerCapabilities({ - prompts: {}, - }); + prompts: { + listChanged: true + } + }) this.server.setRequestHandler( ListPromptsRequestSchema, (): ListPromptsResult => ({ - prompts: Object.entries(this._registeredPrompts).map( + prompts: Object.entries(this._registeredPrompts).filter( + ([, prompt]) => prompt.enabled, + ).map( ([name, prompt]): Prompt => { return { name, @@ -418,6 +450,13 @@ export class McpServer { ); } + if (!prompt.enabled) { + throw new McpError( + ErrorCode.InvalidParams, + `Prompt ${request.params.name} disabled`, + ); + } + if (prompt.argsSchema) { const parseResult = await prompt.argsSchema.safeParseAsync( request.params.arguments, @@ -447,7 +486,7 @@ export class McpServer { /** * Registers a resource `name` at a fixed URI, which will use the given callback to respond to read requests. */ - resource(name: string, uri: string, readCallback: ReadResourceCallback): void; + resource(name: string, uri: string, readCallback: ReadResourceCallback): RegisteredResource; /** * Registers a resource `name` at a fixed URI with metadata, which will use the given callback to respond to read requests. @@ -457,7 +496,7 @@ export class McpServer { uri: string, metadata: ResourceMetadata, readCallback: ReadResourceCallback, - ): void; + ): RegisteredResource; /** * Registers a resource `name` with a template pattern, which will use the given callback to respond to read requests. @@ -466,7 +505,7 @@ export class McpServer { name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback, - ): void; + ): RegisteredResourceTemplate; /** * Registers a resource `name` with a template pattern and metadata, which will use the given callback to respond to read requests. @@ -476,13 +515,13 @@ export class McpServer { template: ResourceTemplate, metadata: ResourceMetadata, readCallback: ReadResourceTemplateCallback, - ): void; + ): RegisteredResourceTemplate; resource( name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[] - ): void { + ): RegisteredResource | RegisteredResourceTemplate { let metadata: ResourceMetadata | undefined; if (typeof rest[0] === "object") { metadata = rest.shift() as ResourceMetadata; @@ -497,35 +536,73 @@ export class McpServer { throw new Error(`Resource ${uriOrTemplate} is already registered`); } - this._registeredResources[uriOrTemplate] = { + const registeredResource: RegisteredResource = { name, metadata, readCallback: readCallback as ReadResourceCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: (updates) => { + if (typeof updates.uri !== "undefined" && updates.uri !== uriOrTemplate) { + delete this._registeredResources[uriOrTemplate] + if (updates.uri) this._registeredResources[updates.uri] = registeredResource + } + if (typeof updates.name !== "undefined") registeredResource.name = updates.name + if (typeof updates.metadata !== "undefined") registeredResource.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResource.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResource.enabled = updates.enabled + this.sendResourceListChanged() + }, }; + this._registeredResources[uriOrTemplate] = registeredResource; + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; } else { if (this._registeredResourceTemplates[name]) { throw new Error(`Resource template ${name} is already registered`); } - this._registeredResourceTemplates[name] = { + const registeredResourceTemplate: RegisteredResourceTemplate = { resourceTemplate: uriOrTemplate, metadata, readCallback: readCallback as ReadResourceTemplateCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredResourceTemplates[name] + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate + } + if (typeof updates.template !== "undefined") registeredResourceTemplate.resourceTemplate = updates.template + if (typeof updates.metadata !== "undefined") registeredResourceTemplate.metadata = updates.metadata + if (typeof updates.callback !== "undefined") registeredResourceTemplate.readCallback = updates.callback + if (typeof updates.enabled !== "undefined") registeredResourceTemplate.enabled = updates.enabled + this.sendResourceListChanged() + }, }; - } + this._registeredResourceTemplates[name] = registeredResourceTemplate; - this.setResourceRequestHandlers(); + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } } /** * Registers a zero-argument tool `name`, which will run the given function when the client calls it. */ - tool(name: string, cb: ToolCallback): void; + tool(name: string, cb: ToolCallback): RegisteredTool; /** * Registers a zero-argument tool `name` (with a description) which will run the given function when the client calls it. */ - tool(name: string, description: string, cb: ToolCallback): void; + tool(name: string, description: string, cb: ToolCallback): RegisteredTool; /** * Registers a tool `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -534,7 +611,7 @@ export class McpServer { name: string, paramsSchema: Args, cb: ToolCallback, - ): void; + ): RegisteredTool; /** * Registers a tool `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -544,9 +621,9 @@ export class McpServer { description: string, paramsSchema: Args, cb: ToolCallback, - ): void; + ): RegisteredTool; - tool(name: string, ...rest: unknown[]): void { + tool(name: string, ...rest: unknown[]): RegisteredTool { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } @@ -562,25 +639,44 @@ export class McpServer { } const cb = rest[0] as ToolCallback; - this._registeredTools[name] = { + const registeredTool: RegisteredTool = { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), callback: cb, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredTools[name] + if (updates.name) this._registeredTools[updates.name] = registeredTool + } + if (typeof updates.description !== "undefined") registeredTool.description = updates.description + if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) + if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled + this.sendToolListChanged() + }, }; + this._registeredTools[name] = registeredTool; this.setToolRequestHandlers(); + this.sendToolListChanged() + + return registeredTool } /** * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. */ - prompt(name: string, cb: PromptCallback): void; + prompt(name: string, cb: PromptCallback): RegisteredPrompt; /** * Registers a zero-argument prompt `name` (with a description) which will run the given function when the client calls it. */ - prompt(name: string, description: string, cb: PromptCallback): void; + prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; /** * Registers a prompt `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -589,7 +685,7 @@ export class McpServer { name: string, argsSchema: Args, cb: PromptCallback, - ): void; + ): RegisteredPrompt; /** * Registers a prompt `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. @@ -599,9 +695,9 @@ export class McpServer { description: string, argsSchema: Args, cb: PromptCallback, - ): void; + ): RegisteredPrompt; - prompt(name: string, ...rest: unknown[]): void { + prompt(name: string, ...rest: unknown[]): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); } @@ -617,13 +713,67 @@ export class McpServer { } const cb = rest[0] as PromptCallback; - this._registeredPrompts[name] = { + const registeredPrompt: RegisteredPrompt = { description, argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), callback: cb, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: (updates) => { + if (typeof updates.name !== "undefined" && updates.name !== name) { + delete this._registeredPrompts[name] + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt + } + if (typeof updates.description !== "undefined") registeredPrompt.description = updates.description + if (typeof updates.argsSchema !== "undefined") registeredPrompt.argsSchema = z.object(updates.argsSchema) + if (typeof updates.callback !== "undefined") registeredPrompt.callback = updates.callback + if (typeof updates.enabled !== "undefined") registeredPrompt.enabled = updates.enabled + this.sendPromptListChanged() + }, }; + this._registeredPrompts[name] = registeredPrompt; this.setPromptRequestHandlers(); + this.sendPromptListChanged() + + return registeredPrompt + } + + /** + * Checks if the server is connected to a transport. + * @returns True if the server is connected + */ + isConnected() { + return this.server.transport !== undefined + } + + /** + * Sends a resource list changed event to the client, if connected. + */ + sendResourceListChanged() { + if (this.isConnected()) { + this.server.sendResourceListChanged(); + } + } + + /** + * Sends a tool list changed event to the client, if connected. + */ + sendToolListChanged() { + if (this.isConnected()) { + this.server.sendToolListChanged(); + } + } + + /** + * Sends a prompt list changed event to the client, if connected. + */ + sendPromptListChanged() { + if (this.isConnected()) { + this.server.sendPromptListChanged(); + } } } @@ -700,10 +850,15 @@ export type ToolCallback = ) => CallToolResult | Promise : (extra: RequestHandlerExtra) => CallToolResult | Promise; -type RegisteredTool = { +export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; callback: ToolCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, enabled?: boolean }): void + remove(): void }; const EMPTY_OBJECT_JSON_SCHEMA = { @@ -730,10 +885,15 @@ export type ReadResourceCallback = ( extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; -type RegisteredResource = { +export type RegisteredResource = { name: string; metadata?: ResourceMetadata; readCallback: ReadResourceCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string, uri?: string | null, metadata?: ResourceMetadata, callback?: ReadResourceCallback, enabled?: boolean }): void + remove(): void }; /** @@ -745,10 +905,15 @@ export type ReadResourceTemplateCallback = ( extra: RequestHandlerExtra, ) => ReadResourceResult | Promise; -type RegisteredResourceTemplate = { +export type RegisteredResourceTemplate = { resourceTemplate: ResourceTemplate; metadata?: ResourceMetadata; readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, template?: ResourceTemplate, metadata?: ResourceMetadata, callback?: ReadResourceTemplateCallback, enabled?: boolean }): void + remove(): void }; type PromptArgsRawShape = { @@ -766,10 +931,15 @@ export type PromptCallback< ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; -type RegisteredPrompt = { +export type RegisteredPrompt = { description?: string; argsSchema?: ZodObject; callback: PromptCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { name?: string | null, description?: string, argsSchema?: Args, callback?: PromptCallback, enabled?: boolean }): void + remove(): void }; function promptArgumentsFromSchema( From 1d18a57a171e99da2da75107d163d2d2dba628f9 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 09:50:59 +0100 Subject: [PATCH 35/60] extract standalone get into a method which can be called by a client --- src/client/streamableHttp.test.ts | 28 ++++--- src/client/streamableHttp.ts | 122 ++++++++++++++++-------------- 2 files changed, 84 insertions(+), 66 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 8a1b9044..0dc582d4 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -161,7 +161,10 @@ describe("StreamableHTTPClientTransport", () => { statusText: "Method Not Allowed" }); + // We expect the 405 error to be caught and handled gracefully + // This should not throw an error that breaks the transport await transport.start(); + await expect(transport.openSseStream()).rejects.toThrow('Failed to open SSE stream: Method Not Allowed'); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( @@ -206,6 +209,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); + await transport.openSseStream(); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -233,7 +237,7 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock) .mockResolvedValueOnce({ - ok: true, + ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: makeStream("request1") @@ -255,16 +259,21 @@ describe("StreamableHTTPClientTransport", () => { ]); // Give time for SSE processing - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 100)); // Both streams should have delivered their messages expect(messageSpy).toHaveBeenCalledTimes(2); - expect(messageSpy).toHaveBeenCalledWith( - expect.objectContaining({ result: { id: "request1" }, id: "request1" }) - ); - expect(messageSpy).toHaveBeenCalledWith( - expect.objectContaining({ result: { id: "request2" }, id: "request2" }) - ); + + // Verify received messages without assuming specific order + expect(messageSpy.mock.calls.some(call => { + const msg = call[0]; + return msg.id === "request1" && msg.result?.id === "request1"; + })).toBe(true); + + expect(messageSpy.mock.calls.some(call => { + const msg = call[0]; + return msg.id === "request2" && msg.result?.id === "request2"; + })).toBe(true); }); it("should include last-event-id header when resuming a broken connection", async () => { @@ -286,6 +295,7 @@ describe("StreamableHTTPClientTransport", () => { }); await transport.start(); + await transport.openSseStream(); await new Promise(resolve => setTimeout(resolve, 50)); // Now simulate attempting to reconnect @@ -296,7 +306,7 @@ describe("StreamableHTTPClientTransport", () => { body: null }); - await transport.start(); + await transport.openSseStream(); // Check that Last-Event-ID was included const calls = (global.fetch as jest.Mock).mock.calls; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 38da5bcb..82cd61ec 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,13 +1,11 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { type ErrorEvent } from "eventsource"; import { EventSourceMessage, EventSourceParserStream } from 'eventsource-parser/stream'; export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, message: string | undefined, - public readonly event: ErrorEvent, ) { super(`Streamable HTTP error: ${message}`); } @@ -83,7 +81,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuth(); + return await this._startOrAuthStandaloneSSE(); } private async _commonHeaders(): Promise { @@ -102,7 +100,7 @@ export class StreamableHTTPClientTransport implements Transport { return headers; } - private async _startOrAuth(): Promise { + private async _startOrAuthStandaloneSSE(): 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 @@ -121,25 +119,22 @@ export class StreamableHTTPClientTransport implements Transport { signal: this._abortController?.signal, }); - if (response.status === 405 || response.status === 404) { - // Server doesn't support GET for SSE, which is allowed by the spec - // We'll rely on SSE responses to POST requests for communication - return; - } - if (!response.ok) { if (response.status === 401 && this._authProvider) { // Need to authenticate return await this._authThenStart(); } - const error = new Error(`Failed to open SSE stream: ${response.status} ${response.statusText}`); + const error = new StreamableHTTPError( + response.status, + `Failed to open SSE stream: ${response.statusText}`, + ); this.onerror?.(error); throw error; } // Successful connection, handle the SSE stream as a standalone listener - const streamId = `initial-${Date.now()}`; + const streamId = `standalone-sse-${Date.now()}`; this._handleSseStream(response.body, streamId); } catch (error) { this.onerror?.(error as Error); @@ -147,6 +142,53 @@ export class StreamableHTTPClientTransport implements Transport { } } + private _handleSseStream(stream: ReadableStream | null, streamId: string): void { + if (!stream) { + return; + } + + // Create a pipeline: binary stream -> text decoder -> SSE parser + const eventStream = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()); + + const reader = eventStream.getReader(); + this._activeStreams.set(streamId, reader); + + const processStream = async () => { + try { + while (true) { + const { done, value: event } = await reader.read(); + if (done) { + this._activeStreams.delete(streamId); + break; + } + + // 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); + } + } + } + } catch (error) { + this._activeStreams.delete(streamId); + this.onerror?.(error as Error); + } + }; + + processStream(); + } + async start() { if (this._activeStreams.size > 0) { throw new Error( @@ -155,7 +197,6 @@ export class StreamableHTTPClientTransport implements Transport { } this._abortController = new AbortController(); - return await this._startOrAuth(); } /** @@ -271,50 +312,17 @@ export class StreamableHTTPClientTransport implements Transport { } } - private _handleSseStream(stream: ReadableStream | null, streamId: string): void { - if (!stream) { - return; + /** + * Opens SSE stream to receive messages from the server. + * + * This allows the server to push messages to the client without requiring the client + * to first send a request via HTTP POST. Some servers may not support this feature. + * If authentication is required but fails, this method will throw an UnauthorizedError. + */ + async openSseStream(): Promise { + if (!this._abortController) { + this._abortController = new AbortController(); } - - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); - - const reader = eventStream.getReader(); - this._activeStreams.set(streamId, reader); - - const processStream = async () => { - try { - while (true) { - const { done, value: event } = await reader.read(); - if (done) { - this._activeStreams.delete(streamId); - break; - } - - // 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); - } - } - } - } catch (error) { - this._activeStreams.delete(streamId); - this.onerror?.(error as Error); - } - }; - - processStream(); + await this._startOrAuthStandaloneSSE(); } } \ No newline at end of file From 6a8fa5ff2b1a0d8eaf94aa04c7342fc4bb65749c Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 10:14:21 +0100 Subject: [PATCH 36/60] small refactor for send logic --- src/server/streamableHttp.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 5aff0c45..b0fcce6d 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -359,13 +359,9 @@ export class StreamableHTTPServerTransport implements Transport { async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; - let shouldCloseConnection = false; if ('result' in message || 'error' in message) { // If the message is a response, use the request ID from the message requestId = message.id; - // This is a response to the original request, we can close the stream - // after sending all related responses - shouldCloseConnection = true; } if (requestId === undefined) { throw new Error("No request ID provided for the message"); @@ -380,8 +376,8 @@ export class StreamableHTTPServerTransport implements Transport { sseResponse.write( `event: message\ndata: ${JSON.stringify(message)}\n\n`, ); - - if (shouldCloseConnection) { + // After all JSON-RPC responses have been sent, the server SHOULD close the SSE stream. + if ('result' in message || 'error' in message) { this._sseResponseMapping.delete(requestId); // Only close the connection if it's not needed by other requests const canCloseConnection = ![...this._sseResponseMapping.entries()].some(([id, res]) => res === sseResponse && id !== requestId); From 18674007e55000b9a6fffd86729bd4034249cdb4 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 11:13:28 +0100 Subject: [PATCH 37/60] remove the _activeStreams map --- src/client/streamableHttp.ts | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 82cd61ec..1420f7be 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -43,7 +43,6 @@ export type StreamableHTTPClientTransportOptions = { * for receiving messages. */ export class StreamableHTTPClientTransport implements Transport { - private _activeStreams: Map> = new Map(); private _abortController?: AbortController; private _url: URL; private _requestInit?: RequestInit; @@ -134,33 +133,28 @@ export class StreamableHTTPClientTransport implements Transport { } // Successful connection, handle the SSE stream as a standalone listener - const streamId = `standalone-sse-${Date.now()}`; - this._handleSseStream(response.body, streamId); + this._handleSseStream(response.body); } catch (error) { this.onerror?.(error as Error); throw error; } } - private _handleSseStream(stream: ReadableStream | null, streamId: string): void { + private _handleSseStream(stream: ReadableStream | null): void { if (!stream) { return; } - // Create a pipeline: binary stream -> text decoder -> SSE parser const eventStream = stream .pipeThrough(new TextDecoderStream()) .pipeThrough(new EventSourceParserStream()); const reader = eventStream.getReader(); - this._activeStreams.set(streamId, reader); - const processStream = async () => { try { while (true) { const { done, value: event } = await reader.read(); if (done) { - this._activeStreams.delete(streamId); break; } @@ -181,7 +175,6 @@ export class StreamableHTTPClientTransport implements Transport { } } } catch (error) { - this._activeStreams.delete(streamId); this.onerror?.(error as Error); } }; @@ -190,7 +183,7 @@ export class StreamableHTTPClientTransport implements Transport { } async start() { - if (this._activeStreams.size > 0) { + if (this._abortController) { throw new Error( "StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.", ); @@ -214,16 +207,6 @@ export class StreamableHTTPClientTransport implements Transport { } async close(): Promise { - // Close all active streams - for (const reader of this._activeStreams.values()) { - try { - reader.cancel(); - } catch (error) { - this.onerror?.(error as Error); - } - } - this._activeStreams.clear(); - // Abort any pending requests this._abortController?.abort(); @@ -292,8 +275,7 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { // For streaming responses, create a unique stream ID based on request IDs - const streamId = `req-${requestIds.join('-')}-${Date.now()}`; - this._handleSseStream(response.body, streamId); + this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -321,7 +303,9 @@ export class StreamableHTTPClientTransport implements Transport { */ async openSseStream(): Promise { if (!this._abortController) { - this._abortController = new AbortController(); + throw new Error( + "StreamableHTTPClientTransport not started! Call connect() before openSseStream().", + ); } await this._startOrAuthStandaloneSSE(); } From 26929fa9b7862f04a6a0980782fa6adb35b5561e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 11:17:34 +0100 Subject: [PATCH 38/60] fix lint --- src/client/streamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 1420f7be..0c667e35 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,7 +1,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { EventSourceMessage, EventSourceParserStream } from 'eventsource-parser/stream'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, From b37a2e11ab2639c5d85c76a13f9a6332b41949ab Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 21:01:35 +0100 Subject: [PATCH 39/60] support for server returning json -- not stream --- src/examples/README.md | 28 +++ src/examples/client/simpleStreamableHttp.ts | 63 ++++--- .../server/jsonResponseStreamableHttp.ts | 119 +++++++++++++ src/server/.streamableHttp.test.ts.swp | Bin 0 -> 16384 bytes src/server/streamableHttp.test.ts | 162 ++++++++++++++++-- src/server/streamableHttp.ts | 127 ++++++++++---- 6 files changed, 430 insertions(+), 69 deletions(-) create mode 100644 src/examples/server/jsonResponseStreamableHttp.ts create mode 100644 src/server/.streamableHttp.test.ts.swp diff --git a/src/examples/README.md b/src/examples/README.md index 6e53fdec..937159c3 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -6,6 +6,34 @@ This directory contains example implementations of MCP clients and servers using Multi node with stete management example will be added soon after we add support. +### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) + +A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. + +#### Running the server + +```bash +npx tsx src/examples/server/jsonResponseStreamableHttp.ts +``` + +The server will start on port 3000. You can test the initialization and tool calling: + +```bash +# Initialize the server and get the session ID from headers +SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') +echo "Session ID: $SESSION_ID" + +# Call the greet tool using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"mcp.call_tool","params":{"name":"greet","arguments":{"name":"World"}},"id":"2"}' \ + http://localhost:3000/mcp +``` + +Note that in this example, we're using plain JSON response mode by setting `Accept: application/json` header. + ### Server (`server/simpleStreamableHttp.ts`) A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 9bf43ce8..589c1f41 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,9 +15,9 @@ import { async function main(): Promise { // Create a new client with streamable HTTP transport - const client = new Client({ - name: 'example-client', - version: '1.0.0' + const client = new Client({ + name: 'example-client', + version: '1.0.0' }); const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') @@ -27,7 +27,6 @@ async function main(): Promise { await client.connect(transport); console.log('Connected to MCP server'); - // List available tools const toolsRequest: ListToolsRequest = { method: 'tools/list', @@ -48,32 +47,44 @@ async function main(): Promise { console.log('Greeting result:', greetResult.content[0].text); // List available prompts - const promptsRequest: ListPromptsRequest = { - method: 'prompts/list', - params: {} - }; - const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); - console.log('Available prompts:', promptsResult.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 - 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); + 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 - const resourcesRequest: ListResourcesRequest = { - method: 'resources/list', - params: {} - }; - const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources:', resourcesResult.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})`); + } + // Close the connection await client.close(); } diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts new file mode 100644 index 00000000..2e83b54a --- /dev/null +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -0,0 +1,119 @@ +import express, { Request, Response } from 'express'; +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'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'json-response-streamable-http-server', + version: '1.0.0', +}); + +// Register a simple tool that returns a greeting +server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + } +); + +const app = express(); +app.use(express.json()); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - use JSON response mode + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, // Enable JSON response mode + }); + + // 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 + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + 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, + }); + } + } +}); + +// Helper function to detect initialize requests +function isInitializeRequest(body: unknown): boolean { + if (Array.isArray(body)) { + return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); + } + return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; +} + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`MCP Streamable HTTP Server with JSON responses listening on port ${PORT}`); + console.log(`Server is running. Press Ctrl+C to stop.`); + console.log(`Initialize with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' http://localhost:${PORT}/mcp`); + console.log(`Then call tool with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "mcp-session-id: YOUR_SESSION_ID" -d '{"jsonrpc":"2.0","method":"mcp.call_tool","params":{"name":"greet","arguments":{"name":"World"}},"id":"2"}' http://localhost:${PORT}/mcp`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + await server.close(); + process.exit(0); +}); \ No newline at end of file diff --git a/src/server/.streamableHttp.test.ts.swp b/src/server/.streamableHttp.test.ts.swp new file mode 100644 index 0000000000000000000000000000000000000000..4ab20fa3582429b76d0a1f7da19f2c2ec77faf7f GIT binary patch literal 16384 zcmeI3%a0tz9mg9&c=~~Yxqw89;lK>c^z5u5h_SI@*T62);?+KgWXnOl-8DPC?x}8f z*X(*@1{5Xv0}{gl@sL6ULXH%H00B`BNF)l8LZlpkLoP^>B7q2r0(JrkIN$23>gj2E zy@nHzYU!gL_phGcs`}NV%S&VTOwW*;?Use-Qp>va52u!Ie4}n{{j+8Hj5*@W)F z1$2t@$mTTRHcv{^G%t+;MuD?cV5_xryfr35UsJi7T=uQSvyEuN83l|2MggOMQNSo* z6fg=H1&jh;0|j`r&3XX!I9Ju>KK1vU!r#;C3Dx^S!TzbT`|ACB1^Xw;9;x?>Dg&L* zf0aE}_TLul|50{*3UHGuu#W$+vL8|LPZaDQDf_CjU-=FBL&yKOvNL7>Nx}XvW!EPg zrwjH|%HC7)SI!$v|D>`9%Klct{-Lt3Df{F%N8^8>?B7=QUlr`?wfSQdFbWt2i~>dh zqkvJsC}0#Y3K#{90!D#S1su<^&WB~2*e~Pzzuy19y4|ww2eY6GKHX+ne+93AUjhzX zFaxdwTfnKUmh}esHFycU2p$AS!2xg^*bDZ6Yr(r)5FVTWKLpN zfBy}50vrJya0o1d1#l@ig)@TRgJ;2U@B=Ubt^nJ?M>vD{2lzX94Lk!L10lEp)W9cb z*AKuy!OP&s;78yP*a@}+(e_&b9+E!2&^Unys!4BC7HavNQ-;3cd9x3vNVNQ=CsJOh-kd{iF;C7EnqGgX&@?u6#?DWd3IP3+v5(4yD4bkP;`N!?+yaA`B^{)=ik$vSgM zwMK5)gVfVRhW#to(FV1gToqh`Y9#1k(6r%u=mW!ktX@xhx?+UL;bDhMqC}JEJPwM( zmeHt0J>z3zI=C83BLg`bq+%dVIjeMnQa0%X0rkG?^KMn8^F@YXF^-*;Uk=)RwBs}C z3GbnD*>vD;G;WJoZQvz3PLK@UL@`pae2dKcObLe#cm@X{os;TXUpX)FfA1tlnr}l- zk@E-3l+hSo6&@iSw`6)B@%m!Q7&?*EIef+`5aOk`9hYdeGOr@fI1IB(4^9)obKXOD zy%lls(G~OVZ9yO$c_Qaur^9J{yW@7NC6RPw>DpV`BV_(Ho4~RuM7Ze8gO>SK=|<8y z1YM>uPfGuj_cVoWCD##p<|FQuqK0u>iGUS=7xoqxr}tIWn94jO(q?px%nxsf zE-cK>r`JXo^ui%^iAxkK4O$c_P|FatLTD2?-;3#RXp#kPlT>~oJN0(UEc7yBkB*R) zt9|Ao?Xq&Mf2OkMJHh~^iYf2M41Z*yM7{$|H>A%Fvv7@xpPLkups#YVHlxrt4clQ_ z*5wT?wCD3&@N@+@mzHIr>!TaWZY352S&|!s!TNFYKNs@326Rh?Q;{ z!0(Xi$V()eOYL3}9kMC^LI$EfT2z^%8i{kN0AhR(YVD#9X|f{Mz4@`0=`x3%KQrr8-g~!Kl6>g-Gx>xpE4z3zel(Fn z?dpZznkAFgtc#oVD1q4jKaaii6!zR=|F6F4jup=I0z1aYr#3-9qjwx z1W$t=*bQ3XH1_u=!H3{=@C5i7SOwRE>wwt*KM4+kc`yS+Iqn4JF$x$3i~>dhqkvJs zC}0#Y3K#{xyb9o0hhU4SPj~cReQaF~nc5hOZFJ3MA^t~i*&#H(U5IZ&6WB58Q&qW@ z-YBKgRB+gpP_HZz>;&iK(SARngm)>CVbgCwIKxZ0*!YH>)T9cN+P>hybzP;uL0M+8 zx0j`eX(L|}&yR7;AFS(avx6o(WIg#+3LEedRkcoXn?7Pf(CnBdJAxm!(FO { await transport.handleRequest(req, response); // Verify that the request is tracked in the SSE map - expect(transport["_sseResponseMapping"].size).toBe(2); - expect(transport["_sseResponseMapping"].has("cleanup-test")).toBe(true); + expect(transport["_responseMapping"].size).toBe(2); + expect(transport["_responseMapping"].has("cleanup-test")).toBe(true); // Send a response await transport.send({ @@ -1019,8 +1019,8 @@ describe("StreamableHTTPServerTransport", () => { }); // Verify that the mapping was cleaned up - expect(transport["_sseResponseMapping"].size).toBe(1); - expect(transport["_sseResponseMapping"].has("cleanup-test")).toBe(false); + expect(transport["_responseMapping"].size).toBe(1); + expect(transport["_responseMapping"].has("cleanup-test")).toBe(false); }); it("should clean up connection tracking when client disconnects", async () => { @@ -1052,17 +1052,17 @@ describe("StreamableHTTPServerTransport", () => { await transport.handleRequest(req, response); // Both requests should be mapped to the same response - expect(transport["_sseResponseMapping"].size).toBe(3); - expect(transport["_sseResponseMapping"].get("req1")).toBe(response); - expect(transport["_sseResponseMapping"].get("req2")).toBe(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["_sseResponseMapping"].size).toBe(1); - expect(transport["_sseResponseMapping"].has("req1")).toBe(false); - expect(transport["_sseResponseMapping"].has("req2")).toBe(false); + expect(transport["_responseMapping"].size).toBe(1); + expect(transport["_responseMapping"].has("req1")).toBe(false); + expect(transport["_responseMapping"].has("req2")).toBe(false); }); }); @@ -1214,6 +1214,148 @@ describe("StreamableHTTPServerTransport", () => { }); }); + describe("JSON Response Mode", () => { + let jsonResponseTransport: StreamableHTTPServerTransport; + let mockResponse: jest.Mocked; + + 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 + }); + + it("should return JSON response for a single request", async () => { + const requestMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-req-id", + }; + + const req = createMockRequest({ + method: "POST", + headers: { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "mcp-session-id": jsonResponseTransport.sessionId, + }, + body: JSON.stringify(requestMessage), + }); + + // 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, + }; + setTimeout(() => { + void jsonResponseTransport.send(responseMessage); + }, 10); + } + }; + + await jsonResponseTransport.handleRequest(req, mockResponse); + + // Wait for all promises to resolve + await new Promise(resolve => setTimeout(resolve, 50)); + + // 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: "test1", params: {}, id: "req1" }, + { jsonrpc: "2.0", method: "test2", 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 sequential responses - send them in 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, + }; + // Send responses in order - req1 first, then req2 + if (message.id === 'req1') { + void jsonResponseTransport.send(responseMessage); + } else { + // Add a tiny delay to req2 to ensure it's processed after req1 + setTimeout(() => void jsonResponseTransport.send(responseMessage), 5); + } + } + }; + + await jsonResponseTransport.handleRequest(req, mockResponse); + + // Wait for all promises to resolve - give it enough time + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should respond with application/json header + expect(mockResponse.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ + "Content-Type": "application/json", + }) + ); + + expect(mockResponse.end).toHaveBeenCalled(); + expect(mockResponse.end.mock.calls[0][0]).toContain("result-for-req2"); + }); + }); + describe("Handling Pre-Parsed Body", () => { beforeEach(async () => { // Initialize the transport diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index b0fcce6d..3fd96307 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -18,8 +18,12 @@ export interface StreamableHTTPServerTransportOptions { */ sessionIdGenerator: () => string | undefined; - - + /** + * If true, the server will return JSON responses instead of starting an SSE stream. + * This can be useful for simple request/response scenarios without streaming. + * Default is false (SSE streams are preferred). + */ + enableJsonResponse?: boolean; } /** @@ -60,8 +64,11 @@ 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 _sseResponseMapping: Map = new Map(); + private _responseMapping: Map = new Map(); + private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; + private _enableJsonResponse: boolean = false; + sessionId?: string | undefined; onclose?: () => void; @@ -70,6 +77,7 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; + this._enableJsonResponse = options.enableJsonResponse ?? false; } /** @@ -221,33 +229,37 @@ export class StreamableHTTPServerTransport implements Transport { this.onmessage?.(message); } } else if (hasRequests) { - const headers: Record = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }; + // The default behavior is to use SSE streaming + // but in some cases server will return JSON responses + if (!this._enableJsonResponse) { + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; - // After initialization, always include the session ID if we have one - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + + res.writeHead(200, headers); } - - res.writeHead(200, headers); - // Store the response for this request to send messages back through this connection // We need to track by request ID to maintain the connection for (const message of messages) { if ('method' in message && 'id' in message) { - this._sseResponseMapping.set(message.id, res); + this._responseMapping.set(message.id, res); } } // Set up close handler for client disconnects res.on("close", () => { // Remove all entries that reference this response - for (const [id, storedRes] of this._sseResponseMapping.entries()) { + for (const [id, storedRes] of this._responseMapping.entries()) { if (storedRes === res) { - this._sseResponseMapping.delete(id); + this._responseMapping.delete(id); + this._requestResponseMap.delete(id); } } }); @@ -350,10 +362,14 @@ export class StreamableHTTPServerTransport implements Transport { async close(): Promise { // Close all SSE connections - this._sseResponseMapping.forEach((response) => { + this._responseMapping.forEach((response) => { response.end(); }); - this._sseResponseMapping.clear(); + this._responseMapping.clear(); + + // Clear any pending responses + this._requestResponseMap.clear(); + this.onclose?.(); } @@ -367,24 +383,69 @@ export class StreamableHTTPServerTransport implements Transport { throw new Error("No request ID provided for the message"); } - const sseResponse = this._sseResponseMapping.get(requestId); - if (!sseResponse) { - throw new Error(`No SSE connection established for request ID: ${String(requestId)}`); + // Get the response for this request + const response = this._responseMapping.get(requestId); + if (!response) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); } - // Send the message as an SSE event - sseResponse.write( - `event: message\ndata: ${JSON.stringify(message)}\n\n`, - ); - // After all JSON-RPC responses have been sent, the server SHOULD close the SSE stream. + + if (!this._enableJsonResponse) { + response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } if ('result' in message || 'error' in message) { - this._sseResponseMapping.delete(requestId); - // Only close the connection if it's not needed by other requests - const canCloseConnection = ![...this._sseResponseMapping.entries()].some(([id, res]) => res === sseResponse && id !== requestId); - if (canCloseConnection) { - sseResponse?.end(); + this._requestResponseMap.set(requestId, message); + + // Get all request IDs that share the same request response object + const relatedIds = Array.from(this._responseMapping.entries()) + .filter(([_, res]) => res === response) + .map(([id]) => id); + + // Check if we have responses for all requests using this connection + const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); + + if (allResponsesReady) { + if (this._enableJsonResponse) { + // If we are in SSE mode, we don't need to do anything else + + // All responses ready, send as JSON + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.sessionId !== undefined) { + headers['mcp-session-id'] = this.sessionId; + } + + // In tests, relatedIds might be coming in non-deterministic order + // We need to sort numerically for numeric IDs, alphabetically for string IDs + const responses = relatedIds + .sort((a, b) => { + // If both IDs are numbers, sort numerically + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + // Otherwise sort by string lexical order + return String(a).localeCompare(String(b)); + }) + .map(id => this._requestResponseMap.get(id)!); + + response.writeHead(200, headers); + if (responses.length === 1) { + response.end(JSON.stringify(responses[0])); + } else { + response.end(JSON.stringify(responses)); + } + } else { + response.end(); + } + // Clean up + for (const id of relatedIds) { + this._requestResponseMap.delete(id); + this._responseMapping.delete(id); + } } } } +} -} \ No newline at end of file From 71aeb9a3a653e9da0cb29eb3c228ab60eddfcaa3 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 21:38:57 +0100 Subject: [PATCH 40/60] add examples with notifications --- src/examples/client/simpleStreamableHttp.ts | 25 +++++++++- .../server/jsonResponseStreamableHttp.ts | 44 ++++++++++++++++ src/examples/server/simpleStreamableHttp.ts | 50 +++++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 589c1f41..b17add14 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -10,7 +10,8 @@ import { GetPromptRequest, GetPromptResultSchema, ListResourcesRequest, - ListResourcesResultSchema + ListResourcesResultSchema, + LoggingMessageNotificationSchema } from '../../types.js'; async function main(): Promise { @@ -19,12 +20,17 @@ async function main(): Promise { name: 'example-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); // Connect the client using the transport and initialize the server await client.connect(transport); + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); + }); + console.log('Connected to MCP server'); // List available tools @@ -46,6 +52,23 @@ async function main(): Promise { const greetResult = await client.request(greetRequest, CallToolResultSchema); console.log('Greeting result:', greetResult.content[0].text); + // Call the new 'multi-greet' tool + console.log('\nCalling multi-greet tool (with notifications)...'); + const multiGreetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'multi-greet', + arguments: { name: 'MCP User' } + } + }; + const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); + console.log('Multi-greet results:'); + multiGreetResult.content.forEach(item => { + if (item.type === 'text') { + console.log(`- ${item.text}`); + } + }); + // List available prompts try { const promptsRequest: ListPromptsRequest = { diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 2e83b54a..9b053631 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -9,6 +9,10 @@ import { CallToolResult } from '../../types.js'; const server = new McpServer({ name: 'json-response-streamable-http-server', version: '1.0.0', +}, { + capabilities: { + logging: {}, + } }); // Register a simple tool that returns a greeting @@ -30,6 +34,46 @@ server.tool( } ); +// Register a tool that sends multiple greetings with notifications +server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); + + await sleep(1000); // Wait 1 second before first greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); + + await sleep(1000); // Wait another second before second greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } +); + const app = express(); app.use(express.json()); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index e6ebe4b9..74c3ef53 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -9,7 +9,7 @@ import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types const server = new McpServer({ name: 'simple-streamable-http-server', version: '1.0.0', -}); +}, { capabilities: { logging: {} } }); // Register a simple tool that returns a greeting server.tool( @@ -30,6 +30,46 @@ server.tool( } ); +// Register a tool that sends multiple greetings with notifications +server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); + + await sleep(1000); // Wait 1 second before first greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); + + await sleep(1000); // Wait another second before second greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } +); + // Register a simple prompt server.prompt( 'greeting-template', @@ -81,7 +121,7 @@ app.post('/mcp', async (req: Request, res: Response) => { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; - + if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; @@ -90,14 +130,14 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); - + // 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; From 5b99e94e6820e38bac6a4a763687dd32f318e598 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Wed, 9 Apr 2025 21:54:35 +0100 Subject: [PATCH 41/60] clean up somments --- src/server/streamableHttp.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 3fd96307..e8844529 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -406,8 +406,6 @@ export class StreamableHTTPServerTransport implements Transport { if (allResponsesReady) { if (this._enableJsonResponse) { - // If we are in SSE mode, we don't need to do anything else - // All responses ready, send as JSON const headers: Record = { 'Content-Type': 'application/json', @@ -416,18 +414,7 @@ export class StreamableHTTPServerTransport implements Transport { headers['mcp-session-id'] = this.sessionId; } - // In tests, relatedIds might be coming in non-deterministic order - // We need to sort numerically for numeric IDs, alphabetically for string IDs const responses = relatedIds - .sort((a, b) => { - // If both IDs are numbers, sort numerically - if (typeof a === 'number' && typeof b === 'number') { - return a - b; - } - - // Otherwise sort by string lexical order - return String(a).localeCompare(String(b)); - }) .map(id => this._requestResponseMap.get(id)!); response.writeHead(200, headers); @@ -437,6 +424,7 @@ export class StreamableHTTPServerTransport implements Transport { response.end(JSON.stringify(responses)); } } else { + // End the SSE stream response.end(); } // Clean up From 82156515523402bd230d54ef6682762922711e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Mon, 7 Apr 2025 18:24:50 +0200 Subject: [PATCH 42/60] Add helper functions to distinguish between jsonRPC message types --- src/shared/protocol.ts | 13 +++++++++---- src/types.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index b072e578..19ff97d3 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -3,6 +3,9 @@ import { CancelledNotificationSchema, ClientCapabilities, ErrorCode, + isJSONRPCRequest, + isJSONRPCResponse, + isJSONRPCNotification, JSONRPCError, JSONRPCNotification, JSONRPCRequest, @@ -271,12 +274,14 @@ export abstract class Protocol< }; this._transport.onmessage = (message) => { - if (!("method" in message)) { + if (isJSONRPCResponse(message)) { this._onresponse(message); - } else if ("id" in message) { + } else if (isJSONRPCRequest(message)) { this._onrequest(message); - } else { + } else if (isJSONRPCNotification(message)) { this._onnotification(message); + } else { + this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); } }; @@ -437,7 +442,7 @@ export abstract class Protocol< this._progressHandlers.delete(messageId); this._cleanupTimeout(messageId); - if ("result" in response) { + if (isJSONRPCResponse(response)) { handler(response); } else { const error = new McpError( diff --git a/src/types.ts b/src/types.ts index da4afd2a..a68c3ff1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,9 @@ export const JSONRPCRequestSchema = z .merge(RequestSchema) .strict(); +export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => + JSONRPCRequestSchema.safeParse(value).success; + /** * A notification which does not expect a response. */ @@ -88,6 +91,11 @@ export const JSONRPCNotificationSchema = z .merge(NotificationSchema) .strict(); +export const isJSONRPCNotification = ( + value: unknown +): value is JSONRPCNotification => + JSONRPCNotificationSchema.safeParse(value).success; + /** * A successful (non-error) response to a request. */ @@ -99,6 +107,9 @@ export const JSONRPCResponseSchema = z }) .strict(); +export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => + JSONRPCResponseSchema.safeParse(value).success; + /** * Error codes defined by the JSON-RPC specification. */ From 4e749dba4de93020f860c713492eb8b6872da746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Barthelet?= Date: Thu, 10 Apr 2025 00:06:07 +0200 Subject: [PATCH 43/60] Add isJSONRPCError method --- src/shared/protocol.ts | 3 ++- src/types.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 19ff97d3..07bfc02c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -3,6 +3,7 @@ import { CancelledNotificationSchema, ClientCapabilities, ErrorCode, + isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, isJSONRPCNotification, @@ -274,7 +275,7 @@ export abstract class Protocol< }; this._transport.onmessage = (message) => { - if (isJSONRPCResponse(message)) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { this._onrequest(message); diff --git a/src/types.ts b/src/types.ts index a68c3ff1..db6bf125 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,6 +150,9 @@ export const JSONRPCErrorSchema = z }) .strict(); +export const isJSONRPCError = (value: unknown): value is JSONRPCError => + JSONRPCErrorSchema.safeParse(value).success; + export const JSONRPCMessageSchema = z.union([ JSONRPCRequestSchema, JSONRPCNotificationSchema, From a76004c92bfa2c75a2f47eb70b4bbf8d24ee02c1 Mon Sep 17 00:00:00 2001 From: Antoine Beauvais-Lacasse Date: Wed, 9 Apr 2025 20:25:25 -0400 Subject: [PATCH 44/60] StreamableHTTPClientTransport cleanup / fixes * always send headers specified in requestInit option * avoid doubled onerror call * use for-await to iterate SSE stream * remove outdated comments * simplify requestId tracking * throw error when response Content-Type is out of spec --- src/client/streamableHttp.test.ts | 79 ++++++++++++++++++++++--- src/client/streamableHttp.ts | 97 +++++++++++++------------------ 2 files changed, 113 insertions(+), 63 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 0dc582d4..40f22139 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -80,7 +80,7 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, - headers: new Headers({ "mcp-session-id": "test-session-id" }), + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), }); await transport.send(message); @@ -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.openSseStream()).rejects.toThrow('Failed to open SSE stream: Method Not Allowed'); + await expect(transport.openSseStream()).rejects.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( @@ -192,7 +192,7 @@ describe("StreamableHTTPClientTransport", () => { const stream = new ReadableStream({ start(controller) { // Send a server notification via SSE - const event = 'event: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n'; + const event = "event: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; controller.enqueue(encoder.encode(event)); } }); @@ -237,7 +237,7 @@ describe("StreamableHTTPClientTransport", () => { (global.fetch as jest.Mock) .mockResolvedValueOnce({ - ok: true, + ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: makeStream("request1") @@ -263,13 +263,13 @@ describe("StreamableHTTPClientTransport", () => { // Both streams should have delivered their messages expect(messageSpy).toHaveBeenCalledTimes(2); - + // Verify received messages without assuming specific order expect(messageSpy.mock.calls.some(call => { const msg = call[0]; return msg.id === "request1" && msg.result?.id === "request1"; })).toBe(true); - + expect(messageSpy.mock.calls.some(call => { const msg = call[0]; return msg.id === "request2" && msg.result?.id === "request2"; @@ -281,7 +281,7 @@ describe("StreamableHTTPClientTransport", () => { 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'; + const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; controller.enqueue(encoder.encode(event)); controller.close(); } @@ -313,4 +313,67 @@ describe("StreamableHTTPClientTransport", () => { const lastCall = calls[calls.length - 1]; expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); }); -}); \ No newline at end of file + + it("should throw error when invalid content-type is received", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue("invalid text response"); + controller.close(); + } + }); + + const errorSpy = jest.fn(); + transport.onerror = errorSpy; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/plain" }), + body: stream + }); + + await transport.start(); + await expect(transport.send(message)).rejects.toThrow("Unexpected content type: text/plain"); + expect(errorSpy).toHaveBeenCalled(); + }); + + + it("should always send specified custom headers", async () => { + const requestInit = { + headers: { + "X-Custom-Header": "CustomValue" + } + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + ((global.fetch as jest.Mock)).mockImplementation( + async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + ); + + await transport.start(); + + await transport.openSseStream(); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); + + requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; + + await transport.send({ jsonrpc: "2.0", method: "test", params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("SecondCustomValue"); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 0c667e35..5ea537c7 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,7 +1,8 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { EventSourceParserStream } from "eventsource-parser/stream"; + export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -17,16 +18,16 @@ export class StreamableHTTPError extends Error { export type StreamableHTTPClientTransportOptions = { /** * An OAuth client provider to use for authentication. - * + * * When an `authProvider` is specified and the connection is started: * 1. The connection is attempted with any existing access token from the `authProvider`. * 2. If the access token has expired, the `authProvider` is used to refresh the token. * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * + * * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection. - * + * * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * + * * `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected. */ authProvider?: OAuthClientProvider; @@ -83,7 +84,7 @@ export class StreamableHTTPClientTransport implements Transport { return await this._startOrAuthStandaloneSSE(); } - private async _commonHeaders(): Promise { + private async _commonHeaders(): Promise { const headers: HeadersInit = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); @@ -96,24 +97,25 @@ export class StreamableHTTPClientTransport implements Transport { headers["mcp-session-id"] = this._sessionId; } - return headers; + return new Headers( + { ...headers, ...this._requestInit?.headers } + ); } private async _startOrAuthStandaloneSSE(): 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 commonHeaders = await this._commonHeaders(); - const headers = new Headers(commonHeaders); - headers.set('Accept', 'text/event-stream'); + 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); + headers.set("last-event-id", this._lastEventId); } const response = await fetch(this._url, { - method: 'GET', + method: "GET", headers, signal: this._abortController?.signal, }); @@ -124,12 +126,10 @@ export class StreamableHTTPClientTransport implements Transport { return await this._authThenStart(); } - const error = new StreamableHTTPError( + throw new StreamableHTTPError( response.status, `Failed to open SSE stream: ${response.statusText}`, ); - this.onerror?.(error); - throw error; } // Successful connection, handle the SSE stream as a standalone listener @@ -144,42 +144,32 @@ export class StreamableHTTPClientTransport implements Transport { if (!stream) { return; } - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); - const reader = eventStream.getReader(); const processStream = async () => { - try { - while (true) { - const { done, value: event } = await reader.read(); - if (done) { - break; - } + // Create a pipeline: binary stream -> text decoder -> SSE parser + const eventStream = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()); - // 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); - } + 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); } } - } catch (error) { - this.onerror?.(error as Error); } }; - processStream(); + processStream().catch(err => this.onerror?.(err)); } async start() { @@ -215,8 +205,7 @@ export class StreamableHTTPClientTransport implements Transport { async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { try { - const commonHeaders = await this._commonHeaders(); - const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); headers.set("accept", "application/json, text/event-stream"); @@ -261,20 +250,13 @@ export class StreamableHTTPClientTransport implements Transport { // Get original message(s) for detecting request IDs const messages = Array.isArray(message) ? message : [message]; - // Extract IDs from request messages for tracking responses - const requestIds = messages.filter(msg => 'method' in msg && 'id' in msg) - .map(msg => 'id' in msg ? msg.id : undefined) - .filter(id => id !== undefined); - - // If we have request IDs and an SSE response, create a unique stream ID - const hasRequests = requestIds.length > 0; + const hasRequests = messages.filter(msg => "method" in msg && "id" in msg && msg.id !== undefined).length > 0; // Check the response type const contentType = response.headers.get("content-type"); if (hasRequests) { if (contentType?.includes("text/event-stream")) { - // For streaming responses, create a unique stream ID based on request IDs this._handleSseStream(response.body); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses @@ -286,6 +268,11 @@ export class StreamableHTTPClientTransport implements Transport { for (const msg of responseMessages) { this.onmessage?.(msg); } + } else { + throw new StreamableHTTPError( + -1, + `Unexpected content type: ${contentType}`, + ); } } } catch (error) { @@ -296,7 +283,7 @@ export class StreamableHTTPClientTransport implements Transport { /** * Opens SSE stream to receive messages from the server. - * + * * This allows the server to push messages to the client without requiring the client * to first send a request via HTTP POST. Some servers may not support this feature. * If authentication is required but fails, this method will throw an UnauthorizedError. @@ -309,4 +296,4 @@ export class StreamableHTTPClientTransport implements Transport { } await this._startOrAuthStandaloneSSE(); } -} \ No newline at end of file +} From 632b836c51c3dd197e8bccf9400d926b6db722cb Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 09:43:42 +0100 Subject: [PATCH 45/60] remove order assumption for batch responses in test --- src/server/streamableHttp.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 74868979..926c4306 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1320,7 +1320,7 @@ describe("StreamableHTTPServerTransport", () => { body: JSON.stringify(batchMessages), }); - // Mock sequential responses - send them in specific order + // Mock responses without enforcing specific order jsonResponseTransport.onmessage = (message) => { if ('method' in message && 'id' in message) { const responseMessage: JSONRPCMessage = { @@ -1328,13 +1328,7 @@ describe("StreamableHTTPServerTransport", () => { result: { value: `result-for-${message.id}` }, id: message.id, }; - // Send responses in order - req1 first, then req2 - if (message.id === 'req1') { - void jsonResponseTransport.send(responseMessage); - } else { - // Add a tiny delay to req2 to ensure it's processed after req1 - setTimeout(() => void jsonResponseTransport.send(responseMessage), 5); - } + void jsonResponseTransport.send(responseMessage); } }; @@ -1351,8 +1345,15 @@ describe("StreamableHTTPServerTransport", () => { }) ); + // Verify response was sent but don't assume specific order expect(mockResponse.end).toHaveBeenCalled(); - expect(mockResponse.end.mock.calls[0][0]).toContain("result-for-req2"); + 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" } })); }); }); From 962a9c959ae86a1635afdd996150ce011c3a1805 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 13:01:09 +0100 Subject: [PATCH 46/60] clean up and docs fixes --- src/examples/README.md | 57 +++++++++++++++--- .../server/jsonResponseStreamableHttp.ts | 27 +++++++-- src/examples/server/simpleStreamableHttp.ts | 23 ++++++- src/server/.streamableHttp.test.ts.swp | Bin 16384 -> 0 bytes src/server/streamableHttp.test.ts | 11 ++-- 5 files changed, 99 insertions(+), 19 deletions(-) delete mode 100644 src/server/.streamableHttp.test.ts.swp diff --git a/src/examples/README.md b/src/examples/README.md index 937159c3..cc6af51c 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -20,15 +20,43 @@ The server will start on port 3000. You can test the initialization and tool cal ```bash # Initialize the server and get the session ID from headers -SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ +SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') echo "Session ID: $SESSION_ID" # Call the greet tool using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" \ +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"mcp.call_tool","params":{"name":"greet","arguments":{"name":"World"}},"id":"2"}' \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "greet", + "arguments": { + "name": "World" + } + }, + "id": "2" + }' \ http://localhost:3000/mcp ``` @@ -52,10 +80,25 @@ The server will start on port 3000. You can test the initialization and tool lis ```bash # First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' \ +SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID" +echo "Session ID: $SESSION_ID # Then list tools using the saved session ID curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 9b053631..1d322112 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -149,10 +149,29 @@ function isInitializeRequest(body: unknown): boolean { // Start the server const PORT = 3000; app.listen(PORT, () => { - console.log(`MCP Streamable HTTP Server with JSON responses listening on port ${PORT}`); - console.log(`Server is running. Press Ctrl+C to stop.`); - console.log(`Initialize with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' http://localhost:${PORT}/mcp`); - console.log(`Then call tool with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "mcp-session-id: YOUR_SESSION_ID" -d '{"jsonrpc":"2.0","method":"mcp.call_tool","params":{"name":"greet","arguments":{"name":"World"}},"id":"2"}' http://localhost:${PORT}/mcp`); + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); + console.log(`Initialize session with the command below id you are using curl for testing: + ----------------------------- + SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\\r') + echo "Session ID: $SESSION_ID" + -----------------------------`); }); // Handle server shutdown diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 74c3ef53..5b228cbd 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -186,7 +186,28 @@ function isInitializeRequest(body: unknown): boolean { const PORT = 3000; app.listen(PORT, () => { console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - console.log(`Test with: curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}},"id":"1"}' http://localhost:${PORT}/mcp`); + console.log(`Initialize session with the command below id you are using curl for testing: + ----------------------------- + SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\\r') + echo "Session ID: $SESSION_ID" + -----------------------------`); }); // Handle server shutdown diff --git a/src/server/.streamableHttp.test.ts.swp b/src/server/.streamableHttp.test.ts.swp deleted file mode 100644 index 4ab20fa3582429b76d0a1f7da19f2c2ec77faf7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI3%a0tz9mg9&c=~~Yxqw89;lK>c^z5u5h_SI@*T62);?+KgWXnOl-8DPC?x}8f z*X(*@1{5Xv0}{gl@sL6ULXH%H00B`BNF)l8LZlpkLoP^>B7q2r0(JrkIN$23>gj2E zy@nHzYU!gL_phGcs`}NV%S&VTOwW*;?Use-Qp>va52u!Ie4}n{{j+8Hj5*@W)F z1$2t@$mTTRHcv{^G%t+;MuD?cV5_xryfr35UsJi7T=uQSvyEuN83l|2MggOMQNSo* z6fg=H1&jh;0|j`r&3XX!I9Ju>KK1vU!r#;C3Dx^S!TzbT`|ACB1^Xw;9;x?>Dg&L* zf0aE}_TLul|50{*3UHGuu#W$+vL8|LPZaDQDf_CjU-=FBL&yKOvNL7>Nx}XvW!EPg zrwjH|%HC7)SI!$v|D>`9%Klct{-Lt3Df{F%N8^8>?B7=QUlr`?wfSQdFbWt2i~>dh zqkvJsC}0#Y3K#{90!D#S1su<^&WB~2*e~Pzzuy19y4|ww2eY6GKHX+ne+93AUjhzX zFaxdwTfnKUmh}esHFycU2p$AS!2xg^*bDZ6Yr(r)5FVTWKLpN zfBy}50vrJya0o1d1#l@ig)@TRgJ;2U@B=Ubt^nJ?M>vD{2lzX94Lk!L10lEp)W9cb z*AKuy!OP&s;78yP*a@}+(e_&b9+E!2&^Unys!4BC7HavNQ-;3cd9x3vNVNQ=CsJOh-kd{iF;C7EnqGgX&@?u6#?DWd3IP3+v5(4yD4bkP;`N!?+yaA`B^{)=ik$vSgM zwMK5)gVfVRhW#to(FV1gToqh`Y9#1k(6r%u=mW!ktX@xhx?+UL;bDhMqC}JEJPwM( zmeHt0J>z3zI=C83BLg`bq+%dVIjeMnQa0%X0rkG?^KMn8^F@YXF^-*;Uk=)RwBs}C z3GbnD*>vD;G;WJoZQvz3PLK@UL@`pae2dKcObLe#cm@X{os;TXUpX)FfA1tlnr}l- zk@E-3l+hSo6&@iSw`6)B@%m!Q7&?*EIef+`5aOk`9hYdeGOr@fI1IB(4^9)obKXOD zy%lls(G~OVZ9yO$c_Qaur^9J{yW@7NC6RPw>DpV`BV_(Ho4~RuM7Ze8gO>SK=|<8y z1YM>uPfGuj_cVoWCD##p<|FQuqK0u>iGUS=7xoqxr}tIWn94jO(q?px%nxsf zE-cK>r`JXo^ui%^iAxkK4O$c_P|FatLTD2?-;3#RXp#kPlT>~oJN0(UEc7yBkB*R) zt9|Ao?Xq&Mf2OkMJHh~^iYf2M41Z*yM7{$|H>A%Fvv7@xpPLkups#YVHlxrt4clQ_ z*5wT?wCD3&@N@+@mzHIr>!TaWZY352S&|!s!TNFYKNs@326Rh?Q;{ z!0(Xi$V()eOYL3}9kMC^LI$EfT2z^%8i{kN0AhR(YVD#9X|f{Mz4@`0=`x3%KQrr8-g~!Kl6>g-Gx>xpE4z3zel(Fn z?dpZznkAFgtc#oVD1q4jKaaii6!zR=|F6F4jup=I0z1aYr#3-9qjwx z1W$t=*bQ3XH1_u=!H3{=@C5i7SOwRE>wwt*KM4+kc`yS+Iqn4JF$x$3i~>dhqkvJs zC}0#Y3K#{xyb9o0hhU4SPj~cReQaF~nc5hOZFJ3MA^t~i*&#H(U5IZ&6WB58Q&qW@ z-YBKgRB+gpP_HZz>;&iK(SARngm)>CVbgCwIKxZ0*!YH>)T9cN+P>hybzP;uL0M+8 zx0j`eX(L|}&yR7;AFS(avx6o(WIg#+3LEedRkcoXn?7Pf(CnBdJAxm!(FO { it("should return JSON response for a single request", async () => { const requestMessage: JSONRPCMessage = { jsonrpc: "2.0", - method: "test", + method: "tools/list", params: {}, id: "test-req-id", }; @@ -1306,8 +1306,8 @@ describe("StreamableHTTPServerTransport", () => { it("should return JSON response for batch requests", async () => { const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "test2", params: {}, id: "req2" }, + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req1" }, + { jsonrpc: "2.0", method: "tools/call", params: {}, id: "req2" }, ]; const req = createMockRequest({ @@ -1334,9 +1334,6 @@ describe("StreamableHTTPServerTransport", () => { await jsonResponseTransport.handleRequest(req, mockResponse); - // Wait for all promises to resolve - give it enough time - await new Promise(resolve => setTimeout(resolve, 100)); - // Should respond with application/json header expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, @@ -1350,7 +1347,7 @@ describe("StreamableHTTPServerTransport", () => { 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" } })); From 569c9287941abe238c177755783fe88c865149f5 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 13:04:58 +0100 Subject: [PATCH 47/60] remove promise from test --- src/server/streamableHttp.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 640c843d..ad80ea62 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1272,20 +1272,14 @@ describe("StreamableHTTPServerTransport", () => { if ('method' in message && 'id' in message) { const responseMessage: JSONRPCMessage = { jsonrpc: "2.0", - result: { value: "test-result" }, + result: { value: `test-result` }, id: message.id, }; - setTimeout(() => { - void jsonResponseTransport.send(responseMessage); - }, 10); + void jsonResponseTransport.send(responseMessage); } }; await jsonResponseTransport.handleRequest(req, mockResponse); - - // Wait for all promises to resolve - await new Promise(resolve => setTimeout(resolve, 50)); - // Should respond with application/json header expect(mockResponse.writeHead).toHaveBeenCalledWith( 200, From 111586c523389ed72a0f80a37232e55d35c78d04 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:23:34 +0100 Subject: [PATCH 48/60] support sse though get --- src/examples/client/simpleStreamableHttp.ts | 112 +++++++++------ .../standaloneSseWithGetStreamableHttp.ts | 136 ++++++++++++++++++ src/server/streamableHttp.test.ts | 113 ++++++++++++++- src/server/streamableHttp.ts | 97 ++++++++++++- 4 files changed, 407 insertions(+), 51 deletions(-) create mode 100644 src/examples/server/standaloneSseWithGetStreamableHttp.ts diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index b17add14..0321bea4 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -11,7 +11,8 @@ import { GetPromptResultSchema, ListResourcesRequest, ListResourcesResultSchema, - LoggingMessageNotificationSchema + LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema } from '../../types.js'; async function main(): Promise { @@ -24,51 +25,80 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); + const supportsStandaloneSse = false; // Connect the client using the transport and initialize the server await client.connect(transport); + console.log('Connected to MCP server'); + // Open a standalone SSE stream to receive server-initiated messages + console.log('Opening SSE stream to receive server notifications...'); + try { + await transport.openSseStream(); + const supportsStandaloneSse = false; + console.log('SSE stream established successfully. Waiting for notifications...'); + } + catch (error) { + console.error('Failed to open SSE stream:', error); + } + + // Set up notification handlers for server-initiated messages client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); }); - - - console.log('Connected to MCP server'); - // List available tools - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); - - // Call the 'greet' tool - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); - - // Call the new 'multi-greet' tool - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } - } - }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { - if (item.type === 'text') { - console.log(`- ${item.text}`); - } + client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { + console.log(`Resource list changed notification received!`); + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); + console.log('Available resources count:', resourcesResult.resources.length); }); + // List available tools + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + console.log('Available tools:', toolsResult.tools); + + if (toolsResult.tools.length === 0) { + console.log('No tools available from the server'); + } else { + // Call the 'greet' tool + const greetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } + }; + const greetResult = await client.request(greetRequest, CallToolResultSchema); + console.log('Greeting result:', greetResult.content[0].text); + + // Call the new 'multi-greet' tool + console.log('\nCalling multi-greet tool (with notifications)...'); + const multiGreetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'multi-greet', + arguments: { name: 'MCP User' } + } + }; + const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); + console.log('Multi-greet results:'); + multiGreetResult.content.forEach(item => { + if (item.type === 'text') { + console.log(`- ${item.text}`); + } + }); + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } + // List available prompts try { const promptsRequest: ListPromptsRequest = { @@ -107,9 +137,11 @@ async function main(): Promise { } catch (error) { console.log(`Resources not supported by this server (${error})`); } + if (supportsStandaloneSse) { + // Instead of closing immediately, keep the connection open to receive notifications + console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); + } - // Close the connection - await client.close(); } main().catch((error: unknown) => { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts new file mode 100644 index 00000000..bfe5b2af --- /dev/null +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -0,0 +1,136 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { ReadResourceResult } from '../../types.js'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0', +}, { + capabilities: { + resources: { + listChanged: true, // Support notifications for resource list changes + }, + } +}); + +// Store transports by session ID to send notifications +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const addResource = (name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.resource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }], + }; + } + ); + +}; + +addResource('example-resource', 'Initial content for example-resource'); + +const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + addResource(name, `Content for ${name}`); +}, 5000); // Change resources every 5 seconds for testing + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + // Connect the transport to the MCP server + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + + // Store the transport by session ID for future requests + if (transport.sessionId) { + transports[transport.sessionId] = transport; + } + return; // Already handled + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Helper function to detect initialize requests +function isInitializeRequest(body: unknown): boolean { + if (Array.isArray(body)) { + return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); + } + return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; +} + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + clearInterval(resourceChangeInterval); + await server.close(); + process.exit(0); +}); \ No newline at end of file diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index ad80ea62..69a77f44 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -564,22 +564,123 @@ describe("StreamableHTTPServerTransport", () => { mockResponse.writeHead.mockClear(); }); - it("should reject GET requests for SSE with 405 Method Not Allowed", async () => { + it("should accept GET requests for SSE with proper Accept header", async () => { const req = createMockRequest({ method: "GET", headers: { - "accept": "application/json, text/event-stream", + "accept": "text/event-stream", "mcp-session-id": transport.sessionId, }, }); await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(405, expect.objectContaining({ - "Allow": "POST, DELETE" + expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "mcp-session-id": transport.sessionId, })); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Method not allowed')); + }); + + it("should reject GET requests without Accept: text/event-stream header", async () => { + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "application/json", + "mcp-session-id": transport.sessionId, + }, + }); + + await transport.handleRequest(req, mockResponse); + + expect(mockResponse.writeHead).toHaveBeenCalledWith(406); + expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Not Acceptable: Client must accept text/event-stream"')); + }); + + it("should send server-initiated requests to GET SSE stream", async () => { + // Open a standalone SSE stream with GET + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse = createMockResponse(); + await transport.handleRequest(req, sseResponse); + + // Send a notification without a related request ID + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/resources/updated", + params: { uri: "someuri" } + }; + + await transport.send(notification); + + // Verify notification was sent on SSE stream + expect(sseResponse.write).toHaveBeenCalledWith( + expect.stringContaining(`event: message\ndata: ${JSON.stringify(notification)}\n\n`) + ); + }); + + it("should not close GET SSE stream after sending server requests or notifications", async () => { + // Open a standalone SSE stream + const req = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse = createMockResponse(); + await transport.handleRequest(req, sseResponse); + + // Send multiple notifications + const notification1: JSONRPCMessage = { jsonrpc: "2.0", method: "event1", params: {} }; + const notification2: JSONRPCMessage = { jsonrpc: "2.0", method: "event2", params: {} }; + + await transport.send(notification1); + await transport.send(notification2); + + // Stream should remain open + expect(sseResponse.end).not.toHaveBeenCalled(); + }); + + it("should reject second GET SSE stream for the same session", async () => { + // Open first SSE stream - should succeed + const req1 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse1 = createMockResponse(); + await transport.handleRequest(req1, sseResponse1); + + // Try to open a second SSE stream - should be rejected + const req2 = createMockRequest({ + method: "GET", + headers: { + "accept": "text/event-stream", + "mcp-session-id": transport.sessionId, + }, + }); + + const sseResponse2 = createMockResponse(); + await transport.handleRequest(req2, sseResponse2); + + // First stream should be good + expect(sseResponse1.writeHead).toHaveBeenCalledWith(200, expect.anything()); + + // Second stream should get 409 Conflict + expect(sseResponse2.writeHead).toHaveBeenCalledWith(409); + expect(sseResponse2.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Conflict: Only one SSE stream is allowed per session"')); }); it("should reject POST requests without proper Accept header", async () => { diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index e8844529..58fc79c0 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -68,6 +68,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; + private _sseStreamKey = "standalone-sse"; sessionId?: string | undefined; @@ -97,6 +98,8 @@ export class StreamableHTTPServerTransport implements Transport { async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise { if (req.method === "POST") { await this.handlePostRequest(req, res, parsedBody); + } else if (req.method === "GET") { + await this.handleGetRequest(req, res); } else if (req.method === "DELETE") { await this.handleDeleteRequest(req, res); } else { @@ -105,12 +108,80 @@ export class StreamableHTTPServerTransport implements Transport { } /** - * Handles unsupported requests (GET, PUT, PATCH, etc.) - * For now we support only POST and DELETE requests. Support for GET for SSE connections will be added later. + * Handles GET requests for SSE stream + */ + private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { + // The client MUST include an Accept header, listing text/event-stream as a supported content type. + const acceptHeader = req.headers.accept; + if (!acceptHeader?.includes("text/event-stream")) { + res.writeHead(406).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Not Acceptable: Client must accept text/event-stream" + }, + id: null + })); + return; + } + + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + if (!this.validateSession(req, res)) { + return; + } + + // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, + // or else return HTTP 405 Method Not Allowed + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }; + + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + // The server MAY include a Last-Event-ID header in the response to this HTTP GET. + // Resumability will be supported in the future + + // Check if there's already an active standalone SSE stream for this session + const existingStream = this._responseMapping.get(this._sseStreamKey); + + if (existingStream !== undefined) { + // Only one GET SSE stream is allowed per session + res.writeHead(409).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Conflict: Only one SSE stream is allowed per session" + }, + id: null + })); + return; + } + + res.writeHead(200, headers); + + // Store the response for this request so we can use it for standalone server notifications + // This response doesn't have an associated request ID, so we'll use a special string to track it + this._responseMapping.set(this._sseStreamKey, res); + + // Set up close handler for client disconnects + res.on("close", () => { + // Clean up resources associated with this connection + this._responseMapping.delete(this._sseStreamKey); + }); + } + + /** + * Handles unsupported requests (PUT, PATCH, etc.) */ private async handleUnsupportedRequest(res: ServerResponse): Promise { res.writeHead(405, { - "Allow": "POST, DELETE" + "Allow": "GET, POST, DELETE" }).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -379,8 +450,25 @@ export class StreamableHTTPServerTransport implements Transport { // If the message is a response, use the request ID from the message requestId = message.id; } + + // Check if this message should be sent on the standalone SSE stream (no request ID) + // Ignore notifications from tools (which have relatedRequestId set) + // Those will be sent via dedicated response SSE streams if (requestId === undefined) { - throw new Error("No request ID provided for the message"); + // For standalone SSE streams, we can only send requests and notifications + if ('result' in message || 'error' in message) { + throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); + } + + const standaloneStream = this._responseMapping.get(this._sseStreamKey); + if (standaloneStream === undefined) { + // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream + return; + } + + // Send the message to the standalone SSE stream + standaloneStream.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + return; } // Get the response for this request @@ -389,7 +477,6 @@ export class StreamableHTTPServerTransport implements Transport { throw new Error(`No connection established for request ID: ${String(requestId)}`); } - if (!this._enableJsonResponse) { response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); } From 0c1a4f22be712b787275b422d604c2817765e084 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:34:28 +0100 Subject: [PATCH 49/60] add get sse to README --- src/examples/README.md | 119 +++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index cc6af51c..6ef35ba4 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -2,12 +2,79 @@ This directory contains example implementations of MCP clients and servers using the TypeScript SDK. +## Table of Contents + +- [Streamable HTTP Servers - Single Node Deployment](#streamable-http---single-node-deployment-with-basic-session-state-management) + - [Simple Server with Streamable HTTP](#simple-server-with-streamable-http-transport-serversimplestreamablehttpts) + - [Server Supporting SSE via GET](#server-supporting-with-sse-via-get-serverstandalonessewithgetstreamablehttpts) + - [Server with JSON Response Mode](#server-with-json-response-mode-serverjsonresponsestreamablehttpts) +- [Client Example - Streamable HTTP](#client-clientsimplestreamablehttpts) + ## Streamable HTTP - single node deployment with basic session state management -Multi node with stete management example will be added soon after we add support. +Multi node with state management example will be added soon after we add support. + + +### Simple Server with Streamable Http transport (`server/simpleStreamableHttp.ts`) + +A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: + +- A simple `greet` tool that returns a greeting for a name +- A `greeting-template` prompt that generates a greeting template +- A static `greeting-resource` resource + +#### Running the server + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts +``` + +The server will start on port 3000. You can test the initialization and tool listing: + +```bash +# First initialize the server and save the session ID to a variable +SESSION_ID=$(curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Accept: text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": {}, + "protocolVersion": "2025-03-26", + "clientInfo": { + "name": "test", + "version": "1.0.0" + } + }, + "id": "1" + }' \ + -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') +echo "Session ID: $SESSION_ID + +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ + http://localhost:3000/mcp +``` + +### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) + +An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). + +#### Running the server + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +The server will start on port 3000 and automatically create new resources every 5 seconds. ### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) +This is not recommented way to use the transport, as its quite limiting and not supporting features like logging and progress notifications on tool execution. A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. #### Running the server @@ -60,52 +127,6 @@ curl -X POST \ http://localhost:3000/mcp ``` -Note that in this example, we're using plain JSON response mode by setting `Accept: application/json` header. - -### Server (`server/simpleStreamableHttp.ts`) - -A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: - -- A simple `greet` tool that returns a greeting for a name -- A `greeting-template` prompt that generates a greeting template -- A static `greeting-resource` resource - -#### Running the server - -```bash -npx tsx src/examples/server/simpleStreamableHttp.ts -``` - -The server will start on port 3000. You can test the initialization and tool listing: - -```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID - -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp -``` ### Client (`client/simpleStreamableHttp.ts`) @@ -123,8 +144,4 @@ npx tsx src/examples/client/simpleStreamableHttp.ts Make sure the server is running before starting the client. -## Notes -- These examples demonstrate the basic usage of the Streamable HTTP transport -- The server manages sessions between the calls -- The client handles both direct HTTP responses and SSE streaming responses From 755534ee1ae685c963d96306431990cb67a89844 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 14:41:48 +0100 Subject: [PATCH 50/60] clean up examples README --- src/examples/README.md | 92 +++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 51 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 6ef35ba4..22e36de9 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -9,6 +9,7 @@ This directory contains example implementations of MCP clients and servers using - [Server Supporting SSE via GET](#server-supporting-with-sse-via-get-serverstandalonessewithgetstreamablehttpts) - [Server with JSON Response Mode](#server-with-json-response-mode-serverjsonresponsestreamablehttpts) - [Client Example - Streamable HTTP](#client-clientsimplestreamablehttpts) +- [Useful bash commands for testing](#useful-commands-for-testing) ## Streamable HTTP - single node deployment with basic session state management @@ -31,35 +32,6 @@ npx tsx src/examples/server/simpleStreamableHttp.ts The server will start on port 3000. You can test the initialization and tool listing: -```bash -# First initialize the server and save the session ID to a variable -SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID - -# Then list tools using the saved session ID -curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ - http://localhost:3000/mcp -``` - ### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). @@ -83,10 +55,31 @@ A simple MCP server that uses the Streamable HTTP transport with JSON response m npx tsx src/examples/server/jsonResponseStreamableHttp.ts ``` -The server will start on port 3000. You can test the initialization and tool calling: + +### Client (`client/simpleStreamableHttp.ts`) + +A client that connects to the server, initializes it, and demonstrates how to: + +- List available tools and call the `greet` tool +- List available prompts and get the `greeting-template` prompt +- List available resources + +#### Running the client ```bash -# Initialize the server and get the session ID from headers +npx tsx src/examples/client/simpleStreamableHttp.ts +``` + +Make sure the server is running before starting the client. + + +### Useful commands for testing + +#### Initialize +Streamable HTTP transport requires to do the initialization first. + +```bash +# First initialize the server and save the session ID to a variable SESSION_ID=$(curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ @@ -105,8 +98,24 @@ SESSION_ID=$(curl -X POST \ "id": "1" }' \ -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\r') -echo "Session ID: $SESSION_ID" +echo "Session ID: $SESSION_ID +``` + +Once thre is a session we can send POST requests + +#### List tools +```bash +# Then list tools using the saved session ID +curl -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SESSION_ID" \ + -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":"2"}' \ + http://localhost:3000/mcp +``` + +#### Call tool + +```bash # Call the greet tool using the saved session ID curl -X POST \ -H "Content-Type: application/json" \ @@ -126,22 +135,3 @@ curl -X POST \ }' \ http://localhost:3000/mcp ``` - - -### Client (`client/simpleStreamableHttp.ts`) - -A client that connects to the server, initializes it, and demonstrates how to: - -- List available tools and call the `greet` tool -- List available prompts and get the `greeting-template` prompt -- List available resources - -#### Running the client - -```bash -npx tsx src/examples/client/simpleStreamableHttp.ts -``` - -Make sure the server is running before starting the client. - - From 5c5a33021cf3e09123895744882fcf2948ad331b Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 15:28:39 +0100 Subject: [PATCH 51/60] add GET to examples servers --- src/client/streamableHttp.ts | 1 - src/examples/client/simpleStreamableHttp.ts | 4 ++-- src/examples/server/jsonResponseStreamableHttp.ts | 7 +++++++ src/examples/server/simpleStreamableHttp.ts | 13 +++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 5ea537c7..ae54bb26 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -131,7 +131,6 @@ export class StreamableHTTPClientTransport implements Transport { `Failed to open SSE stream: ${response.statusText}`, ); } - // Successful connection, handle the SSE stream as a standalone listener this._handleSseStream(response.body); } catch (error) { diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 0321bea4..3eb2485d 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -25,7 +25,7 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - const supportsStandaloneSse = false; + let supportsStandaloneSse = false; // Connect the client using the transport and initialize the server await client.connect(transport); @@ -34,7 +34,7 @@ async function main(): Promise { console.log('Opening SSE stream to receive server notifications...'); try { await transport.openSseStream(); - const supportsStandaloneSse = false; + supportsStandaloneSse = true; console.log('SSE stream established successfully. Waiting for notifications...'); } catch (error) { diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 1d322112..34ab65d1 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -138,6 +138,13 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// Handle GET requests for SSE streams according to spec +app.get('/mcp', async (req: Request, res: Response) => { + // Since this is a very simple example, we don't support GET requests for this server + // The spec requires returning 405 Method Not Allowed in this case + res.status(405).set('Allow', 'POST').send('Method Not Allowed'); +}); + // Helper function to detect initialize requests function isInitializeRequest(body: unknown): boolean { if (Array.isArray(body)) { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 5b228cbd..6e4b2548 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -174,6 +174,19 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); +// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + // Helper function to detect initialize requests function isInitializeRequest(body: unknown): boolean { if (Array.isArray(body)) { From 3dd4ff1542cf2a42332a18b4b6432b2433717770 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 15:53:05 +0100 Subject: [PATCH 52/60] fix hanging issue --- src/server/streamableHttp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 58fc79c0..081e1982 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -162,8 +162,9 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - - res.writeHead(200, headers); + // We need to send headers immediately as message will arrive much later, + // otherwise the client will just wait for the first message + res.writeHead(200, headers).flushHeaders(); // Store the response for this request so we can use it for standalone server notifications // This response doesn't have an associated request ID, so we'll use a special string to track it From 3adca3f3bc19c03b7e625d3dec273d2d7c0280b6 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Thu, 10 Apr 2025 16:01:21 +0100 Subject: [PATCH 53/60] fix tests --- src/server/streamableHttp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 69a77f44..d6f7cb8a 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -32,6 +32,7 @@ function createMockResponse(): jest.Mocked { emit: jest.fn().mockReturnThis(), getHeader: jest.fn(), setHeader: jest.fn(), + flushHeaders: jest.fn(), } as unknown as jest.Mocked; return response; } From 1a89c33e914430735574676741a1f9f58a1d72f2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 10 Apr 2025 11:15:55 -0400 Subject: [PATCH 54/60] Bump to version 1.9.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1b9fd44d..f348adf5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.0", + "version": "1.9.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 6a85e772b0e19d7e2b7d557ac2ce110a89fbb97c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 10 Apr 2025 17:12:30 -0400 Subject: [PATCH 55/60] Bump to version 1.10.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f348adf5..c7963ec6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.9.1", + "version": "1.10.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 5730f3e92af60f727e90529d407b30e9814cd8c4 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 10:19:09 +0100 Subject: [PATCH 56/60] fixes in README --- src/examples/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index 22e36de9..2ea1a596 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -16,7 +16,7 @@ This directory contains example implementations of MCP clients and servers using Multi node with state management example will be added soon after we add support. -### Simple Server with Streamable Http transport (`server/simpleStreamableHttp.ts`) +### Simple server with Streamable HTTP transport (`server/simpleStreamableHttp.ts`) A simple MCP server that uses the Streamable HTTP transport, implemented with Express. The server provides: @@ -32,7 +32,7 @@ npx tsx src/examples/server/simpleStreamableHttp.ts The server will start on port 3000. You can test the initialization and tool listing: -### Server supporting with SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) +### Server supporting SSE via GET (`server/standaloneSseWithGetStreamableHttp.ts`) An MCP server that demonstrates how to support SSE notifications via GET requests using the Streamable HTTP transport with Express. The server dynamically adds resources at regular intervals and supports notifications for resource list changes (server notifications are available through the standalone SSE connection established by GET request). @@ -46,9 +46,10 @@ The server will start on port 3000 and automatically create new resources every ### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) -This is not recommented way to use the transport, as its quite limiting and not supporting features like logging and progress notifications on tool execution. A simple MCP server that uses the Streamable HTTP transport with JSON response mode enabled, implemented with Express. The server provides a simple `greet` tool that returns a greeting for a name. +_NOTE: This demonstrates a server that does not use SSE at all. Note that this limits its support for MCP features; for example, it cannot provide logging and progress notifications for tool execution._ + #### Running the server ```bash @@ -101,8 +102,7 @@ SESSION_ID=$(curl -X POST \ echo "Session ID: $SESSION_ID ``` - -Once thre is a session we can send POST requests +Once a session is established, we can send POST requests: #### List tools ```bash From e3b4496579dc8406e4e72265d6d1b03985fa164e Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 10:42:41 +0100 Subject: [PATCH 57/60] clean up and small fixes to comments --- src/examples/client/simpleStreamableHttp.ts | 1 - src/examples/server/simpleStreamableHttp.ts | 2 +- src/examples/server/standaloneSseWithGetStreamableHttp.ts | 6 ------ src/server/streamableHttp.ts | 2 +- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 3eb2485d..027db881 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -30,7 +30,6 @@ async function main(): Promise { // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); - // Open a standalone SSE stream to receive server-initiated messages console.log('Opening SSE stream to receive server notifications...'); try { await transport.openSseStream(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6e4b2548..f0f74439 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -174,7 +174,7 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); -// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) app.get('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index bfe5b2af..f9d3696b 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -8,12 +8,6 @@ import { ReadResourceResult } from '../../types.js'; const server = new McpServer({ name: 'resource-list-changed-notification-server', version: '1.0.0', -}, { - capabilities: { - resources: { - listChanged: true, // Support notifications for resource list changes - }, - } }); // Store transports by session ID to send notifications diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 081e1982..05ba6146 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -136,7 +136,7 @@ export class StreamableHTTPServerTransport implements Transport { // or else return HTTP 405 Method Not Allowed const headers: Record = { "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }; From 18e4981bd3d0fde152659b95d4250828872718f8 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:06:51 +0100 Subject: [PATCH 58/60] move get sse stream from a separate method to automatic connection when notifications/initialized received --- src/client/streamableHttp.test.ts | 11 ++++---- src/client/streamableHttp.ts | 30 +++++++++------------ src/examples/client/simpleStreamableHttp.ts | 21 +++------------ src/server/streamableHttp.test.ts | 2 +- 4 files changed, 23 insertions(+), 41 deletions(-) diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 40f22139..1572e912 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -164,8 +164,7 @@ describe("StreamableHTTPClientTransport", () => { // We expect the 405 error to be caught and handled gracefully // This should not throw an error that breaks the transport await transport.start(); - await expect(transport.openSseStream()).rejects.toThrow("Failed to open SSE stream: Method Not Allowed"); - + await expect(transport["_startOrAuthStandaloneSSE"]()).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -209,7 +208,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -295,7 +294,7 @@ describe("StreamableHTTPClientTransport", () => { }); await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); await new Promise(resolve => setTimeout(resolve, 50)); // Now simulate attempting to reconnect @@ -306,7 +305,7 @@ describe("StreamableHTTPClientTransport", () => { body: null }); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); // Check that Last-Event-ID was included const calls = (global.fetch as jest.Mock).mock.calls; @@ -366,7 +365,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthStandaloneSSE"](); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index ae54bb26..ea69ee77 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isJSONRPCNotification, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; @@ -126,6 +126,12 @@ export class StreamableHTTPClientTransport implements Transport { return await this._authThenStart(); } + // 405 indicates that the server does not offer an SSE stream at GET endpoint + // This is an expected case that should not trigger an error + if (response.status === 405) { + return; + } + throw new StreamableHTTPError( response.status, `Failed to open SSE stream: ${response.statusText}`, @@ -243,6 +249,12 @@ export class StreamableHTTPClientTransport implements Transport { // If the response is 202 Accepted, there's no body to process if (response.status === 202) { + // if the accepted notification is initialized, we start the SSE stream + // if it's supported by the server + if (isJSONRPCNotification(message) && message.method === "notifications/initialized") { + // We don't need to handle 405 here anymore as it's handled in _startOrAuthStandaloneSSE + this._startOrAuthStandaloneSSE().catch(err => this.onerror?.(err)); + } return; } @@ -279,20 +291,4 @@ export class StreamableHTTPClientTransport implements Transport { throw error; } } - - /** - * Opens SSE stream to receive messages from the server. - * - * This allows the server to push messages to the client without requiring the client - * to first send a request via HTTP POST. Some servers may not support this feature. - * If authentication is required but fails, this method will throw an UnauthorizedError. - */ - async openSseStream(): Promise { - if (!this._abortController) { - throw new Error( - "StreamableHTTPClientTransport not started! Call connect() before openSseStream().", - ); - } - await this._startOrAuthStandaloneSSE(); - } } diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 027db881..c23c17f8 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -25,20 +25,10 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - let supportsStandaloneSse = false; - - // Connect the client using the transport and initialize the server + + // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); - console.log('Opening SSE stream to receive server notifications...'); - try { - await transport.openSseStream(); - supportsStandaloneSse = true; - console.log('SSE stream established successfully. Waiting for notifications...'); - } - catch (error) { - console.error('Failed to open SSE stream:', error); - } // Set up notification handlers for server-initiated messages client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { @@ -136,11 +126,8 @@ async function main(): Promise { } catch (error) { console.log(`Resources not supported by this server (${error})`); } - if (supportsStandaloneSse) { - // Instead of closing immediately, keep the connection open to receive notifications - console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); - } - + // Keep the connection open to receive notifications + console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); } main().catch((error: unknown) => { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index d6f7cb8a..85fcae2f 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -578,7 +578,7 @@ describe("StreamableHTTPServerTransport", () => { expect(mockResponse.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({ "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", "mcp-session-id": transport.sessionId, })); From 2c2cf5b4a2c09c336558cee3078320044e875c16 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:15:43 +0100 Subject: [PATCH 59/60] cleam up example client --- src/examples/client/simpleStreamableHttp.ts | 104 ++++++++++++-------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index c23c17f8..739e1164 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -12,7 +12,7 @@ import { ListResourcesRequest, ListResourcesResultSchema, LoggingMessageNotificationSchema, - ResourceListChangedNotificationSchema + ResourceListChangedNotificationSchema, } from '../../types.js'; async function main(): Promise { @@ -25,7 +25,7 @@ async function main(): Promise { const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp') ); - + // Connect the client using the transport and initialize the server await client.connect(transport); console.log('Connected to MCP server'); @@ -44,49 +44,12 @@ async function main(): Promise { console.log('Available resources count:', resourcesResult.resources.length); }); - // List available tools - try { - const toolsRequest: ListToolsRequest = { - method: 'tools/list', - params: {} - }; - const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); - console.log('Available tools:', toolsResult.tools); + // List and call tools + await listTools(client); - if (toolsResult.tools.length === 0) { - console.log('No tools available from the server'); - } else { - // Call the 'greet' tool - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } - } - }; - const greetResult = await client.request(greetRequest, CallToolResultSchema); - console.log('Greeting result:', greetResult.content[0].text); + await callGreetTool(client); + await callMultiGreetTool(client); - // Call the new 'multi-greet' tool - console.log('\nCalling multi-greet tool (with notifications)...'); - const multiGreetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'multi-greet', - arguments: { name: 'MCP User' } - } - }; - const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); - console.log('Multi-greet results:'); - multiGreetResult.content.forEach(item => { - if (item.type === 'text') { - console.log(`- ${item.text}`); - } - }); - } - } catch (error) { - console.log(`Tools not supported by this server (${error})`); - } // List available prompts try { @@ -130,6 +93,61 @@ async function main(): Promise { console.log('\nKeeping connection open to receive notifications. Press Ctrl+C to exit.'); } +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + console.log('Available tools:', toolsResult.tools); + if (toolsResult.tools.length === 0) { + console.log('No tools available from the server'); + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + return + } +} + +async function callGreetTool(client: Client): Promise { + try { + const greetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'MCP User' } + } + }; + const greetResult = await client.request(greetRequest, CallToolResultSchema); + console.log('Greeting result:', greetResult.content[0].text); + } catch (error) { + console.log(`Error calling greet tool: ${error}`); + } +} + +async function callMultiGreetTool(client: Client): Promise { + try { + console.log('\nCalling multi-greet tool (with notifications)...'); + const multiGreetRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: 'multi-greet', + arguments: { name: 'MCP User' } + } + }; + const multiGreetResult = await client.request(multiGreetRequest, CallToolResultSchema); + console.log('Multi-greet results:'); + multiGreetResult.content.forEach(item => { + if (item.type === 'text') { + console.log(`- ${item.text}`); + } + }); + } catch (error) { + console.log(`Error calling multi-greet tool: ${error}`); + } +} + main().catch((error: unknown) => { console.error('Error running MCP client:', error); process.exit(1); From 44330448da66295857172d0587ac3d33f1a94276 Mon Sep 17 00:00:00 2001 From: ihrpr Date: Fri, 11 Apr 2025 14:21:12 +0100 Subject: [PATCH 60/60] separate field on the class for standaloneSSE --- src/server/streamableHttp.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 05ba6146..ec8d2aa7 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -68,7 +68,7 @@ export class StreamableHTTPServerTransport implements Transport { private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - private _sseStreamKey = "standalone-sse"; + private _standaloneSSE: ServerResponse | undefined; sessionId?: string | undefined; @@ -148,9 +148,8 @@ export class StreamableHTTPServerTransport implements Transport { // Resumability will be supported in the future // Check if there's already an active standalone SSE stream for this session - const existingStream = this._responseMapping.get(this._sseStreamKey); - if (existingStream !== undefined) { + if (this._standaloneSSE !== undefined) { // Only one GET SSE stream is allowed per session res.writeHead(409).end(JSON.stringify({ jsonrpc: "2.0", @@ -166,14 +165,12 @@ export class StreamableHTTPServerTransport implements Transport { // otherwise the client will just wait for the first message res.writeHead(200, headers).flushHeaders(); - // Store the response for this request so we can use it for standalone server notifications - // This response doesn't have an associated request ID, so we'll use a special string to track it - this._responseMapping.set(this._sseStreamKey, res); + // Assing the response to the standalone SSE stream + this._standaloneSSE = res; // Set up close handler for client disconnects res.on("close", () => { - // Clean up resources associated with this connection - this._responseMapping.delete(this._sseStreamKey); + this._standaloneSSE = undefined; }); } @@ -461,14 +458,13 @@ export class StreamableHTTPServerTransport implements Transport { throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); } - const standaloneStream = this._responseMapping.get(this._sseStreamKey); - if (standaloneStream === undefined) { + if (this._standaloneSSE === undefined) { // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream return; } // Send the message to the standalone SSE stream - standaloneStream.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + this._standaloneSSE.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); return; }