diff --git a/README.md b/README.md index 3cb7ff07..c20551a0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - [Prompts](#prompts) - [Running Your Server](#running-your-server) - [stdio](#stdio) - - [HTTP with SSE](#http-with-sse) + - [Streamable HTTP](#streamable-http) - [Testing and Debugging](#testing-and-debugging) - [Examples](#examples) - [Echo Server](#echo-server) @@ -22,6 +22,7 @@ - [Writing MCP Clients](#writing-mcp-clients) - [Server Capabilities](#server-capabilities) - [Proxy OAuth Server](#proxy-authorization-requests-upstream) + - [Backwards Compatibility](#backwards-compatibility) ## Overview @@ -29,7 +30,7 @@ The Model Context Protocol allows applications to provide context for LLMs in a - Build MCP clients that can connect to any MCP server - Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio and SSE +- Use standard transports like stdio and Streamable HTTP - Handle all MCP protocol messages and lifecycle events ## Installation @@ -207,50 +208,179 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` -### HTTP with SSE +### Streamable HTTP -For remote servers, start a web server with a Server-Sent Events (SSE) endpoint, and a separate endpoint for the client to send its messages to: +For remote servers, set up a Streamable HTTP transport that handles both client requests and server-to-client notifications. + +#### With Session Management + +In some cases, servers need to be stateful. This is achieved by [session management](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#session-management). ```typescript -import express, { Request, Response } from "express"; +import express from "express"; +import { randomUUID } from "node:crypto"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js" -const server = new McpServer({ - name: "example-server", - version: "1.0.0" -}); -// ... set up server resources, tools, and prompts ... const app = express(); +app.use(express.json()); -// to support multiple simultaneous connections we have a lookup object from -// sessionId to transport -const transports: {[sessionId: string]: SSEServerTransport} = {}; +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; -app.get("/sse", async (_: Request, res: Response) => { - const transport = new SSEServerTransport('/messages', res); - transports[transport.sessionId] = transport; - res.on("close", () => { - delete transports[transport.sessionId]; - }); - await server.connect(transport); +// Handle POST requests for client-to-server communication +app.post('/mcp', async (req, res) => { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + // Store the transport by session ID + transports[sessionId] = transport; + } + }); + + // Clean up transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + const server = new McpServer({ + name: "example-server", + version: "1.0.0" + }); + + // ... set up server resources, tools, and prompts ... + + // Connect to the MCP server + await server.connect(transport); + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request + await transport.handleRequest(req, res, req.body); }); -app.post("/messages", async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; +// Reusable handler for GET and DELETE requests +const handleSessionRequest = async (req: express.Request, res: express.Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + const transport = transports[sessionId]; - if (transport) { - await transport.handlePostMessage(req, res); - } else { - res.status(400).send('No transport found for sessionId'); + await transport.handleRequest(req, res); +}; + +// Handle GET requests for server-to-client notifications via SSE +app.get('/mcp', handleSessionRequest); + +// Handle DELETE requests for session termination +app.delete('/mcp', handleSessionRequest); + +app.listen(3000); +``` + +#### Without Session Management (Stateless) + +For simpler use cases where session management isn't needed: + +```typescript +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req: Request, res: Response) => { + // In stateless mode, create a new instance of transport and server for each request + // to ensure complete isolation. A single instance would cause request ID collisions + // when multiple clients connect concurrently. + + try { + const server = getServer(); + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } } }); -app.listen(3001); +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); +}); + ``` +This stateless approach is useful for: +- Simple API wrappers +- RESTful scenarios where each request is independent +- Horizontally scaled deployments without shared session state + ### Testing and Debugging To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. @@ -596,6 +726,105 @@ This setup allows you to: - Provide custom documentation URLs - Maintain control over the OAuth flow while delegating to an external provider +### Backwards Compatibility + +Clients and servers with StreamableHttp tranport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows + +#### Client-Side Compatibility + +For clients that need to work with both Streamable HTTP and older SSE servers: + +```typescript +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +let client: Client|undefined = undefined +const baseUrl = new URL(url); +try { + client = new Client({ + name: 'streamable-http-client', + version: '1.0.0' + }); + const transport = new StreamableHTTPClientTransport( + new URL(baseUrl) + ); + await client.connect(transport); + console.log("Connected using Streamable HTTP transport"); +} catch (error) { + // If that fails with a 4xx error, try the older SSE transport + console.log("Streamable HTTP connection failed, falling back to SSE transport"); + client = new Client({ + name: 'sse-client', + version: '1.0.0' + }); + const sseTransport = new SSEClientTransport(baseUrl); + await client.connect(sseTransport); + console.log("Connected using SSE transport"); +} +``` + +#### Server-Side Compatibility + +For servers that need to support both Streamable HTTP and older clients: + +```typescript +import express from "express"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; + +const server = new McpServer({ + name: "backwards-compatible-server", + version: "1.0.0" +}); + +// ... set up server resources, tools, and prompts ... + +const app = express(); +app.use(express.json()); + +// Store transports for each session type +const transports = { + streamable: {} as Record, + sse: {} as Record +}; + +// Modern Streamable HTTP endpoint +app.all('/mcp', async (req, res) => { + // Handle Streamable HTTP transport for modern clients + // Implementation as shown in the "With Session Management" example + // ... +}); + +// Legacy SSE endpoint for older clients +app.get('/sse', async (req, res) => { + // Create SSE transport for legacy clients + const transport = new SSEServerTransport('/messages', res); + transports.sse[transport.sessionId] = transport; + + res.on("close", () => { + delete transports.sse[transport.sessionId]; + }); + + await server.connect(transport); +}); + +// Legacy message endpoint for older clients +app.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId as string; + const transport = transports.sse[sessionId]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +app.listen(3000); +``` + +**Note**: The SSE transport is now deprecated in favor of Streamable HTTP. New implementations should use Streamable HTTP, and existing SSE implementations should plan to migrate. + ## Documentation - [Model Context Protocol documentation](https://modelcontextprotocol.io) diff --git a/package.json b/package.json index c7963ec6..c6a65847 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.10.0", + "version": "1.11.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index cc4717ec..629feab7 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -39,7 +39,7 @@ describe("OAuth Authorization", () => { const [url, options] = calls[0]; expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server"); expect(options.headers).toEqual({ - "MCP-Protocol-Version": "2024-11-05" + "MCP-Protocol-Version": "2025-03-26" }); }); @@ -478,4 +478,4 @@ describe("OAuth Authorization", () => { ).rejects.toThrow("Dynamic client registration failed"); }); }); -}); \ No newline at end of file +}); diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 36dd6518..5b4f332f 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -165,6 +165,194 @@ test("should reject unsupported protocol version", async () => { expect(clientTransport.close).toHaveBeenCalled(); }); +test("should connect new client to old, supported server version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: "test server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: OLD_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "old server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "new client", + version: "1.0", + protocolVersion: LATEST_PROTOCOL_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(client.getServerVersion()).toEqual({ + name: "old server", + version: "1.0", + }); +}); + +test("should negotiate version when client is old, and newer server supports its version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: "new server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "new server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "old client", + version: "1.0", + protocolVersion: OLD_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + expect(client.getServerVersion()).toEqual({ + name: "new server", + version: "1.0", + }); +}); + +test("should throw when client is old, and server doesn't support its version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const FUTURE_VERSION = "FUTURE_VERSION"; + const server = new Server( + { + name: "new server", + version: "1.0", + }, + { + capabilities: { + resources: {}, + tools: {}, + }, + }, + ); + + server.setRequestHandler(InitializeRequestSchema, (_request) => ({ + protocolVersion: FUTURE_VERSION, + capabilities: { + resources: {}, + tools: {}, + }, + serverInfo: { + name: "new server", + version: "1.0", + }, + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [], + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: "old client", + version: "1.0", + protocolVersion: OLD_VERSION, + }, + { + capabilities: { + sampling: {}, + }, + enforceStrictCapabilities: true, + }, + ); + + await Promise.all([ + expect(client.connect(clientTransport)).rejects.toThrow( + "Server's protocol version is not supported: FUTURE_VERSION" + ), + server.connect(serverTransport), + ]); + +}); + test("should respect server capabilities", async () => { const server = new Server( { diff --git a/src/client/index.ts b/src/client/index.ts index 64ea62c4..a3edd0be 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -126,7 +126,11 @@ export class Client< override async connect(transport: Transport, options?: RequestOptions): Promise { await super.connect(transport); - + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } try { const result = await this.request( { diff --git a/src/client/stdio.ts b/src/client/stdio.ts index b83bf27c..9e35293d 100644 --- a/src/client/stdio.ts +++ b/src/client/stdio.ts @@ -1,7 +1,7 @@ import { ChildProcess, IOType } from "node:child_process"; import spawn from "cross-spawn"; import process from "node:process"; -import { Stream } from "node:stream"; +import { Stream, PassThrough } from "node:stream"; import { ReadBuffer, serializeMessage } from "../shared/stdio.js"; import { Transport } from "../shared/transport.js"; import { JSONRPCMessage } from "../types.js"; @@ -93,6 +93,7 @@ export class StdioClientTransport implements Transport { private _abortController: AbortController = new AbortController(); private _readBuffer: ReadBuffer = new ReadBuffer(); private _serverParams: StdioServerParameters; + private _stderrStream: PassThrough | null = null; onclose?: () => void; onerror?: (error: Error) => void; @@ -100,6 +101,9 @@ export class StdioClientTransport implements Transport { constructor(server: StdioServerParameters) { this._serverParams = server; + if (server.stderr === "pipe" || server.stderr === "overlapped") { + this._stderrStream = new PassThrough(); + } } /** @@ -158,15 +162,25 @@ export class StdioClientTransport implements Transport { this._process.stdout?.on("error", (error) => { this.onerror?.(error); }); + + if (this._stderrStream && this._process.stderr) { + this._process.stderr.pipe(this._stderrStream); + } }); } /** * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". * - * This is only available after the process has been started. + * If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to + * attach listeners before the start method is invoked. This prevents loss of any early + * error output emitted by the child process. */ get stderr(): Stream | null { + if (this._stderrStream) { + return this._stderrStream; + } + return this._process?.stderr ?? null; } diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 40f22139..f748a2be 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1,12 +1,24 @@ -import { StreamableHTTPClientTransport } from "./streamableHttp.js"; +import { StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from "./streamableHttp.js"; +import { OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { JSONRPCMessage } from "../types.js"; describe("StreamableHTTPClientTransport", () => { let transport: StreamableHTTPClientTransport; + let mockAuthProvider: jest.Mocked; beforeEach(() => { - transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + mockAuthProvider = { + get redirectUrl() { return "http://localhost/callback"; }, + get clientMetadata() { return { redirect_uris: ["http://localhost/callback"] }; }, + clientInformation: jest.fn(() => ({ client_id: "test-client-id", client_secret: "test-client-secret" })), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + }; + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { authProvider: mockAuthProvider }); jest.spyOn(global, "fetch"); }); @@ -101,6 +113,77 @@ describe("StreamableHTTPClientTransport", () => { expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); }); + it("should terminate session with DELETE request", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + expect(transport.sessionId).toBe("test-session-id"); + + // Now terminate the session + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers() + }); + + await transport.terminateSession(); + + // Verify the DELETE request was sent with the session ID + const calls = (global.fetch as jest.Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].method).toBe("DELETE"); + expect(lastCall[1].headers.get("mcp-session-id")).toBe("test-session-id"); + + // The session ID should be cleared after successful termination + expect(transport.sessionId).toBeUndefined(); + }); + + it("should handle 405 response when server doesn't support session termination", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26" + }, + id: "init-id" + }; + + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/event-stream", "mcp-session-id": "test-session-id" }), + }); + + await transport.send(message); + + // Now terminate the session, but server responds with 405 + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: "Method Not Allowed", + headers: new Headers() + }); + + await expect(transport.terminateSession()).resolves.not.toThrow(); + }); + it("should handle 404 response when session expires", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", @@ -164,8 +247,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["_startOrAuthSse"]({})).resolves.not.toThrow("Failed to open SSE stream: Method Not Allowed"); // Check that GET was attempted expect(global.fetch).toHaveBeenCalledWith( expect.anything(), @@ -209,7 +291,7 @@ describe("StreamableHTTPClientTransport", () => { transport.onmessage = messageSpy; await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthSse"]({}); // Give time for the SSE event to be processed await new Promise(resolve => setTimeout(resolve, 50)); @@ -276,45 +358,62 @@ describe("StreamableHTTPClientTransport", () => { })).toBe(true); }); - it("should include last-event-id header when resuming a broken connection", async () => { - // First make a successful connection that provides an event ID - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - const event = "id: event-123\nevent: message\ndata: {\"jsonrpc\": \"2.0\", \"method\": \"serverNotification\", \"params\": {}}\n\n"; - controller.enqueue(encoder.encode(event)); - controller.close(); + it("should support custom reconnection options", () => { + // Create a transport with custom reconnection options + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 500, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 2, + maxRetries: 5, } }); - (global.fetch as jest.Mock).mockResolvedValueOnce({ + // Verify options were set correctly (checking implementation details) + // Access private properties for testing + const transportInstance = transport as unknown as { + _reconnectionOptions: StreamableHTTPReconnectionOptions; + }; + expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500); + expect(transportInstance._reconnectionOptions.maxRetries).toBe(5); + }); + + it("should pass lastEventId when reconnecting", async () => { + // Create a fresh transport + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + + // Mock fetch to verify headers sent + const fetchSpy = global.fetch as jest.Mock; + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), - body: stream + body: new ReadableStream() }); + // Call the reconnect method directly with a lastEventId await transport.start(); - await transport.openSseStream(); - await new Promise(resolve => setTimeout(resolve, 50)); + // Type assertion to access private method + const transportWithPrivateMethods = transport as unknown as { + _startOrAuthSse: (options: { resumptionToken?: string }) => Promise + }; + await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: "test-event-id" }); - // Now simulate attempting to reconnect - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers({ "content-type": "text/event-stream" }), - body: null - }); - - await transport.openSseStream(); - - // Check that Last-Event-ID was included - const calls = (global.fetch as jest.Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].headers.get("last-event-id")).toBe("event-123"); + // Verify fetch was called with the lastEventId header + expect(fetchSpy).toHaveBeenCalled(); + const fetchCall = fetchSpy.mock.calls[0]; + const headers = fetchCall[1].headers; + expect(headers.get("last-event-id")).toBe("test-event-id"); }); it("should throw error when invalid content-type is received", async () => { + // Clear any previous state from other tests + jest.clearAllMocks(); + + // Create a fresh transport instance + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp")); + const message: JSONRPCMessage = { jsonrpc: "2.0", method: "test", @@ -324,7 +423,7 @@ describe("StreamableHTTPClientTransport", () => { const stream = new ReadableStream({ start(controller) { - controller.enqueue("invalid text response"); + controller.enqueue(new TextEncoder().encode("invalid text response")); controller.close(); } }); @@ -366,7 +465,7 @@ describe("StreamableHTTPClientTransport", () => { await transport.start(); - await transport.openSseStream(); + await transport["_startOrAuthSse"]({}); expect((actualReqInit.headers as Headers).get("x-custom-header")).toBe("CustomValue"); requestInit.headers["X-Custom-Header"] = "SecondCustomValue"; @@ -376,4 +475,61 @@ describe("StreamableHTTPClientTransport", () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); + + + it("should have exponential backoff with configurable maxRetries", () => { + // This test verifies the maxRetries and backoff calculation directly + + // Create transport with specific options for testing + transport = new StreamableHTTPClientTransport(new URL("http://localhost:1234/mcp"), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3, + } + }); + + // Get access to the internal implementation + const getDelay = transport["_getNextReconnectionDelay"].bind(transport); + + // First retry - should use initial delay + expect(getDelay(0)).toBe(100); + + // Second retry - should double (2^1 * 100 = 200) + expect(getDelay(1)).toBe(200); + + // Third retry - should double again (2^2 * 100 = 400) + expect(getDelay(2)).toBe(400); + + // Fourth retry - should double again (2^3 * 100 = 800) + expect(getDelay(3)).toBe(800); + + // Tenth retry - should be capped at maxReconnectionDelay + expect(getDelay(10)).toBe(5000); + }); + + it("attempts auth flow on 401 during POST request", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + params: {}, + id: "test-id" + }; + + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers() + }) + .mockResolvedValue({ + ok: false, + status: 404 + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); + }); }); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 5ea537c7..3462b2ab 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,8 +1,16 @@ import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; +import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +// Default reconnection options for StreamableHTTP connections +const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2, +}; + export class StreamableHTTPError extends Error { constructor( public readonly code: number | undefined, @@ -12,6 +20,60 @@ export class StreamableHTTPError extends Error { } } +/** + * Options for starting or authenticating an SSE connection + */ +interface StartSSEOptions { + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off. + */ + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; + + /** + * Override Message ID to associate with the replay message + * so that response can be associate with the new resumed request. + */ + replayMessageId?: string | number; +} + +/** + * Configuration options for reconnection behavior of the StreamableHTTPClientTransport. + */ +export interface StreamableHTTPReconnectionOptions { + /** + * Maximum backoff time between reconnection attempts in milliseconds. + * Default is 30000 (30 seconds). + */ + maxReconnectionDelay: number; + + /** + * Initial backoff time between reconnection attempts in milliseconds. + * Default is 1000 (1 second). + */ + initialReconnectionDelay: number; + + /** + * The factor by which the reconnection delay increases after each attempt. + * Default is 1.5. + */ + reconnectionDelayGrowFactor: number; + + /** + * Maximum number of reconnection attempts before giving up. + * Default is 2. + */ + maxRetries: number; +} + /** * Configuration options for the `StreamableHTTPClientTransport`. */ @@ -36,6 +98,17 @@ export type StreamableHTTPClientTransportOptions = { * Customizes HTTP requests to the server. */ requestInit?: RequestInit; + + /** + * Options to configure the reconnection behavior. + */ + reconnectionOptions?: StreamableHTTPReconnectionOptions; + + /** + * Session ID for the connection. This is used to identify the session on the server. + * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. + */ + sessionId?: string; }; /** @@ -49,7 +122,7 @@ export class StreamableHTTPClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _sessionId?: string; - private _lastEventId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; onclose?: () => void; onerror?: (error: Error) => void; @@ -62,6 +135,8 @@ export class StreamableHTTPClientTransport implements Transport { this._url = url; this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; + this._sessionId = opts?.sessionId; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; } private async _authThenStart(): Promise { @@ -81,7 +156,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError(); } - return await this._startOrAuthStandaloneSSE(); + return await this._startOrAuthSse({ resumptionToken: undefined }); } private async _commonHeaders(): Promise { @@ -102,16 +177,18 @@ export class StreamableHTTPClientTransport implements Transport { ); } - private async _startOrAuthStandaloneSSE(): Promise { + + private async _startOrAuthSse(options: StartSSEOptions): Promise { + const { resumptionToken } = options; try { // Try to open an initial SSE stream with GET to listen for server messages // This is optional according to the spec - server may not support it const headers = await this._commonHeaders(); headers.set("Accept", "text/event-stream"); - // Include Last-Event-ID header for resumable streams - if (this._lastEventId) { - headers.set("last-event-id", this._lastEventId); + // Include Last-Event-ID header for resumable streams if provided + if (resumptionToken) { + headers.set("last-event-id", resumptionToken); } const response = await fetch(this._url, { @@ -126,50 +203,139 @@ 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}`, ); } - // Successful connection, handle the SSE stream as a standalone listener - this._handleSseStream(response.body); + this._handleSseStream(response.body, options); } catch (error) { this.onerror?.(error as Error); throw error; } } - private _handleSseStream(stream: ReadableStream | null): void { - if (!stream) { + + /** + * Calculates the next reconnection delay using backoff algorithm + * + * @param attempt Current reconnection attempt count for the specific stream + * @returns Time to wait in milliseconds before next reconnection attempt + */ + private _getNextReconnectionDelay(attempt: number): number { + // Access default values directly, ensuring they're never undefined + const initialDelay = this._reconnectionOptions.initialReconnectionDelay; + const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor; + const maxDelay = this._reconnectionOptions.maxReconnectionDelay; + + // Cap at maximum delay + return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); + + } + + /** + * Schedule a reconnection attempt with exponential backoff + * + * @param lastEventId The ID of the last received event for resumability + * @param attemptCount Current reconnection attempt count for this specific stream + */ + private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { + // Use provided options or default options + const maxRetries = this._reconnectionOptions.maxRetries; + + // Check if we've exceeded maximum retry attempts + if (maxRetries > 0 && attemptCount >= maxRetries) { + this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); return; } - const processStream = async () => { - // Create a pipeline: binary stream -> text decoder -> SSE parser - const eventStream = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()); + // Calculate next delay based on current attempt count + const delay = this._getNextReconnectionDelay(attemptCount); - for await (const event of eventStream) { - // Update last event ID if provided - if (event.id) { - this._lastEventId = event.id; + // Schedule the reconnection + setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthSse(options).catch(error => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); + // Schedule another attempt if this one failed, incrementing the attempt counter + this._scheduleReconnection(options, attemptCount + 1); + }); + }, delay); + } + + private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions): void { + if (!stream) { + return; + } + const { onresumptiontoken, replayMessageId } = options; + + let lastEventId: string | undefined; + const processStream = async () => { + // this is the closest we can get to trying to catch network errors + // if something happens reader will throw + try { + // Create a pipeline: binary stream -> text decoder -> SSE parser + const reader = stream + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + + while (true) { + const { value: event, done } = await reader.read(); + if (done) { + break; + } + + // Update last event ID if provided + if (event.id) { + lastEventId = event.id; + onresumptiontoken?.(event.id); + } + + if (!event.event || event.event === "message") { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + if (replayMessageId !== undefined && isJSONRPCResponse(message)) { + message.id = replayMessageId; + } + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } } - // Handle message events (default event type is undefined per docs) - // or explicit 'message' event type - if (!event.event || event.event === "message") { - try { - const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - this.onmessage?.(message); - } catch (error) { - this.onerror?.(error as Error); + } catch (error) { + // Handle stream errors - likely a network disconnect + this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); + + // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing + if (this._abortController && !this._abortController.signal.aborted) { + // Use the exponential backoff reconnection strategy + if (lastEventId !== undefined) { + try { + this._scheduleReconnection({ + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, 0); + } + catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + + } } } } }; - - processStream().catch(err => this.onerror?.(err)); + processStream(); } async start() { @@ -203,8 +369,16 @@ export class StreamableHTTPClientTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage | JSONRPCMessage[]): Promise { + async send(message: JSONRPCMessage | JSONRPCMessage[], options?: { resumptionToken?: string, onresumptiontoken?: (token: string) => void }): Promise { try { + const { resumptionToken, onresumptiontoken } = options || {}; + + if (resumptionToken) { + // If we have at last event ID, we need to reconnect the SSE stream + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => this.onerror?.(err)); + return; + } + const headers = await this._commonHeaders(); headers.set("content-type", "application/json"); headers.set("accept", "application/json, text/event-stream"); @@ -244,6 +418,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 (isInitializedNotification(message)) { + // Start without a lastEventId since this is a fresh connection + this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err)); + } return; } @@ -257,7 +437,10 @@ export class StreamableHTTPClientTransport implements Transport { if (hasRequests) { if (contentType?.includes("text/event-stream")) { - this._handleSseStream(response.body); + // Handle SSE stream responses for requests + // We use the same handler as standalone streams, which now supports + // reconnection with the last event ID + this._handleSseStream(response.body, { onresumptiontoken }); } else if (contentType?.includes("application/json")) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -281,19 +464,51 @@ export class StreamableHTTPClientTransport implements Transport { } } + get sessionId(): string | undefined { + return this._sessionId; + } + /** - * 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. + * Terminates the current session by sending a DELETE request to the server. + * + * Clients that no longer need a particular session + * (e.g., because the user is leaving the client application) SHOULD send an + * HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly + * terminate the session. + * + * The server MAY respond with HTTP 405 Method Not Allowed, indicating that + * the server does not allow clients to terminate sessions. */ - async openSseStream(): Promise { - if (!this._abortController) { - throw new Error( - "StreamableHTTPClientTransport not started! Call connect() before openSseStream().", - ); + async terminateSession(): Promise { + if (!this._sessionId) { + return; // No session to terminate + } + + try { + const headers = await this._commonHeaders(); + + const init = { + ...this._requestInit, + method: "DELETE", + headers, + signal: this._abortController?.signal, + }; + + const response = await fetch(this._url, init); + + // We specifically handle 405 as a valid response according to the spec, + // meaning the server does not support explicit session termination + if (!response.ok && response.status !== 405) { + throw new StreamableHTTPError( + response.status, + `Failed to terminate session: ${response.statusText}` + ); + } + + this._sessionId = undefined; + } catch (error) { + this.onerror?.(error as Error); + throw error; } - await this._startOrAuthStandaloneSSE(); } } diff --git a/src/examples/README.md b/src/examples/README.md index cc6af51c..611c081e 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -2,129 +2,296 @@ This directory contains example implementations of MCP clients and servers using the TypeScript SDK. -## Streamable HTTP - single node deployment with basic session state management +## Table of Contents -Multi node with stete management example will be added soon after we add support. +- [Client Implementations](#client-implementations) + - [Streamable HTTP Client](#streamable-http-client) + - [Backwards Compatible Client](#backwards-compatible-client) +- [Server Implementations](#server-implementations) + - [Single Node Deployment](#single-node-deployment) + - [Streamable HTTP Transport](#streamable-http-transport) + - [Deprecated SSE Transport](#deprecated-sse-transport) + - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) + - [Multi-Node Deployment](#multi-node-deployment) +- [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) -### Server with JSON response mode (`server/jsonResponseStreamableHttp.ts`) +## Client Implementations -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. +### Streamable HTTP Client -#### Running the server +A full-featured interactive client that connects to a Streamable HTTP server, demonstrating how to: -```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" \ - -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" \ - -H "Accept: text/event-stream" \ - -H "mcp-session-id: $SESSION_ID" \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "greet", - "arguments": { - "name": "World" - } - }, - "id": "2" - }' \ - http://localhost:3000/mcp -``` - -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`) - -A client that connects to the server, initializes it, and demonstrates how to: - -- List available tools and call the `greet` tool -- List available prompts and get the `greeting-template` prompt +- Establish and manage a connection to an MCP server +- List and call tools with arguments +- Handle notifications through the SSE stream +- List and get prompts with arguments - List available resources - -#### Running the client +- Handle session termination and reconnection +- Support for resumability with Last-Event-ID tracking ```bash npx tsx src/examples/client/simpleStreamableHttp.ts ``` -Make sure the server is running before starting the client. +### Backwards Compatible Client -## Notes +A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: -- 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 +- The client first POSTs an initialize request to the server URL: + - If successful, it uses the Streamable HTTP transport + - If it fails with a 4xx status, it attempts a GET request to establish an SSE stream + +```bash +npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts +``` + +## Server Implementations + +### Single Node Deployment + +These examples demonstrate how to set up an MCP server on a single node with different transport options. + +#### Streamable HTTP Transport + +##### Simple Streamable HTTP Server + +A server that implements the Streamable HTTP transport (protocol version 2025-03-26). + +- Basic server setup with Express and the Streamable HTTP transport +- Session management with an in-memory event store for resumability +- Tool implementation with the `greet` and `multi-greet` tools +- Prompt implementation with the `greeting-template` prompt +- Static resource exposure +- Support for notifications via SSE stream established by GET requests +- Session termination via DELETE requests + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts +``` + +##### JSON Response Mode Server + +A server that uses Streamable HTTP transport with JSON response mode enabled (no SSE). + +- Streamable HTTP with JSON response mode, which returns responses directly in the response body +- Limited support for notifications (since SSE is disabled) +- Proper response handling according to the MCP specification for servers that don't support SSE +- Returning appropriate HTTP status codes for unsupported methods + +```bash +npx tsx src/examples/server/jsonResponseStreamableHttp.ts +``` + +##### Streamable HTTP with server notifications + +A server that demonstrates server notifications using Streamable HTTP. + +- Resource list change notifications with dynamically added resources +- Automatic resource creation on a timed interval + + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +#### Deprecated SSE Transport + +A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example only used for testing backwards compatibility for clients. + +- Two separate endpoints: `/mcp` for the SSE stream (GET) and `/messages` for client messages (POST) +- Tool implementation with a `start-notification-stream` tool that demonstrates sending periodic notifications + +```bash +npx tsx src/examples/server/simpleSseServer.ts +``` + +#### Streamable Http Backwards Compatible Server with SSE + +A server that supports both Streamable HTTP and SSE transports, adhering to the [MCP specification for backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility). + +- Single MCP server instance with multiple transport options +- Support for Streamable HTTP requests at `/mcp` endpoint (GET/POST/DELETE) +- Support for deprecated SSE transport with `/sse` (GET) and `/messages` (POST) +- Session type tracking to avoid mixing transport types +- Notifications and tool execution across both transport types + +```bash +npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts +``` + +### Multi-Node Deployment + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: +- **Stateless mode** - No need to maintain state between calls to MCP servers. Useful for simple API wrapper servers. +- **Persistent storage mode** - No local state needed, but session data is stored in a database. Example: an MCP server for online ordering where the shopping cart is stored in a database. +- **Local state with message routing** - Local state is needed, and all requests for a session must be routed to the correct node. This can be done with a message queue and pub/sub system. + +#### Stateless Mode + +The Streamable HTTP transport can be configured to operate without tracking sessions. This is perfect for simple API proxies or when each request is completely independent. + +##### Implementation + +To enable stateless mode, configure the `StreamableHTTPServerTransport` with: +```typescript +sessionIdGenerator: undefined +``` + +This disables session management entirely, and the server won't generate or expect session IDs. + +- No session ID headers are sent or expected +- Any server node can process any request +- No state is preserved between requests +- Perfect for RESTful or stateless API scenarios +- Simplest deployment model with minimal infrastructure requirements + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + + + +#### Persistent Storage Mode + +For cases where you need session continuity but don't need to maintain in-memory state on specific nodes, you can use a database to persist session data while still allowing any node to handle requests. + +##### Implementation + +Configure the transport with session management, but retrieve and store all state in an external persistent storage: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +All session state is stored in the database, and any node can serve any client by retrieving the state when needed. + +- Maintains sessions with unique IDs +- Stores all session data in an external database +- Provides resumability through the database-backed EventStore +- Any node can handle any request for the same session +- No node-specific memory state means no need for message routing +- Good for applications where state can be fully externalized +- Somewhat higher latency due to database access for each request + + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +└─────────────────────────────────────────────┘ +``` + + + +#### Streamable HTTP with Distributed Message Routing + +For scenarios where local in-memory state must be maintained on specific nodes (such as Computer Use or complex session state), the Streamable HTTP transport can be combined with a pub/sub system to route messages to the correct node handling each session. + +1. **Bidirectional Message Queue Integration**: + - All nodes both publish to and subscribe from the message queue + - Each node registers the sessions it's actively handling + - Messages are routed based on session ownership + +2. **Request Handling Flow**: + - When a client connects to Node A with an existing `mcp-session-id` + - If Node A doesn't own this session, it: + - Establishes and maintains the SSE connection with the client + - Publishes the request to the message queue with the session ID + - Node B (which owns the session) receives the request from the queue + - Node B processes the request with its local session state + - Node B publishes responses/notifications back to the queue + - Node A subscribes to the response channel and forwards to the client + +3. **Channel Identification**: + - Each message channel combines both `mcp-session-id` and `stream-id` + - This ensures responses are correctly routed back to the originating connection + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +└─────────────────┘ └─────────────────────┘ + ▲│ ▲│ + │▼ │▼ +┌─────────────────────────────────────────────┐ +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +└─────────────────────────────────────────────┘ +``` + + +- Maintains session affinity for stateful operations without client redirection +- Enables horizontal scaling while preserving complex in-memory state +- Provides fault tolerance through the message queue as intermediary + + +## Backwards Compatibility + +### Testing Streamable HTTP Backwards Compatibility with SSE + +To test the backwards compatibility features: + +1. Start one of the server implementations: + ```bash + # Legacy SSE server (protocol version 2024-11-05) + npx tsx src/examples/server/simpleSseServer.ts + + # Streamable HTTP server (protocol version 2025-03-26) + npx tsx src/examples/server/simpleStreamableHttp.ts + + # Backwards compatible server (supports both protocols) + npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts + ``` + +2. Then run the backwards compatible client: + ```bash + npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts + ``` + +This demonstrates how the MCP ecosystem ensures interoperability between clients and servers regardless of which protocol version they were built for. \ No newline at end of file diff --git a/src/examples/client/multipleClientsParallel.ts b/src/examples/client/multipleClientsParallel.ts new file mode 100644 index 00000000..cc01fc06 --- /dev/null +++ b/src/examples/client/multipleClientsParallel.ts @@ -0,0 +1,160 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, + CallToolResult, +} from '../../types.js'; + +/** + * Multiple Clients MCP Example + * + * This client demonstrates how to: + * 1. Create multiple MCP clients in parallel + * 2. Each client calls a single tool + * 3. Track notifications from each client independently + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +interface ClientConfig { + id: string; + name: string; + toolName: string; + toolArguments: Record; +} + +async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { + console.log(`[${config.id}] Creating client: ${config.name}`); + + const client = new Client({ + name: config.name, + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + // Set up client-specific error handler + client.onerror = (error) => { + console.error(`[${config.id}] Client error:`, error); + }; + + // Set up client-specific notification handler + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`[${config.id}] Notification: ${notification.params.data}`); + }); + + try { + // Connect to the server + await client.connect(transport); + console.log(`[${config.id}] Connected to MCP server`); + + // Call the specified tool + console.log(`[${config.id}] Calling tool: ${config.toolName}`); + const toolRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: config.toolName, + arguments: { + ...config.toolArguments, + // Add client ID to arguments for identification in notifications + caller: config.id + } + } + }; + + const result = await client.request(toolRequest, CallToolResultSchema); + console.log(`[${config.id}] Tool call completed`); + + // Keep the connection open for a bit to receive notifications + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Disconnect + await transport.close(); + console.log(`[${config.id}] Disconnected from MCP server`); + + return { id: config.id, result }; + } catch (error) { + console.error(`[${config.id}] Error:`, error); + throw error; + } +} + +async function main(): Promise { + console.log('MCP Multiple Clients Example'); + console.log('============================'); + console.log(`Server URL: ${serverUrl}`); + console.log(''); + + try { + // Define client configurations + const clientConfigs: ClientConfig[] = [ + { + id: 'client1', + name: 'basic-client-1', + toolName: 'start-notification-stream', + toolArguments: { + interval: 3, // 1 second between notifications + count: 5 // Send 5 notifications + } + }, + { + id: 'client2', + name: 'basic-client-2', + toolName: 'start-notification-stream', + toolArguments: { + interval: 2, // 2 seconds between notifications + count: 3 // Send 3 notifications + } + }, + { + id: 'client3', + name: 'basic-client-3', + toolName: 'start-notification-stream', + toolArguments: { + interval: 1, // 0.5 second between notifications + count: 8 // Send 8 notifications + } + } + ]; + + // Start all clients in parallel + console.log(`Starting ${clientConfigs.length} clients in parallel...`); + console.log(''); + + const clientPromises = clientConfigs.map(config => createAndRunClient(config)); + const results = await Promise.all(clientPromises); + + // Display results from all clients + console.log('\n=== Final Results ==='); + results.forEach(({ id, result }) => { + console.log(`\n[${id}] Tool result:`); + if (Array.isArray(result.content)) { + result.content.forEach((item: { type: string; text?: string }) => { + if (item.type === 'text' && item.text) { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } else { + console.log(` Unexpected result format:`, result); + } + }); + + console.log('\n=== All clients completed successfully ==='); + + } catch (error) { + console.error('Error running multiple clients:', error); + process.exit(1); + } +} + +// Start the example +main().catch((error: unknown) => { + console.error('Error running MCP multiple clients example:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts new file mode 100644 index 00000000..3783992d --- /dev/null +++ b/src/examples/client/parallelToolCallsClient.ts @@ -0,0 +1,197 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolResultSchema, + LoggingMessageNotificationSchema, + CallToolResult, +} from '../../types.js'; + +/** + * Parallel Tool Calls MCP Client + * + * This client demonstrates how to: + * 1. Start multiple tool calls in parallel + * 2. Track notifications from each tool call using a caller parameter + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Parallel Tool Calls Client'); + console.log('=============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport; + + try { + // Create client with streamable HTTP transport + client = new Client({ + name: 'parallel-tool-calls-client', + version: '1.0.0' + }); + + client.onerror = (error) => { + console.error('Client error:', error); + }; + + // Connect to the server + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + console.log('Successfully connected to MCP server'); + + // Set up notification handler with caller identification + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification: ${notification.params.data}`); + }); + + console.log("List tools") + const toolsRequest = await listTools(client); + console.log("Tools: ", toolsRequest) + + + // 2. Start multiple notification tools in parallel + console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); + const toolResults = await startParallelNotificationTools(client); + + // Log the results from each tool call + for (const [caller, result] of Object.entries(toolResults)) { + console.log(`\n=== Tool result for ${caller} ===`); + result.content.forEach((item: { type: string; text?: string; }) => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } + + // 3. Wait for all notifications (10 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 10000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + + } catch (error) { + console.error('Error running client:', error); + process.exit(1); + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start multiple notification tools in parallel with different configurations + * Each tool call includes a caller parameter to identify its notifications + */ +async function startParallelNotificationTools(client: Client): Promise> { + try { + // Define multiple tool calls with different configurations + const toolCalls = [ + { + caller: 'fast-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 2, // 0.5 second between notifications + count: 10, // Send 10 notifications + caller: 'fast-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'slow-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 5, // 2 seconds between notifications + count: 5, // Send 5 notifications + caller: 'slow-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'burst-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1, // 0.1 second between notifications + count: 3, // Send just 3 notifications + caller: 'burst-notifier' // Identify this tool call + } + } + } + } + ]; + + console.log(`Starting ${toolCalls.length} notification tools in parallel...`); + + // Start all tool calls in parallel + const toolPromises = toolCalls.map(({ caller, request }) => { + console.log(`Starting tool call for ${caller}...`); + return client.request(request, CallToolResultSchema) + .then(result => ({ caller, result })) + .catch(error => { + console.error(`Error in tool call for ${caller}:`, error); + throw error; + }); + }); + + // Wait for all tool calls to complete + const results = await Promise.all(toolPromises); + + // Organize results by caller + const resultsByTool: Record = {}; + results.forEach(({ caller, result }) => { + resultsByTool[caller] = result; + }); + + return resultsByTool; + } catch (error) { + console.error(`Error starting parallel notification tools:`, error); + throw error; + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index b17add14..c1501a57 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -1,5 +1,6 @@ import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; import { ListToolsRequest, ListToolsResultSchema, @@ -11,107 +12,491 @@ import { GetPromptResultSchema, ListResourcesRequest, ListResourcesResultSchema, - LoggingMessageNotificationSchema + LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema, } from '../../types.js'; +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Track received notifications for debugging resumability +let notificationCount = 0; + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; +let notificationsToolLastEventId: string | undefined = undefined; +let sessionId: string | undefined = undefined; + async function main(): Promise { - // Create a new client with streamable HTTP transport - const client = new Client({ - name: 'example-client', - version: '1.0.0' - }); + console.log('MCP Interactive Client'); + console.log('====================='); - const transport = new StreamableHTTPClientTransport( - new URL('http://localhost:3000/mcp') - ); + // Connect to server immediately with default settings + await connect(); - // Connect the client using the transport and initialize the server - await client.connect(transport); - client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { - console.log(`Notification received: ${notification.params.level} - ${notification.params.data}`); - }); + // Print help and start the command loop + printHelp(); + commandLoop(); +} +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' greet [name] - Call the greet tool'); + console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' start-notifications [interval] [count] - Start periodic notifications'); + console.log(' list-prompts - List available prompts'); + console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); + console.log(' list-resources - List available resources'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} - 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); +function commandLoop(): void { + readline.question('\n> ', async (input) => { + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); - // Call the 'greet' tool - const greetRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'MCP User' } + try { + switch (command) { + case 'connect': + await connect(args[1]); + break; + + case 'disconnect': + await disconnect(); + break; + + case 'terminate-session': + await terminateSession(); + break; + + case 'reconnect': + await reconnect(); + break; + + case 'list-tools': + await listTools(); + break; + + case 'call-tool': + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + + case 'greet': + await callGreetTool(args[1] || 'MCP User'); + break; + + case 'multi-greet': + await callMultiGreetTool(args[1] || 'MCP User'); + break; + + case 'start-notifications': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 10; + await startNotifications(interval, count); + break; + } + + case 'list-prompts': + await listPrompts(); + break; + + case 'get-prompt': + if (args.length < 2) { + console.log('Usage: get-prompt [args]'); + } else { + const promptName = args[1]; + let promptArgs = {}; + if (args.length > 2) { + try { + promptArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await getPrompt(promptName, promptArgs); + } + break; + + case 'list-resources': + await listResources(); + break; + + case 'help': + printHelp(); + break; + + case 'quit': + case 'exit': + await cleanup(); + return; + + default: + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } catch (error) { + console.error(`Error executing command: ${error}`); } - }; - 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}`); - } + // Continue the command loop + commandLoop(); }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`Connecting to ${serverUrl}...`); + + try { + // Create a new client + client = new Client({ + name: 'example-client', + version: '1.0.0' + }); + client.onerror = (error) => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + } + + transport = new StreamableHTTPClientTransport( + new URL(serverUrl), + { + sessionId: sessionId + } + ); + + // Set up notification handlers + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + notificationCount++; + console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); + // Re-display the prompt + process.stdout.write('> '); + }); + + client.setNotificationHandler(ResourceListChangedNotificationSchema, async (_) => { + console.log(`\nResource list changed notification received!`); + try { + if (!client) { + console.log('Client disconnected, cannot fetch resources'); + return; + } + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + console.log('Available resources count:', resourcesResult.resources.length); + } catch { + console.log('Failed to list resources after change notification'); + } + // Re-display the prompt + process.stdout.write('> '); + }); + + // Connect the client + await client.connect(transport); + sessionId = transport.sessionId + console.log('Transport created with session ID:', sessionId); + console.log('Connected to MCP server'); + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (!transport.sessionId) { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } +} + +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + console.log(`Calling tool '${name}' with args:`, args); + const onLastEventIdUpdate = (event: string) => { + notificationsToolLastEventId = event; + }; + const result = await client.request(request, CallToolResultSchema, { + resumptionToken: notificationsToolLastEventId, onresumptiontoken: onLastEventIdUpdate + }); + + console.log('Tool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error) { + console.log(`Error calling tool ${name}: ${error}`); + } +} + +async function callGreetTool(name: string): Promise { + await callTool('greet', { name }); +} + +async function callMultiGreetTool(name: string): Promise { + console.log('Calling multi-greet tool with notifications...'); + await callTool('multi-greet', { name }); +} + +async function startNotifications(interval: number, count: number): Promise { + console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); + await callTool('start-notification-stream', { interval, count }); +} + +async function listPrompts(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } - // List available prompts try { const promptsRequest: ListPromptsRequest = { method: 'prompts/list', params: {} }; const promptsResult = await client.request(promptsRequest, ListPromptsResultSchema); - console.log('Available prompts:', promptsResult.prompts); + console.log('Available prompts:'); + if (promptsResult.prompts.length === 0) { + console.log(' No prompts available'); + } else { + for (const prompt of promptsResult.prompts) { + console.log(` - ${prompt.name}: ${prompt.description}`); + } + } } catch (error) { console.log(`Prompts not supported by this server (${error})`); } +} + +async function getPrompt(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } - // Get a prompt try { const promptRequest: GetPromptRequest = { method: 'prompts/get', params: { - name: 'greeting-template', - arguments: { name: 'MCP User' } + name, + arguments: args as Record } }; + const promptResult = await client.request(promptRequest, GetPromptResultSchema); - console.log('Prompt template:', promptResult.messages[0].content.text); + console.log('Prompt template:'); + promptResult.messages.forEach((msg, index) => { + console.log(` [${index + 1}] ${msg.role}: ${msg.content.text}`); + }); } catch (error) { - console.log(`Prompt retrieval not supported by this server (${error})`); + console.log(`Error getting prompt ${name}: ${error}`); + } +} + +async function listResources(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; } - // List available resources try { const resourcesRequest: ListResourcesRequest = { method: 'resources/list', params: {} }; const resourcesResult = await client.request(resourcesRequest, ListResourcesResultSchema); - console.log('Available resources:', resourcesResult.resources); + + console.log('Available resources:'); + if (resourcesResult.resources.length === 0) { + console.log(' No resources available'); + } else { + for (const resource of resourcesResult.resources) { + console.log(` - ${resource.name}: ${resource.uri}`); + } + } } catch (error) { console.log(`Resources not supported by this server (${error})`); } - - // Close the connection - await client.close(); } +async function cleanup(): Promise { + if (client && transport) { + try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + process.stdin.setRawMode(false); + readline.close(); + console.log('\nGoodbye!'); + process.exit(0); +} + +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async (data) => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client main().catch((error: unknown) => { console.error('Error running MCP client:', error); process.exit(1); diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 00000000..7646f0f7 --- /dev/null +++ b/src/examples/client/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,192 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { SSEClientTransport } from '../../client/sse.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema, +} from '../../types.js'; + +/** + * Simplified Backwards Compatible MCP Client + * + * This client demonstrates backward compatibility with both: + * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) + * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) + * + * Following the MCP specification for backwards compatibility: + * - Attempts to POST an initialize request to the server URL first (modern transport) + * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Backwards Compatible Client'); + console.log('==============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport | SSEClientTransport; + + try { + // Try connecting with automatic transport detection + const connection = await connectWithBackwardsCompatibility(serverUrl); + client = connection.client; + transport = connection.transport; + + // Set up notification handler + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); + }); + + // DEMO WORKFLOW: + // 1. List available tools + console.log('\n=== Listing Available Tools ==='); + await listTools(client); + + // 2. Call the notification tool + console.log('\n=== Starting Notification Stream ==='); + await startNotificationTool(client); + + // 3. Wait for all notifications (5 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + + } catch (error) { + console.error('Error running client:', error); + process.exit(1); + } +} + +/** + * Connect to an MCP server with backwards compatibility + * Following the spec for client backward compatibility + */ +async function connectWithBackwardsCompatibility(url: string): Promise<{ + client: Client, + transport: StreamableHTTPClientTransport | SSEClientTransport, + transportType: 'streamable-http' | 'sse' +}> { + console.log('1. Trying Streamable HTTP transport first...'); + + // Step 1: Try Streamable HTTP transport first + const client = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + + client.onerror = (error) => { + console.error('Client error:', error); + }; + const baseUrl = new URL(url); + + try { + // Create modern transport + const streamableTransport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(streamableTransport); + + console.log('Successfully connected using modern Streamable HTTP transport.'); + return { + client, + transport: streamableTransport, + transportType: 'streamable-http' + }; + } catch (error) { + // Step 2: If transport fails, try the older SSE transport + console.log(`StreamableHttp transport connection failed: ${error}`); + console.log('2. Falling back to deprecated HTTP+SSE transport...'); + + try { + // Create SSE transport pointing to /sse endpoint + const sseTransport = new SSEClientTransport(baseUrl); + const sseClient = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + await sseClient.connect(sseTransport); + + console.log('Successfully connected using deprecated HTTP+SSE transport.'); + return { + client: sseClient, + transport: sseTransport, + transportType: 'sse' + }; + } catch (sseError) { + console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); + throw new Error('Could not connect to server with any available transport'); + } + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start a notification stream by calling the notification tool + */ +async function startNotificationTool(client: Client): Promise { + try { + // Call the notification tool using reasonable defaults + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1000, // 1 second between notifications + count: 5 // Send 5 notifications + } + } + }; + + console.log('Calling notification tool...'); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + }); + } catch (error) { + console.log(`Error calling notification tool: ${error}`); + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index 1d322112..02d8c2de 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -3,76 +3,80 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { z } from 'zod'; -import { CallToolResult } from '../../types.js'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; + // Create an MCP server with implementation details -const server = new McpServer({ - name: 'json-response-streamable-http-server', - version: '1.0.0', -}, { - capabilities: { - logging: {}, - } -}); +const getServer = () => { + const server = new McpServer({ + name: 'json-response-streamable-http-server', + version: '1.0.0', + }, { + capabilities: { + logging: {}, + } + }); -// Register a simple tool that returns a greeting -server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!`, - }, - ], - }; - } -); + // Register a simple tool that returns a greeting + server.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!`, + }, + ], + }; + } + ); -// Register a tool that sends multiple greetings with notifications -server.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + // Register a tool that sends multiple greetings with notifications + server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); - await sleep(1000); // Wait 1 second before first greeting + await sleep(1000); // Wait 1 second before first greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); - await sleep(1000); // Wait another second before second greeting + await sleep(1000); // Wait another second before second greeting - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!`, - } - ], - }; - } -); + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } + ); + return server; +} const app = express(); app.use(express.json()); @@ -95,18 +99,18 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, // Enable JSON response mode + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); // Connect the transport to the MCP server BEFORE handling the request + const server = getServer(); 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 @@ -138,45 +142,21 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); -// 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'; -} +// 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'); +}); // Start the server const PORT = 3000; app.listen(PORT, () => { console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); - console.log(`Initialize session with the command below id you are using curl for testing: - ----------------------------- - SESSION_ID=$(curl -X POST \ - -H "Content-Type: application/json" \ - -H "Accept: application/json" \ - -H "Accept: text/event-stream" \ - -d '{ - "jsonrpc": "2.0", - "method": "initialize", - "params": { - "capabilities": {}, - "protocolVersion": "2025-03-26", - "clientInfo": { - "name": "test", - "version": "1.0.0" - } - }, - "id": "1" - }' \ - -i http://localhost:3000/mcp 2>&1 | grep -i "mcp-session-id" | cut -d' ' -f2 | tr -d '\\r') - echo "Session ID: $SESSION_ID" - -----------------------------`); }); // Handle server shutdown process.on('SIGINT', async () => { console.log('Shutting down server...'); - await server.close(); process.exit(0); }); \ No newline at end of file diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts new file mode 100644 index 00000000..c3417920 --- /dev/null +++ b/src/examples/server/simpleSseServer.ts @@ -0,0 +1,168 @@ +import express, { Request, Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult } from '../../types.js'; + +/** + * This example server demonstrates the deprecated HTTP+SSE transport + * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. + * + * The server exposes two endpoints: + * - /mcp: For establishing the SSE stream (GET) + * - /messages: For receiving client messages (POST) + * + */ + +// Create an MCP server instance +const getServer = () => { + const server = new McpServer({ + name: 'simple-sse-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); + + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + // Send the initial notification + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Starting notification stream with ${count} messages every ${interval}ms` + } + }); + + // Send periodic notifications + while (counter < count) { + counter++; + await sleep(interval); + + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + } + + return { + content: [ + { + type: 'text', + text: `Completed sending ${count} notifications every ${interval}ms`, + } + ], + }; + } + ); + return server; +}; + +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +// SSE endpoint for establishing the stream +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (establishing SSE stream)'); + + try { + // Create a new SSE transport for the client + // The endpoint for POST messages is '/messages' + const transport = new SSEServerTransport('/messages', res); + + // Store the transport by session ID + const sessionId = transport.sessionId; + transports[sessionId] = transport; + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + console.log(`SSE transport closed for session ${sessionId}`); + delete transports[sessionId]; + }; + + // Connect the transport to the MCP server + const server = getServer(); + await server.connect(transport); + + console.log(`Established SSE stream with session ID: ${sessionId}`); + } catch (error) { + console.error('Error establishing SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Messages endpoint for receiving client JSON-RPC requests +app.post('/messages', async (req: Request, res: Response) => { + console.log('Received POST request to /messages'); + + // Extract session ID from URL query parameter + // In the SSE protocol, this is added by the client based on the endpoint event + const sessionId = req.query.sessionId as string | undefined; + + if (!sessionId) { + console.error('No session ID provided in request URL'); + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = transports[sessionId]; + if (!transport) { + console.error(`No active transport found for session ID: ${sessionId}`); + res.status(404).send('Session not found'); + return; + } + + try { + // Handle the POST message with the transport + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts new file mode 100644 index 00000000..6fb2ae83 --- /dev/null +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -0,0 +1,162 @@ +import express, { Request, Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { z } from 'zod'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; + +const getServer = () => { + // Create an MCP server with implementation details + const server = new McpServer({ + name: 'stateless-streamable-http-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); + + // Register a simple prompt + server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, + }, + ], + }; + } + ); + + // Register a tool specifically for testing resumability + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(10), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + + // Create a simple resource at a fixed URI + server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } + ); + return server; +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req: Request, res: Response) => { + const server = getServer(); + try { + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed." + }, + id: null + })); +}); + + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + process.exit(0); +}); \ No newline at end of file diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 5b228cbd..1933cc94 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,113 +1,163 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { z } from 'zod'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import { CallToolResult, GetPromptResult, isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; // Create an MCP server with implementation details -const server = new McpServer({ - name: 'simple-streamable-http-server', - version: '1.0.0', -}, { capabilities: { logging: {} } }); +const getServer = () => { + const server = new McpServer({ + name: 'simple-streamable-http-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); -// Register a simple tool that returns a greeting -server.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }): Promise => { - return { - content: [ - { - type: 'text', - text: `Hello, ${name}!`, - }, - ], - }; - } -); - -// Register a tool that sends multiple greetings with notifications -server.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet'), - }, - async ({ name }, { sendNotification }): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await sendNotification({ - method: "notifications/message", - params: { level: "debug", data: `Starting multi-greet for ${name}` } - }); - - await sleep(1000); // Wait 1 second before first greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending first greeting to ${name}` } - }); - - await sleep(1000); // Wait another second before second greeting - - await sendNotification({ - method: "notifications/message", - params: { level: "info", data: `Sending second greeting to ${name}` } - }); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!`, - } - ], - }; - } -); - -// Register a simple prompt -server.prompt( - 'greeting-template', - 'A simple greeting prompt template', - { - name: z.string().describe('Name to include in greeting'), - }, - async ({ name }): Promise => { - return { - messages: [ - { - role: 'user', - content: { + // 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: `Please greet ${name} in a friendly manner.`, + text: `Hello, ${name}!`, }, - }, - ], - }; - } -); + ], + }; + } + ); -// 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!', - }, - ], - }; - } -); + // Register a tool that sends multiple greetings with notifications (with annotations) + server.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet'), + }, + { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + }, + async ({ name }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await sendNotification({ + method: "notifications/message", + params: { level: "debug", data: `Starting multi-greet for ${name}` } + }); + + await sleep(1000); // Wait 1 second before first greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending first greeting to ${name}` } + }); + + await sleep(1000); // Wait another second before second greeting + + await sendNotification({ + method: "notifications/message", + params: { level: "info", data: `Sending second greeting to ${name}` } + }); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!`, + } + ], + }; + } + ); + + // Register a simple prompt + server.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + name: z.string().describe('Name to include in greeting'), + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.`, + }, + }, + ], + }; + } + ); + + // Register a tool specifically for testing resumability + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + + // Create a simple resource at a fixed URI + server.resource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!', + }, + ], + }; + } + ); + return server; +}; const app = express(); app.use(express.json()); @@ -127,21 +177,33 @@ app.post('/mcp', async (req: Request, res: Response) => { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request + const eventStore = new InMemoryEventStore(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } }); + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport + const server = getServer(); 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 @@ -174,45 +236,67 @@ app.post('/mcp', async (req: Request, res: Response) => { } }); -// Helper function to detect initialize requests -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some(msg => typeof msg === 'object' && msg !== null && 'method' in msg && msg.method === 'initialize'); +// Handle 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]) { + res.status(400).send('Invalid or missing session ID'); + return; } - return typeof body === 'object' && body !== null && 'method' in body && body.method === 'initialize'; -} + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Handle DELETE requests for session termination (according to MCP spec) +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); // Start the server const PORT = 3000; app.listen(PORT, () => { 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 process.on('SIGINT', async () => { console.log('Shutting down server...'); - await server.close(); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); process.exit(0); -}); \ No newline at end of file +}); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts new file mode 100644 index 00000000..ded110a1 --- /dev/null +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -0,0 +1,246 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from "node:crypto"; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import { z } from 'zod'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; + +/** + * This example server demonstrates backwards compatibility with both: + * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) + * 2. The Streamable HTTP transport (protocol version 2025-03-26) + * + * It maintains a single MCP server instance but exposes two transport options: + * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) + * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) + * - /messages: The deprecated POST endpoint for older clients (POST to send messages) + */ + +const getServer = () => { + const server = new McpServer({ + name: 'backwards-compatible-server', + version: '1.0.0', + }, { capabilities: { logging: {} } }); + + // Register a simple tool that sends notifications over time + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50), + }, + async ({ interval, count }, { sendNotification }): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + } + }); + } + catch (error) { + console.error("Error sending notification:", error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms`, + } + ], + }; + } + ); + return server; +}; + +// Create Express application +const app = express(); +app.use(express.json()); + +// Store transports by session ID +const transports: Record = {}; + +//============================================================================= +// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) +//============================================================================= + +// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint +app.all('/mcp', async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Check if the transport is of the correct type + const existingTransport = transports[sessionId]; + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server + const server = getServer(); + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with the transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}); + +//============================================================================= +// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) +//============================================================================= + +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (deprecated SSE transport)'); + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on("close", () => { + delete transports[transport.sessionId]; + }); + const server = getServer(); + await server.connect(transport); +}); + +app.post("/messages", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + let transport: SSEServerTransport; + const existingTransport = transports[sessionId]; + if (existingTransport instanceof SSEServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol', + }, + id: null, + }); + return; + } + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + + +// Start the server +const PORT = 3000; +app.listen(PORT, () => { + console.log(`Backwards compatible MCP server listening on port ${PORT}`); + console.log(` +============================================== +SUPPORTED TRANSPORT OPTIONS: + +1. Streamable Http(Protocol version: 2025-03-26) + Endpoint: /mcp + Methods: GET, POST, DELETE + Usage: + - Initialize with POST to /mcp + - Establish SSE stream with GET to /mcp + - Send requests with POST to /mcp + - Terminate session with DELETE to /mcp + +2. Http + SSE (Protocol version: 2024-11-05) + Endpoints: /sse (GET) and /messages (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /messages?sessionId= +============================================== +`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); \ No newline at end of file diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts new file mode 100644 index 00000000..8c8c3baa --- /dev/null +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -0,0 +1,125 @@ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { isInitializeRequest, 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', +}); + +// 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(), + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Connect the transport to the MCP server + await server.connect(transport); + + // Handle the request - the onsessioninitialized callback will store the transport + await transport.handleRequest(req, res, req.body); + 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); +}); + + +// 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/examples/shared/inMemoryEventStore.ts b/src/examples/shared/inMemoryEventStore.ts new file mode 100644 index 00000000..fbdebe12 --- /dev/null +++ b/src/examples/shared/inMemoryEventStore.ts @@ -0,0 +1,77 @@ +import { JSONRPCMessage } from '../../types.js'; +import { EventStore } from '../../server/streamableHttp.js'; + +/** + * Simple in-memory implementation of the EventStore interface for resumability + * This is primarily intended for examples and testing, not for production use + * where a persistent storage solution would be more appropriate. + */ +export class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } + + /** + * Extracts the stream ID from an event ID + */ + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0] : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter(lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + return ''; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + return ''; + } + + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + } + } + return streamId; + } +} \ No newline at end of file diff --git a/src/inMemory.test.ts b/src/inMemory.test.ts index f7e9e979..baf43446 100644 --- a/src/inMemory.test.ts +++ b/src/inMemory.test.ts @@ -1,5 +1,6 @@ import { InMemoryTransport } from "./inMemory.js"; import { JSONRPCMessage } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; describe("InMemoryTransport", () => { let clientTransport: InMemoryTransport; @@ -35,6 +36,32 @@ describe("InMemoryTransport", () => { expect(receivedMessage).toEqual(message); }); + test("should send message with auth info from client to server", async () => { + const message: JSONRPCMessage = { + jsonrpc: "2.0", + method: "test", + id: 1, + }; + + const authInfo: AuthInfo = { + token: "test-token", + clientId: "test-client", + scopes: ["read", "write"], + expiresAt: Date.now() / 1000 + 3600, + }; + + let receivedMessage: JSONRPCMessage | undefined; + let receivedAuthInfo: AuthInfo | undefined; + serverTransport.onmessage = (msg, extra) => { + receivedMessage = msg; + receivedAuthInfo = extra?.authInfo; + }; + + await clientTransport.send(message, { authInfo }); + expect(receivedMessage).toEqual(message); + expect(receivedAuthInfo).toEqual(authInfo); + }); + test("should send message from server to client", async () => { const message: JSONRPCMessage = { jsonrpc: "2.0", diff --git a/src/inMemory.ts b/src/inMemory.ts index 106a9e7e..5dd6e81e 100644 --- a/src/inMemory.ts +++ b/src/inMemory.ts @@ -1,16 +1,22 @@ import { Transport } from "./shared/transport.js"; -import { JSONRPCMessage } from "./types.js"; +import { JSONRPCMessage, RequestId } from "./types.js"; +import { AuthInfo } from "./server/auth/types.js"; + +interface QueuedMessage { + message: JSONRPCMessage; + extra?: { authInfo?: AuthInfo }; +} /** * In-memory transport for creating clients and servers that talk to each other within the same process. */ export class InMemoryTransport implements Transport { private _otherTransport?: InMemoryTransport; - private _messageQueue: JSONRPCMessage[] = []; + private _messageQueue: QueuedMessage[] = []; onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; sessionId?: string; /** @@ -27,10 +33,8 @@ export class InMemoryTransport implements Transport { async start(): Promise { // Process any messages that were queued before start was called while (this._messageQueue.length > 0) { - const message = this._messageQueue.shift(); - if (message) { - this.onmessage?.(message); - } + const queuedMessage = this._messageQueue.shift()!; + this.onmessage?.(queuedMessage.message, queuedMessage.extra); } } @@ -41,15 +45,19 @@ export class InMemoryTransport implements Transport { this.onclose?.(); } - async send(message: JSONRPCMessage): Promise { + /** + * Sends a message with optional auth info. + * This is useful for testing authentication scenarios. + */ + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { if (!this._otherTransport) { throw new Error("Not connected"); } if (this._otherTransport.onmessage) { - this._otherTransport.onmessage(message); + this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); } else { - this._otherTransport._messageQueue.push(message); + this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); } } } diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts new file mode 100644 index 00000000..b7ff17e6 --- /dev/null +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -0,0 +1,303 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema } from '../types.js'; +import { z } from 'zod'; + +describe('Streamable HTTP Transport Session Management', () => { + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { + logging: {}, + tools: {}, + resources: {}, + prompts: {} + } + } + ); + + // Add a simple resource + mcpServer.resource( + 'test-resource', + '/test', + { description: 'A test resource' }, + async () => ({ + contents: [{ + uri: '/test', + text: 'This is a test resource content' + }] + }) + ); + + mcpServer.prompt( + 'test-prompt', + 'A test prompt', + async () => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + }] + }) + ); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { + name: z.string().describe('Name to greet').default('World'), + }, + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } + ); + + // Create transport with or without session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: withSessionManagement + ? () => randomUUID() // With session management, generate UUID + : undefined // Without session management, return undefined + }); + + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, mcpServer, serverTransport, baseUrl }; + } + + describe('Stateless Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(false); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should support multiple client connections', async () => { + // Create and connect a client + const client1 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + + // Verify that no session ID was set + expect(transport1.sessionId).toBeUndefined(); + + // List available tools + await client1.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + const client2 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport2 = new StreamableHTTPClientTransport(baseUrl); + await client2.connect(transport2); + + // Verify that no session ID was set + expect(transport2.sessionId).toBeUndefined(); + + // List available tools + await client2.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + + }); + it('should operate without session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that no session ID was set + expect(transport.sessionId).toBeUndefined(); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateless Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateless Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); + + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }, ListToolsResultSchema); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual(expect.objectContaining({ + name: 'greet' + })); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }, ListResourcesResultSchema); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }, ListPromptsResultSchema); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual(expect.objectContaining({ + name: 'test-prompt' + })); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateful Transport' + } + } + }, CallToolResultSchema); + + // Verify tool result + expect(greetingResult.content).toEqual([ + { type: 'text', text: 'Hello, Stateful Transport!' } + ]); + + // Clean up + await transport.close(); + }); + }); +}); \ No newline at end of file diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts new file mode 100644 index 00000000..efd2611f --- /dev/null +++ b/src/integration-tests/taskResumability.test.ts @@ -0,0 +1,272 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../client/index.js'; +import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; +import { McpServer } from '../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; +import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; +import { z } from 'zod'; +import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; + + + +describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { capabilities: { logging: {} } } + ); + + // Add a simple notification tool that completes quickly + mcpServer.tool( + 'send-notification', + 'Sends a single notification', + { + message: z.string().describe('Message to send').default('Test notification') + }, + async ({ message }, { sendNotification }) => { + // Send notification immediately + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: message + } + }); + + return { + content: [{ type: 'text', text: 'Notification sent' }] + }; + } + ); + + // Add a long-running tool that sends multiple notifications + mcpServer.tool( + 'run-notifications', + 'Sends multiple notifications over time', + { + count: z.number().describe('Number of notifications to send').default(10), + interval: z.number().describe('Interval between notifications in ms').default(50) + }, + async ({ count, interval }, { sendNotification }) => { + // Send notifications at specified intervals + for (let i = 0; i < count; i++) { + await sendNotification({ + method: "notifications/message", + params: { + level: "info", + data: `Notification ${i + 1} of ${count}` + } + }); + + // Wait for the specified interval before sending next notification + if (i < count - 1) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); + + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); + + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + baseUrl = await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => { }); + await serverTransport.close().catch(() => { }); + server.close(); + }); + + it('should store session ID when client connects', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientId = 'test-client-long-running'; + const notifications = []; + let lastEventId: string | undefined; + + // Create first client + const client1 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + const sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = jest.fn((eventId: string) => { + lastEventId = eventId; + }); + expect(lastEventId).toBeUndefined(); + // Start the notification tool with event tracking using request + const toolPromise = client1.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 3, + interval: 10 + } + } + }, CallToolResultSchema, { + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate + }); + + // Wait for some notifications to arrive (not all) - shorter wait time + await new Promise(resolve => setTimeout(resolve, 20)); + + // Verify we received some notifications and lastEventId was updated + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.length).toBeLessThan(4); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + + // Disconnect first client without waiting for completion + // When we close the connection, it will cause a ConnectionClosed error for + // any in-progress requests, which is expected behavior + await transport1.close(); + // Save the promise so we can catch it after closing + const catchPromise = toolPromise.catch(err => { + // This error is expected - the connection was intentionally closed + if (err?.code !== -32000) { // ConnectionClosed error code + console.error("Unexpected error type during transport close:", err); + } + }); + + + + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); + + // Wait for the rejection to be handled + await catchPromise; + + + // Create second client with same client ID + const client2 = new Client({ + id: clientId, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for second client + client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect second client with same session ID + const transport2 = new StreamableHTTPClientTransport(baseUrl, { + sessionId + }); + await client2.connect(transport2); + + // Resume the notification stream using lastEventId + // This is the key part - we're resuming the same long-running tool using lastEventId + await client2.request({ + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 1, + interval: 5 + } + } + }, CallToolResultSchema, { + resumptionToken: lastEventId, // Pass the lastEventId from the previous session + onresumptiontoken: onLastEventIdUpdate + }); + + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(1); + + + // Clean up + await transport2.close(); + + }); +}); \ No newline at end of file diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 93c5a493..c25c2b60 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -21,4 +21,10 @@ export interface AuthInfo { * When the token expires (in seconds since epoch). */ expiresAt?: number; + + /** + * Additional data associated with the token. + * This field should be used for any additional data that needs to be attached to the auth info. + */ + extra?: Record; } \ No newline at end of file diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index eaac5c71..c9be5c76 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -101,6 +101,7 @@ describe("ResourceTemplate", () => { const abortController = new AbortController(); const result = await template.listCallback?.({ signal: abortController.signal, + requestId: 'not-implemented', sendRequest: () => { throw new Error("Not implemented") }, sendNotification: () => { throw new Error("Not implemented") } }); @@ -403,7 +404,7 @@ describe("tool()", () => { ]) }); - test("should register tool with args schema", async () => { + test("should register tool with params", async () => { const mcpServer = new McpServer({ name: "test server", version: "1.0", @@ -493,6 +494,133 @@ describe("tool()", () => { expect(result.tools[0].name).toBe("test"); expect(result.tools[0].description).toBe("Test description"); }); + + test("should register tool with annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool("test", { title: "Test Tool", readOnlyHint: true }, async () => ({ + content: [ + { + type: "text", + text: "Test response", + }, + ], + })); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "tools/list", + }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + }); + + test("should register tool with params and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + { name: z.string() }, + { title: "Test Tool", readOnlyHint: true }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } } + }); + expect(result.tools[0].annotations).toEqual({ title: "Test Tool", readOnlyHint: true }); + }); + + test("should register tool with description, params, and annotations", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + const client = new Client({ + name: "test client", + version: "1.0", + }); + + mcpServer.tool( + "test", + "A tool with everything", + { name: z.string() }, + { title: "Complete Test Tool", readOnlyHint: true, openWorldHint: false }, + async ({ name }) => ({ + content: [{ type: "text", text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { method: "tools/list" }, + ListToolsResultSchema, + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe("test"); + expect(result.tools[0].description).toBe("A tool with everything"); + expect(result.tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { name: { type: "string" } } + }); + expect(result.tools[0].annotations).toEqual({ + title: "Complete Test Tool", + readOnlyHint: true, + openWorldHint: false + }); + }); test("should validate tool args", async () => { const mcpServer = new McpServer({ @@ -646,6 +774,59 @@ describe("tool()", () => { expect(receivedSessionId).toBe("test-session-123"); }); + test("should pass requestId to tool callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.tool("request-id-test", async (extra) => { + receivedRequestId = extra.requestId; + return { + content: [ + { + type: "text", + text: `Received request ID: ${extra.requestId}`, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "request-id-test", + }, + }, + CallToolResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content[0].text).toContain("Received request ID:"); + }); + test("should provide sendNotification within tool call", async () => { const mcpServer = new McpServer( { @@ -1702,6 +1883,59 @@ describe("resource()", () => { expect(result.completion.values).toEqual(["movies", "music"]); expect(result.completion.total).toBe(2); }); + + test("should pass requestId to resource callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + resources: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.resource("request-id-test", "test://resource", async (_uri, extra) => { + receivedRequestId = extra.requestId; + return { + contents: [ + { + uri: "test://resource", + text: `Received request ID: ${extra.requestId}`, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "resources/read", + params: { + uri: "test://resource", + }, + }, + ReadResourceResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents[0].text).toContain("Received request ID:"); + }); }); describe("prompt()", () => { @@ -2511,4 +2745,60 @@ describe("prompt()", () => { expect(result.completion.values).toEqual(["Alice"]); expect(result.completion.total).toBe(1); }); + + test("should pass requestId to prompt callback via RequestHandlerExtra", async () => { + const mcpServer = new McpServer({ + name: "test server", + version: "1.0", + }); + + const client = new Client( + { + name: "test client", + version: "1.0", + }, + { + capabilities: { + prompts: {}, + }, + }, + ); + + let receivedRequestId: string | number | undefined; + mcpServer.prompt("request-id-test", async (extra) => { + receivedRequestId = extra.requestId; + return { + messages: [ + { + role: "assistant", + content: { + type: "text", + text: `Received request ID: ${extra.requestId}`, + }, + }, + ], + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([ + client.connect(clientTransport), + mcpServer.server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: "prompts/get", + params: { + name: "request-id-test", + }, + }, + GetPromptResultSchema, + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages[0].content.text).toContain("Received request ID:"); + }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 652f9774..6204eb2a 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -39,6 +39,7 @@ import { ReadResourceResult, ServerRequest, ServerNotification, + ToolAnnotations, } from "../types.js"; import { Completable, CompletableDef } from "./completable.js"; import { UriTemplate, Variables } from "../shared/uriTemplate.js"; @@ -118,6 +119,7 @@ export class McpServer { strictUnions: true, }) as Tool["inputSchema"]) : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, }; }, ), @@ -605,21 +607,51 @@ export class McpServer { tool(name: string, description: string, cb: ToolCallback): RegisteredTool; /** - * Registers a tool `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. + * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. + * + * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ tool( name: string, - paramsSchema: Args, + paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback, ): RegisteredTool; /** - * Registers a tool `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * Registers a tool `name` (with a description) taking either parameter schema or annotations. + * This unified overload handles both `tool(name, description, paramsSchema, cb)` and + * `tool(name, description, annotations, cb)` cases. + * + * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. + */ + tool( + name: string, + description: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with both parameter schema and annotations. + */ + tool( + name: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: ToolCallback, + ): RegisteredTool; + + /** + * Registers a tool with description, parameter schema, and annotations. */ tool( name: string, description: string, paramsSchema: Args, + annotations: ToolAnnotations, cb: ToolCallback, ): RegisteredTool; @@ -627,6 +659,13 @@ export class McpServer { if (this._registeredTools[name]) { throw new Error(`Tool ${name} is already registered`); } + + // Helper to check if an object is a Zod schema (ZodRawShape) + const isZodRawShape = (obj: unknown): obj is ZodRawShape => { + if (typeof obj !== "object" || obj === null) return false; + // Check that at least one property is a ZodType instance + return Object.values(obj as object).some(v => v instanceof ZodType); + }; let description: string | undefined; if (typeof rest[0] === "string") { @@ -634,8 +673,29 @@ export class McpServer { } let paramsSchema: ZodRawShape | undefined; + let annotations: ToolAnnotations | undefined; + + // Handle the different overload combinations if (rest.length > 1) { - paramsSchema = rest.shift() as ZodRawShape; + // We have at least two more args before the callback + const firstArg = rest[0]; + + if (isZodRawShape(firstArg)) { + // We have a params schema as the first arg + paramsSchema = rest.shift() as ZodRawShape; + + // Check if the next arg is potentially annotations + if (rest.length > 1 && typeof rest[0] === "object" && rest[0] !== null && !(isZodRawShape(rest[0]))) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } + } else if (typeof firstArg === "object" && firstArg !== null) { + // Not a ZodRawShape, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } } const cb = rest[0] as ToolCallback; @@ -643,6 +703,7 @@ export class McpServer { description, inputSchema: paramsSchema === undefined ? undefined : z.object(paramsSchema), + annotations, callback: cb, enabled: true, disable: () => registeredTool.update({ enabled: false }), @@ -656,6 +717,7 @@ export class McpServer { if (typeof updates.description !== "undefined") registeredTool.description = updates.description if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback + if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled this.sendToolListChanged() }, @@ -853,11 +915,12 @@ export type ToolCallback = export type RegisteredTool = { description?: string; inputSchema?: AnyZodObject; + annotations?: ToolAnnotations; callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, enabled?: boolean }): void + update(updates: { name?: string | null, description?: string, paramsSchema?: Args, callback?: ToolCallback, annotations?: ToolAnnotations, enabled?: boolean }): void remove(): void }; diff --git a/src/server/sse.ts b/src/server/sse.ts index 46948b47..03f6fefc 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -4,6 +4,7 @@ import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { AuthInfo } from "./auth/types.js"; import { URL } from 'url'; const MAXIMUM_MESSAGE_SIZE = "4mb"; @@ -19,7 +20,7 @@ export class SSEServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. @@ -76,7 +77,7 @@ export class SSEServerTransport implements Transport { * This should be called when a POST request is made to send a message to the server. */ async handlePostMessage( - req: IncomingMessage, + req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown, ): Promise { @@ -85,6 +86,7 @@ export class SSEServerTransport implements Transport { res.writeHead(500).end(message); throw new Error(message); } + const authInfo: AuthInfo | undefined = req.auth; let body: string | unknown; try { @@ -104,7 +106,7 @@ export class SSEServerTransport implements Transport { } try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body); + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); } catch { res.writeHead(400).end(`Invalid message: ${body}`); return; @@ -116,7 +118,7 @@ export class SSEServerTransport implements Transport { /** * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. */ - async handleMessage(message: unknown): Promise { + async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { let parsedMessage: JSONRPCMessage; try { parsedMessage = JSONRPCMessageSchema.parse(message); @@ -125,7 +127,7 @@ export class SSEServerTransport implements Transport { throw error; } - this.onmessage?.(parsedMessage); + this.onmessage?.(parsedMessage, extra); } async close(): Promise { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index ad80ea62..a0f2e0bb 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -1,1488 +1,1140 @@ -import { IncomingMessage, ServerResponse } from "node:http"; -import { StreamableHTTPServerTransport } from "./streamableHttp.js"; -import { JSONRPCMessage } from "../types.js"; -import { Readable } from "node:stream"; +import { createServer, type Server, IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; import { randomUUID } from "node:crypto"; -// Mock IncomingMessage -function createMockRequest(options: { - method: string; - headers: Record; - body?: string; -}): IncomingMessage { - const readable = new Readable(); - readable._read = () => { }; - if (options.body) { - readable.push(options.body); - readable.push(null); - } +import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./streamableHttp.js"; +import { McpServer } from "./mcp.js"; +import { CallToolResult, JSONRPCMessage } from "../types.js"; +import { z } from "zod"; - return Object.assign(readable, { - method: options.method, - headers: options.headers, - }) as IncomingMessage; +/** + * Test server configuration for StreamableHTTPServerTransport tests + */ +interface TestServerConfig { + sessionIdGenerator: (() => string) | undefined; + enableJsonResponse?: boolean; + customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; + eventStore?: EventStore; } -// Mock ServerResponse -function createMockResponse(): jest.Mocked { - const response = { - writeHead: jest.fn().mockReturnThis(), - write: jest.fn().mockReturnThis(), - end: jest.fn().mockReturnThis(), - on: jest.fn().mockReturnThis(), - emit: jest.fn().mockReturnThis(), - getHeader: jest.fn(), - setHeader: jest.fn(), - } as unknown as jest.Mocked; - return response; +/** + * Helper to create and start test HTTP server with MCP setup + */ +async function createTestServer(config: TestServerConfig = { sessionIdGenerator: (() => randomUUID()) }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer( + { name: "test-server", version: "1.0.0" }, + { capabilities: { logging: {} } } + ); + + mcpServer.tool( + "greet", + "A simple greeting tool", + { name: z.string().describe("Name to greet") }, + async ({ name }): Promise => { + return { content: [{ type: "text", text: `Hello, ${name}!` }] }; + } + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); + + return { server, transport, mcpServer, baseUrl }; +} + +/** + * Helper to stop test server + */ +async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + // First close the transport to ensure all SSE streams are closed + await transport.close(); + + // Close the server without waiting indefinitely + server.close(); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: "2.0", + method: "initialize", + params: { + clientInfo: { name: "test-client", version: "1.0" }, + protocolVersion: "2025-03-26", + capabilities: { + }, + }, + + id: "init-1", + } as JSONRPCMessage, + + toolsList: { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "tools-1", + } as JSONRPCMessage +}; + +/** + * Helper to extract text from SSE response + * Note: Can only be called once per response stream. For multiple reads, + * get the reader manually and read multiple times. + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to send JSON-RPC request + */ +async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + + if (sessionId) { + headers["mcp-session-id"] = sessionId; + } + + return fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify(message), + }); +} + +function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { + expect(data).toMatchObject({ + jsonrpc: "2.0", + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern), + }), + }); } describe("StreamableHTTPServerTransport", () => { + let server: Server; let transport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - let mockRequest: string; + let baseUrl: URL; + let sessionId: string; - beforeEach(() => { - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - mockResponse = createMockResponse(); - mockRequest = JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }); + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; }); - afterEach(() => { - jest.clearAllMocks(); + afterEach(async () => { + await stopTestServer({ server, transport }); }); - describe("Session Management", () => { - it("should generate session ID during initialization", async () => { - const initializeMessage: JSONRPCMessage = { + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get("mcp-session-id"); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it("should initialize server and generate session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("mcp-session-id")).toBeDefined(); + }); + + it("should reject second initialization request", async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); + + // Try second initialize + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: "second-init" + }; + + const response = await sendPostRequest(baseUrl, secondInitMessage); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); + }); + + it("should reject batch initialize request", async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { jsonrpc: "2.0", method: "initialize", params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - expect(transport.sessionId).toBeUndefined(); - expect(transport["_initialized"]).toBe(false); - - await transport.handleRequest(req, mockResponse); - - expect(transport.sessionId).toBeDefined(); - expect(transport["_initialized"]).toBe(true); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "mcp-session-id": transport.sessionId, - }) - ); - }); - - it("should reject second initialization request", async () => { - // First initialize - const initMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage1), - }); - - await transport.handleRequest(req1, mockResponse); - expect(transport["_initialized"]).toBe(true); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - mockResponse.end.mockClear(); - - // Try second initialize - const initMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + clientInfo: { name: "test-client-2", version: "1.0" }, + protocolVersion: "2025-03-26", }, id: "init-2", - }; + } + ]; - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage2), - }); + const response = await sendPostRequest(baseUrl, batchInitMessages); - await transport.handleRequest(req2, mockResponse); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); + }); - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Server already initialized"')); + it("should pandle post requests via sse response correctly", async () => { + sessionId = await initializeServer(); + + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + + // Read the SSE stream for the response + const text = await readSSEEvent(response); + + // Parse the SSE event + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "greet", + description: "A simple greeting tool", + }), + ]), + }), + id: "tools-1", }); + }); - it("should reject batch initialize request", async () => { - const batchInitialize: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" + it("should call a tool and return the result", async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { + name: "Test User", + }, + }, + id: "call-1", + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + result: { + content: [ + { + type: "text", + text: "Hello, Test User!", }, - id: "init-1", - }, - { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client-2", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-2", - } - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(batchInitialize), - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Invalid Request: Only one initialization request is allowed"')); - }); - - it("should reject invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an invalid session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": "invalid-session-id", - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Session not found"')); - }); - - it("should reject non-initialization requests without session ID with 400 Bad Request", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try without session ID - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - // No mcp-session-id header - }, - body: mockRequest - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header is required"')); - }); - - it("should reject requests to uninitialized server", async () => { - // Create a new transport that hasn't been initialized - const uninitializedTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - - const req = createMockRequest({ - method: "POST", - headers: { - "accept": "application/json, text/event-stream", - "content-type": "application/json", - "mcp-session-id": "any-session-id", - }, - body: mockRequest - }); - - await uninitializedTransport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Server not initialized"')); - }); - - it("should reject session ID as array", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try with an array session ID - const req = createMockRequest({ - method: "POST", - headers: { - "mcp-session-id": ["session1", "session2"], - "accept": "application/json, text/event-stream", - "content-type": "application/json", - }, - body: mockRequest, - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"message":"Bad Request: Mcp-Session-Id header must be a single value"')); - }); - }); - describe("Mode without state management", () => { - let transportWithoutState: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; - - beforeEach(async () => { - transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - mockResponse = createMockResponse(); - - // Initialize the transport for each test - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transportWithoutState.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should not include session ID in response headers when in mode without state management", async () => { - // Use a non-initialization request - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(message), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalled(); - // Extract the headers from writeHead call - const headers = mockResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); - }); - - it("should not validate session ID in mode without state management", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "invalid-session-id", // This would cause a 404 in mode with state management - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1 - }), - }); - - await transportWithoutState.handleRequest(req, mockResponse); - - // Should still get 200 OK, not 404 Not Found - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.not.objectContaining({ - "mcp-session-id": expect.anything(), - }) - ); - }); - - it("should handle POST requests without session validation in mode without state management", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": "non-existent-session-id", // This would be rejected in mode with state management - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transportWithoutState.onmessage = onMessageMock; - - await transportWithoutState.handleRequest(req, mockResponse); - - // Message should be processed despite invalid session ID - expect(onMessageMock).toHaveBeenCalledWith(message); - }); - - it("should work with a mix of requests with and without session IDs in mode without state management", async () => { - // First request without session ID - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-id" - }) - }); - - await transportWithoutState.handleRequest(req1, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - - // Reset mock for second request - mockResponse.writeHead.mockClear(); - - // Second request with a session ID (which would be invalid in mode with state management) - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "mcp-session-id": "some-random-session-id", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "test-id-2" - }) - }); - - await transportWithoutState.handleRequest(req2, mockResponse); - - // Should still succeed - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle initialization in mode without state management", async () => { - const transportWithoutState = new StreamableHTTPServerTransport({ sessionIdGenerator: () => undefined }); - - // Initialize message - const initializeMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(false); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initializeMessage), - }); - - const newResponse = createMockResponse(); - await transportWithoutState.handleRequest(req, newResponse); - - // After initialization, the sessionId should still be undefined - expect(transportWithoutState.sessionId).toBeUndefined(); - expect(transportWithoutState["_initialized"]).toBe(true); - - // Headers should NOT include session ID in mode without state management - const headers = newResponse.writeHead.mock.calls[0][1]; - expect(headers).not.toHaveProperty("mcp-session-id"); + ], + }, + id: "call-1", }); }); - describe("Request Handling", () => { - // Initialize the transport before tests that need initialization - beforeEach(async () => { - // For tests that need initialization, initialize here - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should reject requests without a valid session ID", async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request/); + expect(errorData.id).toBeNull(); + }); - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); + it("should reject invalid session ID", async () => { + // First initialize to be in valid state + await initializeServer(); + + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, "invalid-session-id"); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); + + it("should establish standalone SSE stream and receive server-initiated messages", async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should reject GET requests for SSE with 405 Method Not Allowed", async () => { - const req = createMockRequest({ - method: "GET", - headers: { - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - }); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); - await transport.handleRequest(req, mockResponse); - expect(mockResponse.writeHead).toHaveBeenCalledWith(405, expect.objectContaining({ - "Allow": "POST, DELETE" - })); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('Method not allowed')); - }); + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, + }; - it("should reject POST requests without proper Accept header", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; + // Send the notification via transport + await transport.send(notification); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); - await transport.handleRequest(req, mockResponse); + const eventLines = text.split("\n"); + const dataLine = eventLines.find(line => line.startsWith("data:")); + expect(dataLine).toBeDefined(); - expect(mockResponse.writeHead).toHaveBeenCalledWith(406); - }); - - it("should properly handle JSON-RPC request messages in POST requests", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: 1, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(message), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should properly handle JSON-RPC notification or response messages in POST requests", async () => { - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - }; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(notification), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledWith(notification); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch notification messages properly with 202 response", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {} }, - { jsonrpc: "2.0", method: "test2", params: {} }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - await transport.handleRequest(req, mockResponse); - - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(mockResponse.writeHead).toHaveBeenCalledWith(202); - }); - - it("should handle batch request messages with SSE when Accept header includes text/event-stream", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "test1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "test2", params: {}, id: "req2" }, - ]; - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "text/event-stream, application/json", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - mockResponse = createMockResponse(); // Create fresh mock - await transport.handleRequest(req, mockResponse); - - // Should establish SSE connection - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream" - }) - ); - expect(onMessageMock).toHaveBeenCalledTimes(2); - // Stream should remain open until responses are sent - expect(mockResponse.end).not.toHaveBeenCalled(); - }); - - it("should reject unsupported Content-Type", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "text/plain", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: "test", - }); - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(415); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - }); - - it("should properly handle DELETE requests and close session", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with proper session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": transport.sessionId, - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(200); - expect(onCloseMock).toHaveBeenCalled(); - }); - - it("should reject DELETE requests with invalid session ID", async () => { - // First initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - - // Now try DELETE with invalid session ID - const req = createMockRequest({ - method: "DELETE", - headers: { - "mcp-session-id": "invalid-session-id", - }, - }); - - const onCloseMock = jest.fn(); - transport.onclose = onCloseMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(404); - expect(onCloseMock).not.toHaveBeenCalled(); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification" }, }); }); - describe("SSE Response Handling", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; + it("should not close GET SSE stream after sending multiple server notifications", async () => { + sessionId = await initializeServer(); - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - const initResponse = createMockResponse(); - await transport.handleRequest(initReq, initResponse); - mockResponse.writeHead.mockClear(); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should send response messages as SSE events", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" } + }; - await transport.handleRequest(req, mockResponse); + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); - // Send a response to the request - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("First notification"); + expect(done).toBe(false); // Stream should still be open + }); - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); + it("should reject second SSE stream for the same session", async () => { + sessionId = await initializeServer(); - // Verify response was sent as SSE event - expect(mockResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(responseMessage)}\n\n`) - ); - - // Stream should be closed after sending response - expect(mockResponse.end).toHaveBeenCalled(); + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when sending intermediate notifications and requests", async () => { - // Setup a POST request with JSON-RPC request that accepts SSE - const requestMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "test-req-id" - }; + expect(firstStream.status).toBe(200); - // Create fresh response for this test - mockResponse = createMockResponse(); - - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage) - }); - - await transport.handleRequest(req, mockResponse); - - // Send an intermediate notification - const notification: JSONRPCMessage = { - jsonrpc: "2.0", - method: "progress", - params: { progress: "50%" } - }; - - await transport.send(notification, { relatedRequestId: "test-req-id" }); - - // Stream should remain open - expect(mockResponse.end).not.toHaveBeenCalled(); - - // Send the final response - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id" - }; - - await transport.send(responseMessage, { relatedRequestId: "test-req-id" }); - - // Now stream should be closed - expect(mockResponse.end).toHaveBeenCalled(); + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, }); - it("should keep stream open when multiple requests share the same connection", async () => { - // Create a fresh response for this test - const sharedResponse = createMockResponse(); + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); + }); - // Send two requests in a batch that will share the same connection - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "method1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "method2", params: {}, id: "req2" } - ]; + it("should reject GET requests without Accept: text/event-stream header", async () => { + sessionId = await initializeServer(); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(batchRequests) - }); - - await transport.handleRequest(req, sharedResponse); - - // Respond to first request - const response1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result1" }, - id: "req1" - }; - - await transport.send(response1); - - // Connection should remain open because req2 is still pending - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response1)}\n\n`) - ); - expect(sharedResponse.end).not.toHaveBeenCalled(); - - // Respond to second request - const response2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: "result2" }, - id: "req2" - }; - - await transport.send(response2); - - // Now connection should close as all requests are complete - expect(sharedResponse.write).toHaveBeenCalledWith( - expect.stringContaining(`event: message\ndata: ${JSON.stringify(response2)}\n\n`) - ); - expect(sharedResponse.end).toHaveBeenCalled(); + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "application/json", + "mcp-session-id": sessionId, + }, }); - it("should clean up connection tracking when a response is sent", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: {}, - id: "cleanup-test" - }) - }); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); + }); - const response = createMockResponse(); - await transport.handleRequest(req, response); + it("should reject POST requests without proper Accept header", async () => { + sessionId = await initializeServer(); - // Verify that the request is tracked in the SSE map - expect(transport["_responseMapping"].size).toBe(2); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(true); - - // Send a response - await transport.send({ - jsonrpc: "2.0", - result: {}, - id: "cleanup-test" - }); - - // Verify that the mapping was cleaned up - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("cleanup-test")).toBe(false); + // Try POST without Accept: text/event-stream + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", // Missing text/event-stream + "mcp-session-id": sessionId, + }, + body: JSON.stringify(TEST_MESSAGES.toolsList), }); - it("should clean up connection tracking when client disconnects", async () => { - // Setup two requests that share a connection - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify([ - { jsonrpc: "2.0", method: "longRunning1", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "longRunning2", params: {}, id: "req2" } + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); + }); + + it("should reject unsupported Content-Type", async () => { + sessionId = await initializeServer(); + + // Try POST with text/plain Content-Type + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "text/plain", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is plain text", + }); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); + }); + + it("should handle JSON-RPC batch notification messages with 202 response", async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "someNotification1", params: {} }, + { jsonrpc: "2.0", method: "someNotification2", params: {} }, + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); + }); + + it("should handle batch request messages with SSE stream for responses", async () => { + sessionId = await initializeServer(); + + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "BatchUser" } }, id: "req-2" }, + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it("should properly handle invalid JSON data", async () => { + sessionId = await initializeServer(); + + // Send invalid JSON + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "This is not valid JSON", + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32700, /Parse error/); + }); + + it("should return 400 error for invalid JSON-RPC messages", async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: "tools/list", params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: "2.0", + error: expect.anything(), + }); + }); + + it("should reject requests to uninitialized server", async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "uninitialized-test", + }; + + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, "any-session-id"); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); + }); + + it("should send response messages to the connection that sent the request", async () => { + sessionId = await initializeServer(); + + const message1: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "req-1" + }; + + const message2: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/call", + params: { + name: "greet", + arguments: { name: "Connection2" } + }, + id: "req-2" + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it("should keep stream open after sending server notifications", async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + // Send several server-initiated notifications + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "First notification" }, + }); + + await transport.send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Second notification" }, + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it("should properly handle DELETE requests and close session", async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get("mcp-session-id"); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: "DELETE", + headers: { "mcp-session-id": tempSessionId || "" }, + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up - don't wait indefinitely for server close + tempServer.close(); + }); + + it("should reject DELETE requests with invalid session ID", async () => { + // Initialize the server first to activate it + sessionId = await initializeServer(); + + // Try to delete with invalid session ID + const response = await fetch(baseUrl, { + method: "DELETE", + headers: { "mcp-session-id": "invalid-session-id" }, + }); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); +}); + +// Test JSON Response Mode +describe("StreamableHTTPServerTransport with JSON Response Mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: (() => randomUUID()), enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + sessionId = initResponse.headers.get("mcp-session-id") as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should return JSON response for a single request", async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "json-req-1", + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: "2.0", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) ]) - }); + }), + id: "json-req-1" + }); + }); - const response = createMockResponse(); + it("should return JSON response for batch requests", async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "JSON" } }, id: "batch-2" } + ]; - // We need to manually store the callback to trigger it later - let closeCallback: (() => void) | undefined; - response.on.mockImplementation((event, callback: () => void) => { - if (typeof event === "string" && event === "close") { - closeCallback = callback; + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const results = await response.json(); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: string }) => r.id === "batch-1"); + const callResponse = results.find((r: { id?: string }) => r.id === "batch-2"); + + expect(listResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-1", + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ name: "greet" }) + ]) + }) + })); + + expect(callResponse).toEqual(expect.objectContaining({ + jsonrpc: "2.0", + id: "batch-2", + result: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ type: "text", text: "Hello, JSON!" }) + ]) + }) + })); + }); +}); + +// Test pre-parsed body handling +describe("StreamableHTTPServerTransport with pre-parsed body", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody !== null) { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) res.writeHead(500).end(); } - return response; - }); - - await transport.handleRequest(req, response); - - // Both requests should be mapped to the same response - expect(transport["_responseMapping"].size).toBe(3); - expect(transport["_responseMapping"].get("req1")).toBe(response); - expect(transport["_responseMapping"].get("req2")).toBe(response); - - // Simulate client disconnect by triggering the stored callback - if (closeCallback) closeCallback(); - - // All entries using this response should be removed - expect(transport["_responseMapping"].size).toBe(1); - expect(transport["_responseMapping"].has("req1")).toBe(false); - expect(transport["_responseMapping"].has("req2")).toBe(false); + }, + sessionIdGenerator: (() => randomUUID()) }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; }); - describe("Message Targeting", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should send response messages to the connection that sent the request", async () => { - // Create request with two separate connections - const requestMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test1", - params: {}, - id: "req-id-1", - }; - - const mockResponse1 = createMockResponse(); - const req1 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage1), - }); - await transport.handleRequest(req1, mockResponse1); - - const requestMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test2", - params: {}, - id: "req-id-2", - }; - - const mockResponse2 = createMockResponse(); - const req2 = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId - }, - body: JSON.stringify(requestMessage2), - }); - await transport.handleRequest(req2, mockResponse2); - - // Send responses with matching IDs - const responseMessage1: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-1", - }; - - await transport.send(responseMessage1, { relatedRequestId: "req-id-1" }); - - const responseMessage2: JSONRPCMessage = { - jsonrpc: "2.0", - result: { success: true }, - id: "req-id-2", - }; - - await transport.send(responseMessage2, { relatedRequestId: "req-id-2" }); - - // Verify responses were sent to the right connections - expect(mockResponse1.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage1)) - ); - - expect(mockResponse2.write).toHaveBeenCalledWith( - expect.stringContaining(JSON.stringify(responseMessage2)) - ); - - // Verify responses were not sent to the wrong connections - const resp1HasResp2 = mockResponse1.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage2)) - ); - expect(resp1HasResp2).toBe(false); - - const resp2HasResp1 = mockResponse2.write.mock.calls.some(call => - typeof call[0] === 'string' && call[0].includes(JSON.stringify(responseMessage1)) - ); - expect(resp2HasResp1).toBe(false); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); }); - describe("Error Handling", () => { - it("should return 400 error for invalid JSON data", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: "invalid json", - }); + it("should accept pre-parsed request body", async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-1", + }; - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; - - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"code":-32700')); - expect(onErrorMock).toHaveBeenCalled(); + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + // Empty body - we're testing pre-parsed body + body: "" }); - it("should return 400 error for invalid JSON-RPC messages", async () => { - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify({ invalid: "message" }), - }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); - const onErrorMock = jest.fn(); - transport.onerror = onErrorMock; + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - await transport.handleRequest(req, mockResponse); - - expect(mockResponse.writeHead).toHaveBeenCalledWith(400); - expect(mockResponse.end).toHaveBeenCalledWith(expect.stringContaining('"jsonrpc":"2.0"')); - expect(onErrorMock).toHaveBeenCalled(); - }); + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); }); - describe("JSON Response Mode", () => { - let jsonResponseTransport: StreamableHTTPServerTransport; - let mockResponse: jest.Mocked; + it("should handle pre-parsed batch messages", async () => { + parsedBody = [ + { jsonrpc: "2.0", method: "tools/list", params: {}, id: "batch-1" }, + { jsonrpc: "2.0", method: "tools/call", params: { name: "greet", arguments: { name: "PreParsed" } }, id: "batch-2" } + ]; - beforeEach(async () => { - jsonResponseTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - }); - - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - mockResponse = createMockResponse(); - await jsonResponseTransport.handleRequest(initReq, mockResponse); - mockResponse = createMockResponse(); // Reset for tests + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: "" // Empty as we're using pre-parsed }); - it("should return JSON response for a single request", async () => { - const requestMessage: JSONRPCMessage = { + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); + }); + + it("should prefer pre-parsed body over request body", async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: "preparsed-wins", + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: JSON.stringify({ jsonrpc: "2.0", - method: "tools/list", - params: {}, - id: "test-req-id", - }; + method: "tools/call", + params: { name: "greet", arguments: { name: "Ignored" } }, + id: "ignored-id" + }) + }); - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(requestMessage), - }); + expect(response.status).toBe(200); - // Mock immediate response - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `test-result` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); +}); + +// Test resumability support +describe("StreamableHTTPServerTransport with resumability", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); + + // Simple implementation of EventStore + const eventStore: EventStore = { + + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + async replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise { + const streamId = lastEventId.split('_')[0]; + // Extract stream ID from the event ID + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + await send(eventId, message); } - }; + } + return streamId; + }, + }; - await jsonResponseTransport.handleRequest(req, mockResponse); - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Should return the response as JSON - const expectedResponse = { - jsonrpc: "2.0", - result: { value: "test-result" }, - id: "test-req-id", - }; - - expect(mockResponse.end).toHaveBeenCalledWith(JSON.stringify(expectedResponse)); + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore }); - it("should return JSON response for batch requests", async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: "2.0", method: "tools/list", params: {}, id: "req1" }, - { jsonrpc: "2.0", method: "tools/call", params: {}, id: "req2" }, - ]; + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": jsonResponseTransport.sessionId, - }, - body: JSON.stringify(batchMessages), - }); + // Verify resumability is enabled on the transport + expect((transport)['_eventStore']).toBeDefined(); - // Mock responses without enforcing specific order - jsonResponseTransport.onmessage = (message) => { - if ('method' in message && 'id' in message) { - const responseMessage: JSONRPCMessage = { - jsonrpc: "2.0", - result: { value: `result-for-${message.id}` }, - id: message.id, - }; - void jsonResponseTransport.send(responseMessage); - } - }; - - await jsonResponseTransport.handleRequest(req, mockResponse); - - // Should respond with application/json header - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "application/json", - }) - ); - - // Verify response was sent but don't assume specific order - expect(mockResponse.end).toHaveBeenCalled(); - const responseJson = JSON.parse(mockResponse.end.mock.calls[0][0] as string); - expect(Array.isArray(responseJson)).toBe(true); - expect(responseJson).toHaveLength(2); - - // Check each response exists separately without assuming order - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req1", result: { value: "result-for-req1" } })); - expect(responseJson).toContainEqual(expect.objectContaining({ id: "req2", result: { value: "result-for-req2" } })); - }); + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get("mcp-session-id") as string; + expect(sessionId).toBeDefined(); }); - describe("Handling Pre-Parsed Body", () => { - beforeEach(async () => { - // Initialize the transport - const initMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "initialize", - params: { - clientInfo: { name: "test-client", version: "1.0" }, - protocolVersion: "2025-03-26" - }, - id: "init-1", - }; - - const initReq = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - }, - body: JSON.stringify(initMessage), - }); - - await transport.handleRequest(initReq, mockResponse); - mockResponse.writeHead.mockClear(); - }); - - it("should accept pre-parsed request body", async () => { - const message: JSONRPCMessage = { - jsonrpc: "2.0", - method: "test", - params: {}, - id: "pre-parsed-test", - }; - - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, message); - - // Verify the message was processed correctly - expect(onMessageMock).toHaveBeenCalledWith(message); - expect(mockResponse.writeHead).toHaveBeenCalledWith( - 200, - expect.objectContaining({ - "Content-Type": "text/event-stream", - }) - ); - }); - - it("should handle pre-parsed batch messages", async () => { - const batchMessages: JSONRPCMessage[] = [ - { - jsonrpc: "2.0", - method: "method1", - params: { data: "test1" }, - id: "batch1" - }, - { - jsonrpc: "2.0", - method: "method2", - params: { data: "test2" }, - id: "batch2" - }, - ]; - - // Create a request without actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - // No body provided here - it will be passed as parsedBody - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, batchMessages); - - // Should be called for each message in the batch - expect(onMessageMock).toHaveBeenCalledTimes(2); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[0]); - expect(onMessageMock).toHaveBeenCalledWith(batchMessages[1]); - }); - - it("should prefer pre-parsed body over request body", async () => { - const requestBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromRequestBody", - params: {}, - id: "request-body", - }; - - const parsedBodyMessage: JSONRPCMessage = { - jsonrpc: "2.0", - method: "fromParsedBody", - params: {}, - id: "parsed-body", - }; - - // Create a request with actual body content - const req = createMockRequest({ - method: "POST", - headers: { - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "mcp-session-id": transport.sessionId, - }, - body: JSON.stringify(requestBodyMessage), - }); - - const onMessageMock = jest.fn(); - transport.onmessage = onMessageMock; - - // Pass the pre-parsed body directly - await transport.handleRequest(req, mockResponse, parsedBodyMessage); - - // Should use the parsed body instead of the request body - expect(onMessageMock).toHaveBeenCalledWith(parsedBodyMessage); - expect(onMessageMock).not.toHaveBeenCalledWith(requestBodyMessage); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); }); -}); \ No newline at end of file + + it("should store and include event IDs in server SSE messages", async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get("content-type")).toBe("text/event-stream"); + + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info", data: "Test notification with event ID" }, + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('_GET_stream')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); + + + it("should store and replay MCP server tool notifications", async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + expect(sseResponse.status).toBe(200); // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: "info", data: "First notification from MCP server" }); + + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]; + + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: "info", data: "Second notification from MCP server" }); + + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); + + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: "GET", + headers: { + Accept: "text/event-stream", + "mcp-session-id": sessionId, + "last-event-id": firstEventId + }, + }); + + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); + + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); + }); +}); + +// Test stateless mode +describe("StreamableHTTPServerTransport in stateless mode", () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it("should operate without session ID validation", async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get("mcp-session-id")).toBeNull(); + + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(200); + }); + + it("should handle POST requests with various session IDs in stateless mode", async () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "random-id-1", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t1" }), + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "mcp-session-id": "different-id-2", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: "t2" }), + }); + expect(response2.status).toBe(200); + }); + + it("should reject second SSE stream even in stateless mode", async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream1.status).toBe(200); + + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: "GET", + headers: { Accept: "text/event-stream" }, + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed + }); +}); \ No newline at end of file diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index e8844529..7112c52a 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -1,11 +1,32 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Transport } from "../shared/transport.js"; -import { JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; +import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema, RequestId } from "../types.js"; import getRawBody from "raw-body"; import contentType from "content-type"; +import { randomUUID } from "node:crypto"; const MAXIMUM_MESSAGE_SIZE = "4mb"; +export type StreamId = string; +export type EventId = string; + +/** + * Interface for resumability support via event storage + */ +export interface EventStore { + /** + * Stores an event for later retrieval + * @param streamId ID of the stream the event belongs to + * @param message The JSON-RPC message to store + * @returns The generated event ID for the stored event + */ + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; + + replayEventsAfter(lastEventId: EventId, { send }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise + }): Promise; +} + /** * Configuration options for StreamableHTTPServerTransport */ @@ -16,7 +37,16 @@ export interface StreamableHTTPServerTransportOptions { * * Return undefined to disable session management. */ - sessionIdGenerator: () => string | undefined; + sessionIdGenerator: (() => string) | undefined; + + /** + * A callback for session initialization events + * This is called when the server initializes a new session. + * Useful in cases when you need to register multiple mcp sessions + * and need to keep track of them. + * @param sessionId The generated session ID + */ + onsessioninitialized?: (sessionId: string) => void; /** * If true, the server will return JSON responses instead of starting an SSE stream. @@ -24,6 +54,12 @@ export interface StreamableHTTPServerTransportOptions { * Default is false (SSE streams are preferred). */ enableJsonResponse?: boolean; + + /** + * Event store for resumability support + * If provided, resumability will be enabled, allowing clients to reconnect and resume messages + */ + eventStore?: EventStore; } /** @@ -35,12 +71,12 @@ export interface StreamableHTTPServerTransportOptions { * ```typescript * // Stateful mode - server sets the session ID * const statefulTransport = new StreamableHTTPServerTransport({ - * sessionId: randomUUID(), + * sessionIdGenerator: () => randomUUID(), * }); * * // Stateless mode - explicitly set session ID to undefined * const statelessTransport = new StreamableHTTPServerTransport({ - * sessionId: undefined, + * sessionIdGenerator: undefined, * }); * * // Using with pre-parsed request body @@ -57,18 +93,21 @@ export interface StreamableHTTPServerTransportOptions { * - State is maintained in-memory (connections, message history) * * In stateless mode: - * - Session ID is only included in initialization responses + * - No Session ID is included in any responses * - No session validation is performed */ export class StreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode - private sessionIdGenerator: () => string | undefined; + private sessionIdGenerator: (() => string) | undefined; private _started: boolean = false; - private _responseMapping: Map = new Map(); + private _streamMapping: Map = new Map(); + private _requestToStreamMapping: Map = new Map(); private _requestResponseMap: Map = new Map(); private _initialized: boolean = false; private _enableJsonResponse: boolean = false; - + private _standaloneSseStreamId: string = '_GET_stream'; + private _eventStore?: EventStore; + private _onsessioninitialized?: (sessionId: string) => void; sessionId?: string | undefined; onclose?: () => void; @@ -78,6 +117,8 @@ export class StreamableHTTPServerTransport implements Transport { constructor(options: StreamableHTTPServerTransportOptions) { this.sessionIdGenerator = options.sessionIdGenerator; this._enableJsonResponse = options.enableJsonResponse ?? false; + this._eventStore = options.eventStore; + this._onsessioninitialized = options.onsessioninitialized; } /** @@ -97,6 +138,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 +148,131 @@ 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; + } + // Handle resumability: check for Last-Event-ID header + if (this._eventStore) { + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + await this.replayEvents(lastEventId, res); + return; + } + } + + // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, + // or else return HTTP 405 Method Not Allowed + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }; + + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + + // Check if there's already an active standalone SSE stream for this session + if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { + // Only one GET SSE stream is allowed per session + res.writeHead(409).end(JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Conflict: Only one SSE stream is allowed per session" + }, + id: null + })); + return; + } + + // We need to send headers immediately as messages will arrive much later, + // otherwise the client will just wait for the first message + res.writeHead(200, headers).flushHeaders(); + + // Assign the response to the standalone SSE stream + this._streamMapping.set(this._standaloneSseStreamId, res); + // Set up close handler for client disconnects + res.on("close", () => { + this._streamMapping.delete(this._standaloneSseStreamId); + }); + } + + /** + * Replays events that would have been sent after the specified event ID + * Only used when resumability is enabled + */ + private async replayEvents(lastEventId: string, res: ServerResponse): Promise { + if (!this._eventStore) { + return; + } + try { + const headers: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }; + + if (this.sessionId !== undefined) { + headers["mcp-session-id"] = this.sessionId; + } + res.writeHead(200, headers).flushHeaders(); + + const streamId = await this._eventStore?.replayEventsAfter(lastEventId, { + send: async (eventId: string, message: JSONRPCMessage) => { + if (!this.writeSSEEvent(res, message, eventId)) { + this.onerror?.(new Error("Failed replay events")); + res.end(); + } + } + }); + this._streamMapping.set(streamId, res); + } catch (error) { + this.onerror?.(error as Error); + } + } + + /** + * Writes an event to the SSE stream with proper formatting + */ + private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean { + let eventData = `event: message\n`; + // Include event ID if provided - this is important for resumability + if (eventId) { + eventData += `id: ${eventId}\n`; + } + eventData += `data: ${JSON.stringify(message)}\n\n`; + + return res.write(eventData); + } + + /** + * Handles unsupported requests (PUT, PATCH, etc.) */ private async handleUnsupportedRequest(res: ServerResponse): Promise { res.writeHead(405, { - "Allow": "POST, DELETE" + "Allow": "GET, POST, DELETE" }).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -177,11 +339,11 @@ export class StreamableHTTPServerTransport implements Transport { // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ - const isInitializationRequest = messages.some( - msg => 'method' in msg && msg.method === 'initialize' - ); + const isInitializationRequest = messages.some(isInitializeRequest); if (isInitializationRequest) { - if (this._initialized) { + // If it's a server with session management and the session ID is already set we should reject the request + // to avoid re-initialization. + if (this._initialized && this.sessionId !== undefined) { res.writeHead(400).end(JSON.stringify({ jsonrpc: "2.0", error: { @@ -203,9 +365,15 @@ export class StreamableHTTPServerTransport implements Transport { })); return; } - this.sessionId = this.sessionIdGenerator(); + this.sessionId = this.sessionIdGenerator?.(); this._initialized = true; + // If we have a session ID and an onsessioninitialized handler, call it immediately + // This is needed in cases where the server needs to keep track of multiple sessions + if (this.sessionId && this._onsessioninitialized) { + this._onsessioninitialized(this.sessionId); + } + } // If an Mcp-Session-Id is returned by the server during initialization, // clients using the Streamable HTTP transport MUST include it @@ -216,11 +384,9 @@ export class StreamableHTTPServerTransport implements Transport { // check if it contains requests - const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg); - const hasOnlyNotificationsOrResponses = messages.every(msg => - ('method' in msg && !('id' in msg)) || ('result' in msg || 'error' in msg)); + const hasRequests = messages.some(isJSONRPCRequest); - if (hasOnlyNotificationsOrResponses) { + if (!hasRequests) { // if it only contains notifications or responses, return 202 res.writeHead(202).end(); @@ -231,6 +397,7 @@ export class StreamableHTTPServerTransport implements Transport { } else if (hasRequests) { // The default behavior is to use SSE streaming // but in some cases server will return JSON responses + const streamId = randomUUID(); if (!this._enableJsonResponse) { const headers: Record = { "Content-Type": "text/event-stream", @@ -248,20 +415,14 @@ export class StreamableHTTPServerTransport implements Transport { // Store the response for this request to send messages back through this connection // We need to track by request ID to maintain the connection for (const message of messages) { - if ('method' in message && 'id' in message) { - this._responseMapping.set(message.id, res); + if (isJSONRPCRequest(message)) { + this._streamMapping.set(streamId, res); + this._requestToStreamMapping.set(message.id, streamId); } } - // Set up close handler for client disconnects res.on("close", () => { - // Remove all entries that reference this response - for (const [id, storedRes] of this._responseMapping.entries()) { - if (storedRes === res) { - this._responseMapping.delete(id); - this._requestResponseMap.delete(id); - } - } + this._streamMapping.delete(streamId); }); // handle each message @@ -302,6 +463,11 @@ export class StreamableHTTPServerTransport implements Transport { * Returns true if the session is valid, false otherwise */ private validateSession(req: IncomingMessage, res: ServerResponse): boolean { + if (this.sessionIdGenerator === undefined) { + // If the sessionIdGenerator ID is not set, the session management is disabled + // and we don't need to validate the session ID + return true; + } if (!this._initialized) { // If the server has not been initialized yet, reject all requests res.writeHead(400).end(JSON.stringify({ @@ -314,11 +480,7 @@ export class StreamableHTTPServerTransport implements Transport { })); return false; } - if (this.sessionId === undefined) { - // If the session ID is not set, the session management is disabled - // and we don't need to validate the session ID - return true; - } + const sessionId = req.headers["mcp-session-id"]; if (!sessionId) { @@ -362,49 +524,82 @@ export class StreamableHTTPServerTransport implements Transport { async close(): Promise { // Close all SSE connections - this._responseMapping.forEach((response) => { + this._streamMapping.forEach((response) => { response.end(); }); - this._responseMapping.clear(); + this._streamMapping.clear(); // Clear any pending responses this._requestResponseMap.clear(); - this.onclose?.(); } async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; - if ('result' in message || 'error' in message) { + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { // If the message is a response, use the request ID from the message requestId = message.id; } + + // 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 (isJSONRPCResponse(message) || isJSONRPCError(message)) { + throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); + } + const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId) + if (standaloneSse === undefined) { + // The spec says the server MAY send messages on the stream, so it's ok to discard if no stream + return; + } + + // Generate and store event ID if event store is provided + let eventId: string | undefined; + if (this._eventStore) { + // Stores the event and gets the generated event ID + eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); + } + + // Send the message to the standalone SSE stream + this.writeSSEEvent(standaloneSse, message, eventId); + return; } // Get the response for this request - const response = this._responseMapping.get(requestId); - if (!response) { + const streamId = this._requestToStreamMapping.get(requestId); + const response = this._streamMapping.get(streamId!); + if (!streamId) { throw new Error(`No connection established for request ID: ${String(requestId)}`); } - if (!this._enableJsonResponse) { - response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); - } - if ('result' in message || 'error' in message) { - this._requestResponseMap.set(requestId, message); + // For SSE responses, generate event ID if event store is provided + let eventId: string | undefined; - // Get all request IDs that share the same request response object - const relatedIds = Array.from(this._responseMapping.entries()) - .filter(([_, res]) => res === response) + if (this._eventStore) { + eventId = await this._eventStore.storeEvent(streamId, message); + } + if (response) { + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } + } + + if (isJSONRPCResponse(message) || isJSONRPCError(message)) { + this._requestResponseMap.set(requestId, message); + const relatedIds = Array.from(this._requestToStreamMapping.entries()) + .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) .map(([id]) => id); // Check if we have responses for all requests using this connection const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); if (allResponsesReady) { + if (!response) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } if (this._enableJsonResponse) { // All responses ready, send as JSON const headers: Record = { @@ -430,7 +625,7 @@ export class StreamableHTTPServerTransport implements Transport { // Clean up for (const id of relatedIds) { this._requestResponseMap.delete(id); - this._responseMapping.delete(id); + this._requestToStreamMapping.delete(id); } } } diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 07bfc02c..4694929d 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -21,8 +21,10 @@ import { RequestId, Result, ServerCapabilities, + RequestMeta, } from "../types.js"; -import { Transport } from "./transport.js"; +import { Transport, TransportSendOptions } from "./transport.js"; +import { AuthInfo } from "../server/auth/types.js"; /** * Callback for progress notifications. @@ -82,12 +84,7 @@ export type RequestOptions = { * If not specified, there is no maximum total timeout. */ maxTotalTimeout?: number; - - /** - * May be used to indicate to the transport which incoming request to associate this outgoing request with. - */ - relatedRequestId?: RequestId; -}; +} & TransportSendOptions; /** * Options that can be given per notification. @@ -109,11 +106,27 @@ export type RequestHandlerExtra { + this._transport.onmessage = (message, extra) => { if (isJSONRPCResponse(message) || isJSONRPCError(message)) { this._onresponse(message); } else if (isJSONRPCRequest(message)) { - this._onrequest(message); + this._onrequest(message, extra); } else if (isJSONRPCNotification(message)) { this._onnotification(message); } else { @@ -326,7 +339,7 @@ export abstract class Protocol< ); } - private _onrequest(request: JSONRPCRequest): void { + private _onrequest(request: JSONRPCRequest, extra?: { authInfo?: AuthInfo }): void { const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; @@ -351,20 +364,22 @@ export abstract class Protocol< const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); - // Create extra object with both abort signal and sessionId from transport - const extra: RequestHandlerExtra = { + const fullExtra: RequestHandlerExtra = { signal: abortController.signal, sessionId: this._transport?.sessionId, + _meta: request.params?._meta, sendNotification: (notification) => this.notification(notification, { relatedRequestId: request.id }), sendRequest: (r, resultSchema, options?) => - this.request(r, resultSchema, { ...options, relatedRequestId: request.id }) + this.request(r, resultSchema, { ...options, relatedRequestId: request.id }), + authInfo: extra?.authInfo, + requestId: request.id, }; // Starting with Promise.resolve() puts any synchronous errors into the monad as well. Promise.resolve() - .then(() => handler(request, extra)) + .then(() => handler(request, fullExtra)) .then( (result) => { if (abortController.signal.aborted) { @@ -501,7 +516,7 @@ export abstract class Protocol< resultSchema: T, options?: RequestOptions, ): Promise> { - const { relatedRequestId } = options ?? {}; + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; return new Promise((resolve, reject) => { if (!this._transport) { @@ -543,7 +558,7 @@ export abstract class Protocol< requestId: messageId, reason: String(reason), }, - }, { relatedRequestId }) + }, { relatedRequestId, resumptionToken, onresumptiontoken }) .catch((error) => this._onerror(new Error(`Failed to send cancellation: ${error}`)), ); @@ -581,7 +596,7 @@ export abstract class Protocol< this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - this._transport.send(jsonrpcRequest, { relatedRequestId }).catch((error) => { + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error) => { this._cleanupTimeout(messageId); reject(error); }); diff --git a/src/shared/transport.ts b/src/shared/transport.ts index e464653b..fe0a60e6 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -1,5 +1,29 @@ +import { AuthInfo } from "../server/auth/types.js"; import { JSONRPCMessage, RequestId } from "../types.js"; +/** + * Options for sending a JSON-RPC message. + */ +export type TransportSendOptions = { + /** + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + */ + relatedRequestId?: RequestId; + + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. + */ + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; +} /** * Describes the minimal contract for a MCP transport that a client or server can communicate over. */ @@ -18,7 +42,7 @@ export interface Transport { * * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. */ - send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise; + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; /** * Closes the connection. @@ -41,8 +65,11 @@ export interface Transport { /** * Callback for when a message (request or response) is received over the connection. + * + * Includes the authInfo if the transport is authenticated. + * */ - onmessage?: (message: JSONRPCMessage) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; /** * The session ID generated for this connection. diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 00000000..0fbc003d --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,17 @@ +import { LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from "./types.js"; + +describe("Types", () => { + + test("should have correct latest protocol version", () => { + expect(LATEST_PROTOCOL_VERSION).toBeDefined(); + expect(LATEST_PROTOCOL_VERSION).toBe("2025-03-26"); + }); + test("should have correct supported protocol versions", () => { + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeInstanceOf(Array); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-11-05"); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2024-10-07"); + }); + +}); diff --git a/src/types.ts b/src/types.ts index db6bf125..2ee0f752 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,9 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2024-11-05"; +export const LATEST_PROTOCOL_VERSION = "2025-03-26"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, + "2024-11-05", "2024-10-07", ]; @@ -19,18 +20,18 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); +const RequestMetaSchema = z + .object({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: z.optional(ProgressTokenSchema), + }) + .passthrough(); + const BaseRequestParamsSchema = z .object({ - _meta: z.optional( - z - .object({ - /** - * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. - */ - progressToken: z.optional(ProgressTokenSchema), - }) - .passthrough(), - ), + _meta: z.optional(RequestMetaSchema), }) .passthrough(); @@ -248,6 +249,10 @@ export const InitializeRequestSchema = RequestSchema.extend({ }), }); +export const isInitializeRequest = (value: unknown): value is InitializeRequest => + InitializeRequestSchema.safeParse(value).success; + + /** * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. */ @@ -337,6 +342,9 @@ export const InitializedNotificationSchema = NotificationSchema.extend({ method: z.literal("notifications/initialized"), }); +export const isInitializedNotification = (value: unknown): value is InitializedNotification => + InitializedNotificationSchema.safeParse(value).success; + /* Ping */ /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. @@ -745,6 +753,62 @@ export const PromptListChangedNotificationSchema = NotificationSchema.extend({ }); /* Tools */ +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + */ +export const ToolAnnotationsSchema = z + .object({ + /** + * A human-readable title for the tool. + */ + title: z.optional(z.string()), + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint: z.optional(z.boolean()), + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint: z.optional(z.boolean()), + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint: z.optional(z.boolean()), + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint: z.optional(z.boolean()), + }) + .passthrough(); + /** * Definition for a tool the client can call. */ @@ -767,6 +831,10 @@ export const ToolSchema = z properties: z.optional(z.object({}).passthrough()), }) .passthrough(), + /** + * Optional additional tool information. + */ + annotations: z.optional(ToolAnnotationsSchema), }) .passthrough(); @@ -1173,6 +1241,7 @@ type Infer = Flatten>; export type ProgressToken = Infer; export type Cursor = Infer; export type Request = Infer; +export type RequestMeta = Infer; export type Notification = Infer; export type Result = Infer; export type RequestId = Infer; @@ -1239,6 +1308,7 @@ export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; /* Tools */ +export type ToolAnnotations = Infer; export type Tool = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer;