From bda811a95037b71dae55fdc2f2fc55660aaf486b Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 30 Jun 2025 15:21:57 +0100 Subject: [PATCH 01/12] feat: Add CORS configuration for browser-based MCP clients - Add cors middleware to example servers with Mcp-Session-Id exposed - Add CORS documentation section to README - Configure minimal CORS settings (only expose required headers) This enables browser-based clients to connect to MCP servers by properly exposing the Mcp-Session-Id header required for session management. Reported-by: Jerome --- README.md | 20 +++++++++++++++++++ .../server/jsonResponseStreamableHttp.ts | 7 +++++++ .../server/simpleStatelessStreamableHttp.ts | 7 +++++++ .../sseAndStreamableHttpCompatibleServer.ts | 7 +++++++ 4 files changed, 41 insertions(+) diff --git a/README.md b/README.md index ac10e8cb..65189353 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,26 @@ app.listen(3000); > ); > ``` + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```typescript +import cors from 'cors'; + +// Add CORS middleware before your MCP routes +app.use(cors({ + origin: '*', // Configure appropriately for production + exposedHeaders: ['Mcp-Session-Id'] +})); +``` + +This configuration is necessary because: +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + #### Without Session Management (Stateless) For simpler use cases where session management isn't needed: diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 02d8c2de..04b14470 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -4,6 +4,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, isInitializeRequest } from '../../types.js'; +import cors from 'cors'; // Create an MCP server with implementation details @@ -81,6 +82,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 6fb2ae83..d235265c 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -3,6 +3,7 @@ import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import cors from 'cors'; const getServer = () => { // Create an MCP server with implementation details @@ -96,6 +97,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); try { diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index ded110a1..7b18578a 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -6,6 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js'; import { z } from 'zod'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import cors from 'cors'; /** * This example server demonstrates backwards compatibility with both: @@ -71,6 +72,12 @@ const getServer = () => { const app = express(); app.use(express.json()); +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use(cors({ + origin: '*', // Allow all origins - adjust as needed for production + exposedHeaders: ['Mcp-Session-Id'] +})); + // Store transports by session ID const transports: Record = {}; From 3d381df2bc53ad6fbe5eba80d6454d83d3d5ab13 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 12:35:52 +0100 Subject: [PATCH 02/12] Added cors settings to simpleStreamableHttp example server --- src/examples/server/simpleStreamableHttp.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3d523543..029fff77 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -11,6 +11,8 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from 'src/shared/auth.js'; import { checkResourceAllowed } from 'src/shared/auth-utils.js'; +import cors from 'cors'; + // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); const strictOAuth = process.argv.includes('--oauth-strict'); @@ -420,12 +422,18 @@ const getServer = () => { return server; }; -const MCP_PORT = 3000; -const AUTH_PORT = 3001; +const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; const app = express(); app.use(express.json()); +// Allow CORS all domains, expose the Mcp-Session-Id header +app.use(cors({ + origin: '*', // Allow all origins + exposedHeaders: ["Mcp-Session-Id"] +})); + // Set up OAuth if enabled let authMiddleware = null; if (useOAuth) { From 7c374fd81e67abc8889a6931cce702c6ae9fb628 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 12:40:28 +0100 Subject: [PATCH 03/12] Merged the CORS tips --- README.md | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 65189353..b91f004a 100644 --- a/README.md +++ b/README.md @@ -570,18 +570,7 @@ app.listen(3000); ``` > [!TIP] -> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. -> -> For example, in Node.js you can configure it like this: -> -> ```ts -> app.use( -> cors({ -> origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], -> exposedHeaders: ['mcp-session-id'], -> allowedHeaders: ['Content-Type', 'mcp-session-id'], -> }) -> ); +> When using this in a remote environment, make sure to allow the header parameter `mcp-session-id` in CORS. Otherwise, it may result in a `Bad Request: No valid session ID provided` error. Read the following section for examples. > ``` @@ -594,8 +583,10 @@ import cors from 'cors'; // Add CORS middleware before your MCP routes app.use(cors({ - origin: '*', // Configure appropriately for production + origin: '*', // Configure appropriately for production, for example: + // origin: ['https://your-remote-domain.com, https://your-other-remote-domain.com'], exposedHeaders: ['Mcp-Session-Id'] + allowedHeaders: ['Content-Type', 'mcp-session-id'], })); ``` From afb89715695faf2280c786804fdbc8d650aabbd9 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:44:35 +0100 Subject: [PATCH 04/12] Add ondelete hook to StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional ondelete callback that fires when DELETE request is received, providing the sessionId for cleanup handling. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 022d1a47..bf3a93c9 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -133,6 +133,7 @@ export class StreamableHTTPServerTransport implements Transport { sessionId?: string; onclose?: () => void; + ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -538,6 +539,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } + this.ondelete?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 52e22ad7cb4d7fc1197976929f2e305dc6d23579 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 14:57:41 +0100 Subject: [PATCH 05/12] Refactor ondelete hook to onsessionclosed in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed ondelete callback to onsessionclosed for better clarity - Moved callback from public property to private field following options pattern - Added comprehensive JSDoc documentation explaining the callback's purpose - Updated DELETE request handler to use the new callback structure 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index bf3a93c9..37164c86 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -49,6 +49,18 @@ export interface StreamableHTTPServerTransportOptions { */ onsessioninitialized?: (sessionId: string) => void; + /** + * A callback for session close events + * This is called when the server closes a session due to a DELETE request. + * Useful in cases when you need to clean up resources associated with the session. + * Note that this is different from the transport closing, if you are handling + * HTTP requests from multiple nodes you might want to close each + * StreamableHTTPServerTransport after a request is completed while still keeping the + * session open/running. + * @param sessionId The session ID that was closed + */ + onsessionclosed?: (sessionId: string) => void; + /** * If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. @@ -127,13 +139,13 @@ export class StreamableHTTPServerTransport implements Transport { private _standaloneSseStreamId: string = '_GET_stream'; private _eventStore?: EventStore; private _onsessioninitialized?: (sessionId: string) => void; + private _onsessionclosed?: (sessionId: string) => void; private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; sessionId?: string; onclose?: () => void; - ondelete?: (sessionId: string) => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; @@ -142,6 +154,7 @@ export class StreamableHTTPServerTransport implements Transport { this._enableJsonResponse = options.enableJsonResponse ?? false; this._eventStore = options.eventStore; this._onsessioninitialized = options.onsessioninitialized; + this._onsessionclosed = options.onsessionclosed; this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; @@ -539,7 +552,7 @@ export class StreamableHTTPServerTransport implements Transport { if (!this.validateProtocolVersion(req, res)) { return; } - this.ondelete?.(this.sessionId!); + this._onsessionclosed?.(this.sessionId!); await this.close(); res.writeHead(200).end(); } From 61052b1fa89963c1cf5f038e2972fd85cf19f6a2 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 7 Jul 2025 15:19:40 +0100 Subject: [PATCH 06/12] Add comprehensive tests for onsessionclosed callback in StreamableHTTPServerTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Tests callback is called when session is closed via DELETE • Tests system works correctly when callback is not provided • Tests callback is not called for invalid session DELETE requests • Tests correct session ID is passed when multiple sessions exist • Follows existing test patterns and includes proper cleanup 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/streamableHttp.test.ts | 166 +++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 2 deletions(-) diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 502435ea..e54bea01 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -29,6 +29,7 @@ interface TestServerConfig { enableJsonResponse?: boolean; customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; eventStore?: EventStore; + onsessionclosed?: (sessionId: string) => void; } /** @@ -57,7 +58,8 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -111,7 +113,8 @@ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenera const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore + eventStore: config.eventStore, + onsessionclosed: config.onsessionclosed }); await mcpServer.connect(transport); @@ -1504,6 +1507,165 @@ describe("StreamableHTTPServerTransport in stateless mode", () => { }); }); +// Test onsessionclosed callback +describe("StreamableHTTPServerTransport onsessionclosed callback", () => { + it("should call onsessionclosed callback when session is closed via DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback when not provided", async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": tempSessionId || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); + }); + + it("should not call onsessionclosed callback for invalid session DELETE", async () => { + const mockCallback = jest.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { + "mcp-session-id": "invalid-session-id", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); + + // Clean up + tempServer.close(); + }); + + it("should call onsessionclosed callback with correct session ID when multiple sessions exist", async () => { + const mockCallback = jest.fn(); + + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server1 = result1.server; + const url1 = result1.baseUrl; + + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback, + }); + + const server2 = result2.server; + const url2 = result2.baseUrl; + + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get("mcp-session-id"); + + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get("mcp-session-id"); + + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId1 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: "DELETE", + headers: { + "mcp-session-id": sessionId2 || "", + "mcp-protocol-version": "2025-03-26", + }, + }); + + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); + + // Clean up + server1.close(); + server2.close(); + }); +}); + // Test DNS rebinding protection describe("StreamableHTTPServerTransport DNS rebinding protection", () => { let server: Server; From 6ae4a5727c3c7ce1413f11af20c3ca2225f452ba Mon Sep 17 00:00:00 2001 From: anthonjn Date: Mon, 7 Jul 2025 11:54:44 -0700 Subject: [PATCH 07/12] add custom headers on initial _startOrAuth call (#318) * add custom headers on initial _startOrAuth call * update client/sse.ts: align commonHeaders w/ streamableHttp version --------- Co-authored-by: Olivier Chafik --- src/client/sse.test.ts | 23 +++++++++++++++++++++++ src/client/sse.ts | 23 ++++++++++------------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 2d116344..3e3abe68 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -382,6 +382,29 @@ describe("SSEClientTransport", () => { expect(mockAuthProvider.tokens).toHaveBeenCalled(); }); + it("attaches custom header from provider on initial SSE connection", async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: "test-token", + token_type: "Bearer" + }); + const customHeaders = { + "X-Custom-Header": "custom-value", + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + requestInit: { + headers: customHeaders, + }, + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe("Bearer test-token"); + expect(lastServerRequest.headers["x-custom-header"]).toBe("custom-value"); + expect(mockAuthProvider.tokens).toHaveBeenCalled(); + }); + it("attaches auth header from provider on POST requests", async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: "test-token", diff --git a/src/client/sse.ts b/src/client/sse.ts index faffecc4..568a5159 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -106,10 +106,8 @@ export class SSEClientTransport implements Transport { return await this._startOrAuth(); } - private async _commonHeaders(): Promise { - const headers = { - ...this._requestInit?.headers, - } as HeadersInit & Record; + private async _commonHeaders(): Promise { + const headers: HeadersInit = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { @@ -120,24 +118,24 @@ export class SSEClientTransport implements Transport { headers["mcp-protocol-version"] = this._protocolVersion; } - return headers; + return new Headers( + { ...headers, ...this._requestInit?.headers } + ); } private _startOrAuth(): Promise { -const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch + const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch return new Promise((resolve, reject) => { this._eventSource = new EventSource( this._url.href, { ...this._eventSourceInit, fetch: async (url, init) => { - const headers = await this._commonHeaders() + const headers = await this._commonHeaders(); + headers.set("Accept", "text/event-stream"); const response = await fetchImpl(url, { ...init, - headers: new Headers({ - ...headers, - Accept: "text/event-stream" - }) + headers, }) if (response.status === 401 && response.headers.has('www-authenticate')) { @@ -238,8 +236,7 @@ const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typ } try { - const commonHeaders = await this._commonHeaders(); - const headers = new Headers(commonHeaders); + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); const init = { ...this._requestInit, From 60310e9478cfc58534d2ed242f21d4a08036d463 Mon Sep 17 00:00:00 2001 From: HoberMin <102784200+HoberMin@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:39:02 +0900 Subject: [PATCH 08/12] feat: Add error handling tests for InMemoryTransport --- src/inMemory.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++++- src/inMemory.ts | 4 +- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index baf43446..0b0d5fe5 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -96,10 +96,43 @@ describe("InMemoryTransport", () => { }); test("should throw error when sending after close", async () => { - await clientTransport.close(); + const [client, server] = InMemoryTransport.createLinkedPair(); + let clientError: Error | undefined; + let serverError: Error | undefined; + + client.onerror = (err) => { + clientError = err; + }; + + server.onerror = (err) => { + serverError = err; + }; + + await client.close(); + + // Attempt to send message from client await expect( - clientTransport.send({ jsonrpc: "2.0", method: "test", id: 1 }), + client.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), ).rejects.toThrow("Not connected"); + + // Attempt to send message from server + await expect( + server.send({ + jsonrpc: "2.0", + method: "test", + id: 2, + }), + ).rejects.toThrow("Not connected"); + + // Verify that both sides received errors + expect(clientError).toBeDefined(); + expect(clientError?.message).toBe("Not connected"); + expect(serverError).toBeDefined(); + expect(serverError?.message).toBe("Not connected"); }); test("should queue messages sent before start", async () => { @@ -118,4 +151,65 @@ describe("InMemoryTransport", () => { await serverTransport.start(); expect(receivedMessage).toEqual(message); }); + + describe("error handling", () => { + test("should trigger onerror when sending without connection", async () => { + const transport = new InMemoryTransport(); + let error: Error | undefined; + + transport.onerror = (err) => { + error = err; + }; + + await expect( + transport.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), + ).rejects.toThrow("Not connected"); + + expect(error).toBeDefined(); + expect(error?.message).toBe("Not connected"); + }); + + test("should trigger onerror when sending after close", async () => { + const [client, server] = InMemoryTransport.createLinkedPair(); + let clientError: Error | undefined; + let serverError: Error | undefined; + + client.onerror = (err) => { + clientError = err; + }; + + server.onerror = (err) => { + serverError = err; + }; + + await client.close(); + + // Attempt to send message from client + await expect( + client.send({ + jsonrpc: "2.0", + method: "test", + id: 1, + }), + ).rejects.toThrow("Not connected"); + + // Attempt to send message from server + await expect( + server.send({ + jsonrpc: "2.0", + method: "test", + id: 2, + }), + ).rejects.toThrow("Not connected"); + + // Verify that both sides received errors + expect(clientError?.message).toBe("Not connected"); + expect(serverError).toBeDefined(); + expect(serverError?.message).toBe("Not connected"); + }); + }); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 5dd6e81e..056a4718 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -51,7 +51,9 @@ export class InMemoryTransport implements Transport { */ async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { - throw new Error("Not connected"); + const error = new Error("Not connected"); + this.onerror?.(error); + throw error; } if (this._otherTransport.onmessage) { From a7dc1258501cde8cc4a20fb7415c82b18e3bb78b Mon Sep 17 00:00:00 2001 From: HoberMin <102784200+HoberMin@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:27:19 +0900 Subject: [PATCH 09/12] feat: improve stdio test Windows compatibility and refactor command logic --- src/client/stdio.test.ts | 6 ++---- src/client/stdio.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index b2132446..8c3786eb 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,9 +1,7 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; +import { StdioClientTransport, getDefaultServerParameters } from "./stdio.js"; -const serverParameters: StdioServerParameters = { - command: "/usr/bin/tee", -}; +const serverParameters = getDefaultServerParameters(); test("should start then close cleanly", async () => { const client = new StdioClientTransport(serverParameters); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index 62292ce1..a34e8f19 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -39,6 +39,15 @@ export type StdioServerParameters = { cwd?: string; }; +// Configure default server parameters based on OS +// Uses 'more' command for Windows and 'tee' command for Unix/Linux +export const getDefaultServerParameters = (): StdioServerParameters => { + if (process.platform === "win32") { + return { command: "more" }; + } + return { command: "/usr/bin/tee" }; +}; + /** * Environment variables to inherit by default, if an environment is not explicitly given. */ From 88ede3783e65c6168d5f9237e136e52a116b1a1b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 8 Jul 2025 15:15:50 +0100 Subject: [PATCH 10/12] Address review feedback: move getDefaultServerParameters to test file - Moved getDefaultServerParameters from stdio.ts to stdio.test.ts since it's only used in tests - Reverted unrelated changes to inMemory.ts and inMemory.test.ts - Kept the core Windows compatibility fix for stdio tests Co-authored-by: HoberMin <99346635+HoberMin@users.noreply.github.com> --- src/client/stdio.test.ts | 11 ++++- src/client/stdio.ts | 9 ---- src/inMemory.test.ts | 98 +--------------------------------------- src/inMemory.ts | 4 +- 4 files changed, 13 insertions(+), 109 deletions(-) diff --git a/src/client/stdio.test.ts b/src/client/stdio.test.ts index 8c3786eb..2e4d92c2 100644 --- a/src/client/stdio.test.ts +++ b/src/client/stdio.test.ts @@ -1,5 +1,14 @@ import { JSONRPCMessage } from "../types.js"; -import { StdioClientTransport, getDefaultServerParameters } from "./stdio.js"; +import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; + +// Configure default server parameters based on OS +// Uses 'more' command for Windows and 'tee' command for Unix/Linux +const getDefaultServerParameters = (): StdioServerParameters => { + if (process.platform === "win32") { + return { command: "more" }; + } + return { command: "/usr/bin/tee" }; +}; const serverParameters = getDefaultServerParameters(); diff --git a/src/client/stdio.ts b/src/client/stdio.ts index a34e8f19..62292ce1 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -39,15 +39,6 @@ export type StdioServerParameters = { cwd?: string; }; -// Configure default server parameters based on OS -// Uses 'more' command for Windows and 'tee' command for Unix/Linux -export const getDefaultServerParameters = (): StdioServerParameters => { - if (process.platform === "win32") { - return { command: "more" }; - } - return { command: "/usr/bin/tee" }; -}; - /** * Environment variables to inherit by default, if an environment is not explicitly given. */ diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index 0b0d5fe5..baf43446 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -96,43 +96,10 @@ describe("InMemoryTransport", () => { }); test("should throw error when sending after close", async () => { - const [client, server] = InMemoryTransport.createLinkedPair(); - let clientError: Error | undefined; - let serverError: Error | undefined; - - client.onerror = (err) => { - clientError = err; - }; - - server.onerror = (err) => { - serverError = err; - }; - - await client.close(); - - // Attempt to send message from client + await clientTransport.close(); await expect( - client.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), + clientTransport.send({ jsonrpc: "2.0", method: "test", id: 1 }), ).rejects.toThrow("Not connected"); - - // Attempt to send message from server - await expect( - server.send({ - jsonrpc: "2.0", - method: "test", - id: 2, - }), - ).rejects.toThrow("Not connected"); - - // Verify that both sides received errors - expect(clientError).toBeDefined(); - expect(clientError?.message).toBe("Not connected"); - expect(serverError).toBeDefined(); - expect(serverError?.message).toBe("Not connected"); }); test("should queue messages sent before start", async () => { @@ -151,65 +118,4 @@ describe("InMemoryTransport", () => { await serverTransport.start(); expect(receivedMessage).toEqual(message); }); - - describe("error handling", () => { - test("should trigger onerror when sending without connection", async () => { - const transport = new InMemoryTransport(); - let error: Error | undefined; - - transport.onerror = (err) => { - error = err; - }; - - await expect( - transport.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), - ).rejects.toThrow("Not connected"); - - expect(error).toBeDefined(); - expect(error?.message).toBe("Not connected"); - }); - - test("should trigger onerror when sending after close", async () => { - const [client, server] = InMemoryTransport.createLinkedPair(); - let clientError: Error | undefined; - let serverError: Error | undefined; - - client.onerror = (err) => { - clientError = err; - }; - - server.onerror = (err) => { - serverError = err; - }; - - await client.close(); - - // Attempt to send message from client - await expect( - client.send({ - jsonrpc: "2.0", - method: "test", - id: 1, - }), - ).rejects.toThrow("Not connected"); - - // Attempt to send message from server - await expect( - server.send({ - jsonrpc: "2.0", - method: "test", - id: 2, - }), - ).rejects.toThrow("Not connected"); - - // Verify that both sides received errors - expect(clientError?.message).toBe("Not connected"); - expect(serverError).toBeDefined(); - expect(serverError?.message).toBe("Not connected"); - }); - }); }); diff --git a/src/inMemory.ts b/src/inMemory.ts index 056a4718..5dd6e81e 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -51,9 +51,7 @@ export class InMemoryTransport implements Transport { */ async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { - const error = new Error("Not connected"); - this.onerror?.(error); - throw error; + throw new Error("Not connected"); } if (this._otherTransport.onmessage) { From 4056c098a18a30fafa8aada15f9181b59697f4c1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 9 Jul 2025 11:23:24 +0100 Subject: [PATCH 11/12] Add missing app.listen error handling to server examples --- src/cli.ts | 6 +++++- src/examples/server/demoInMemoryOAuthProvider.ts | 6 +++++- src/examples/server/jsonResponseStreamableHttp.ts | 6 +++++- src/examples/server/simpleSseServer.ts | 6 +++++- src/examples/server/simpleStatelessStreamableHttp.ts | 6 +++++- src/examples/server/simpleStreamableHttp.ts | 6 +++++- src/examples/server/sseAndStreamableHttpCompatibleServer.ts | 6 +++++- src/examples/server/standaloneSseWithGetStreamableHttp.ts | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b5000896..f580a624 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -102,7 +102,11 @@ async function runServer(port: number | null) { await transport.handlePostMessage(req, res); }); - app.listen(port, () => { + app.listen(port, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Server running on http://localhost:${port}/sse`); }); } else { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 274a504a..c83748d3 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -200,7 +200,11 @@ export const setupAuthServer = ({authServerUrl, mcpServerUrl, strictResource}: { const auth_port = authServerUrl.port; // Start the auth server - authApp.listen(auth_port, () => { + authApp.listen(auth_port, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`OAuth Authorization Server listening on port ${auth_port}`); }); diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 04b14470..d6501d27 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -158,7 +158,11 @@ app.get('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index c3417920..f8bdd466 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -145,7 +145,11 @@ app.post('/messages', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index d235265c..b5a1e291 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -158,7 +158,11 @@ app.delete('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 029fff77..98f9d351 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -648,7 +648,11 @@ if (useOAuth && authMiddleware) { app.delete('/mcp', mcpDeleteHandler); } -app.listen(MCP_PORT, () => { +app.listen(MCP_PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); }); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 7b18578a..e097ca70 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -210,7 +210,11 @@ app.post("/messages", async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Backwards compatible MCP server listening on port ${PORT}`); console.log(` ============================================== diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 8c8c3baa..27981813 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -112,7 +112,11 @@ app.get('/mcp', async (req: Request, res: Response) => { // Start the server const PORT = 3000; -app.listen(PORT, () => { +app.listen(PORT, (error) => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } console.log(`Server listening on port ${PORT}`); }); From 8714f21541ca7b8f9ab20041b8019ed78d8ccf94 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 9 Jul 2025 07:43:33 -0700 Subject: [PATCH 12/12] fix(server): validate expiresAt token value for non existence (#446) * fix(server): validate expiresAt token value for non existence --------- Co-authored-by: Olivier Chafik --- src/server/auth/middleware/bearerAuth.test.ts | 43 +++++++++++++++++-- src/server/auth/middleware/bearerAuth.ts | 6 ++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index b8953e5c..9b051b1a 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -37,6 +37,7 @@ describe("requireBearerAuth middleware", () => { token: "valid-token", clientId: "client-123", scopes: ["read", "write"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour }; mockVerifyAccessToken.mockResolvedValue(validAuthInfo); @@ -53,13 +54,17 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.status).not.toHaveBeenCalled(); expect(mockResponse.json).not.toHaveBeenCalled(); }); - - it("should reject expired tokens", async () => { + + it.each([ + [100], // Token expired 100 seconds ago + [0], // Token expires at the same time as now + ])("should reject expired tokens (expired %s seconds ago)", async (expiredSecondsAgo: number) => { + const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo; const expiredAuthInfo: AuthInfo = { token: "expired-token", clientId: "client-123", scopes: ["read", "write"], - expiresAt: Math.floor(Date.now() / 1000) - 100, // Token expired 100 seconds ago + expiresAt }; mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); @@ -82,6 +87,37 @@ describe("requireBearerAuth middleware", () => { expect(nextFunction).not.toHaveBeenCalled(); }); + it.each([ + [undefined], // Token has no expiration time + [NaN], // Token has no expiration time + ])("should reject tokens with no expiration time (expiresAt: %s)", async (expiresAt: number | undefined) => { + const noExpirationAuthInfo: AuthInfo = { + token: "no-expiration-token", + clientId: "client-123", + scopes: ["read", "write"], + expiresAt + }; + mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo); + + mockRequest.headers = { + authorization: "Bearer expired-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('Bearer error="invalid_token"') + ); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: "invalid_token", error_description: "Token has no expiration time" }) + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + it("should accept non-expired tokens", async () => { const nonExpiredAuthInfo: AuthInfo = { token: "valid-token", @@ -141,6 +177,7 @@ describe("requireBearerAuth middleware", () => { token: "valid-token", clientId: "client-123", scopes: ["read", "write", "admin"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, // Token expires in an hour }; mockVerifyAccessToken.mockResolvedValue(authInfo); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 91f763a9..7b6d8f61 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -63,8 +63,10 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } } - // Check if the token is expired - if (!!authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { + // Check if the token is set to expire or if it is expired + if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { + throw new InvalidTokenError("Token has no expiration time"); + } else if (authInfo.expiresAt < Date.now() / 1000) { throw new InvalidTokenError("Token has expired"); }