From 797ac16fa72a46d4e71ef4450903e51fbd968924 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Fri, 2 May 2025 23:02:18 +0100 Subject: [PATCH 1/8] Make session storage generic on transports --- src/session-storage.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/session-storage.ts b/src/session-storage.ts index 9c50fee..fe9299a 100644 --- a/src/session-storage.ts +++ b/src/session-storage.ts @@ -1,4 +1,4 @@ -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; import { EventEmitter } from "node:events"; type SessionEvents = { @@ -7,18 +7,18 @@ type SessionEvents = { error: [unknown]; }; -export class Sessions +export class Sessions extends EventEmitter - implements Iterable + implements Iterable { - private readonly sessions: Map; + private readonly sessions: Map; constructor() { super({ captureRejections: true }); this.sessions = new Map(); } - add = (id: string, transport: SSEServerTransport) => { + add = (id: string, transport: T) => { if (this.sessions.has(id)) { throw new Error("Session already exists"); } @@ -32,7 +32,7 @@ export class Sessions this.emit("terminated", id); }; - get = (id: string): SSEServerTransport | undefined => { + get = (id: string): T | undefined => { return this.sessions.get(id); }; From 4513f19ad16b71a9c0e39400e28ebc2745df25f8 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 12:11:36 +0100 Subject: [PATCH 2/8] Add support for Streamable HTTP transports --- examples/stateless-streamable-http-server.ts | 48 ++ package.json | 3 +- src/index.ts | 1 + src/mcp-sse-plugin.test.ts | 3 +- src/mcp-sse-plugin.ts | 4 +- src/streamable-http.test.ts | 480 +++++++++++++++++++ src/streamable-http.ts | 174 +++++++ yarn.lock | 111 ++++- 8 files changed, 814 insertions(+), 10 deletions(-) create mode 100644 examples/stateless-streamable-http-server.ts create mode 100644 src/streamable-http.test.ts create mode 100644 src/streamable-http.ts diff --git a/examples/stateless-streamable-http-server.ts b/examples/stateless-streamable-http-server.ts new file mode 100644 index 0000000..267d0f9 --- /dev/null +++ b/examples/stateless-streamable-http-server.ts @@ -0,0 +1,48 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.d.ts"; +import { fastify } from "fastify"; +import { Sessions, streamableHttp } from "../src"; + +const mcpServer = new McpServer({ + name: "stateless-streamable-http-server", + version: "0.0.1", +}); + +mcpServer.tool("greet", () => { + return { + content: [{ type: "text", text: "Hello, world!" }], + }; +}); + +async function main() { + const app = fastify({ + logger: { + level: "error", + transport: { + target: "pino-pretty", + options: { + translateTime: "HH:MM:ss Z", + ignore: "pid,hostname", + }, + }, + }, + }); + + app.register(streamableHttp, { + stateful: true, + mcpEndpoint: "/mcp", + createServer: () => mcpServer.server, + sessions: new Sessions(), + }); + + await app.listen({ + port: 3000, + }); + + console.log("Server is running on port 3000"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json index 12632c4..d73073b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "eslint-config-prettier": "^10.0.2", "globals": "^16.0.0", "jest": "^29.7.0", + "pino-pretty": "^13.0.0", "prettier": "3.5.3", "ts-jest": "^29.2.6", "ts-node": "^10.9.2", @@ -44,7 +45,7 @@ "typescript-eslint": "^8.25.0" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.0", + "@modelcontextprotocol/sdk": "^1.11.0", "fastify": "^5.2.1" } } diff --git a/src/index.ts b/src/index.ts index cbef419..1f7133e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { fastifyMCPSSE } from "./mcp-sse-plugin"; export { Sessions } from "./session-storage"; +export { streamableHttp } from "./streamable-http"; diff --git a/src/mcp-sse-plugin.test.ts b/src/mcp-sse-plugin.test.ts index fc967be..a0fc036 100644 --- a/src/mcp-sse-plugin.test.ts +++ b/src/mcp-sse-plugin.test.ts @@ -1,4 +1,5 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import fastify from "fastify"; import { randomUUID } from "node:crypto"; import { Readable } from "node:stream"; @@ -84,7 +85,7 @@ describe(fastifyMCPSSE.name, () => { version: "1.0.0", }); - const sessions = new Sessions(); + const sessions = new Sessions(); app.register(fastifyMCPSSE, { server: mcpServer, sessions, diff --git a/src/mcp-sse-plugin.ts b/src/mcp-sse-plugin.ts index 009a140..6ff9386 100644 --- a/src/mcp-sse-plugin.ts +++ b/src/mcp-sse-plugin.ts @@ -5,7 +5,7 @@ import { Sessions } from "./session-storage"; type MCPSSEPluginOptions = { server: Server; - sessions?: Sessions; + sessions?: Sessions; sseEndpoint?: string; messagesEndpoint?: string; }; @@ -17,7 +17,7 @@ export const fastifyMCPSSE: FastifyPluginCallback = ( ) => { const { server, - sessions = new Sessions(), + sessions = new Sessions(), sseEndpoint = "/sse", messagesEndpoint = "/messages", } = options; diff --git a/src/streamable-http.test.ts b/src/streamable-http.test.ts new file mode 100644 index 0000000..68d4903 --- /dev/null +++ b/src/streamable-http.test.ts @@ -0,0 +1,480 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.d.ts"; +import fastify from "fastify"; +import { randomUUID } from "node:crypto"; +import { createInterface, Interface } from "node:readline"; +import { Sessions } from "./session-storage"; +import { streamableHttp } from "./streamable-http"; + +describe(streamableHttp.name, () => { + const createServer = () => { + const { server } = new McpServer({ name: "test", version: "1.0.0" }); + return server; + }; + + describe("stateless mode", () => { + it("should reject GET requests", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: false, + createServer, + mcpEndpoint: "/mcp", + }); + + const response = await app.inject({ + method: "GET", + url: "/mcp", + }); + + expect(response.statusCode).toBe(405); + }); + + it("should reject DELETE requests", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: false, + createServer, + mcpEndpoint: "/mcp", + }); + + const response = await app.inject({ + method: "DELETE", + url: "/mcp", + }); + + expect(response.statusCode).toBe(405); + }); + + it("should handle POST requests", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: false, + createServer, + mcpEndpoint: "/mcp", + }); + + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("text/event-stream"); + + const rl = createInterface(response.stream()); + const values = await lines(rl); + expect(values).toEqual([ + "event: message", + `data: {"result":{"protocolVersion":"2025-03-26","capabilities":{},"serverInfo":{"name":"test","version":"1.0.0"}},"jsonrpc":"2.0","id":1}`, + "", + ]); + }); + + it("should not send a session ID header", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: false, + createServer, + mcpEndpoint: "/mcp", + }); + + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["mcp-session-id"]).toBeUndefined(); + }); + }); + + describe("stateful mode", () => { + it("should reject a request without a session ID if it it not an initialization request", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "ping", + params: {}, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + error: { + code: -32000, + }, + }); + }); + + it("should accept a request without a session ID if it is an initialization request", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("text/event-stream"); + expect(response.headers["mcp-session-id"]).toBeDefined(); + }); + + it("should reject a request with a session ID for a non existent session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": randomUUID(), + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "ping", + params: {}, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle a request with a session ID for an existing session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const initResponse = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + const sessionId = initResponse.headers["mcp-session-id"]; + expect(sessionId).toBeDefined(); + + const pingResponse = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "ping", + params: {}, + }, + }); + + expect(pingResponse.statusCode).toBe(200); + expect(pingResponse.headers["mcp-session-id"]).toBe(sessionId); + }); + + it("should reject a GET request without a session ID", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "GET", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject a GET request with a session ID for a non existent session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "GET", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": randomUUID(), + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle a GET request with a session ID for an existing session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const initResponse = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + const sessionId = initResponse.headers["mcp-session-id"]; + expect(sessionId).toBeDefined(); + + const getResponse = await app.inject({ + method: "GET", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + payloadAsStream: true, + }); + + expect(getResponse.statusCode).toBe(200); + expect(getResponse.headers["mcp-session-id"]).toBe(sessionId); + }); + + it("should reject a DELETE request without a session ID", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "DELETE", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle a DELETE request with a session ID for an existing session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const response = await app.inject({ + method: "DELETE", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": randomUUID(), + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should handle a DELETE request with a session ID for an existing session", async () => { + const app = fastify(); + + app.register(streamableHttp, { + stateful: true, + createServer, + mcpEndpoint: "/mcp", + sessions: new Sessions(), + }); + + const initResponse = await app.inject({ + method: "POST", + url: "/mcp", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "ExampleClient", + version: "1.0.0", + }, + }, + }, + }); + + const sessionId = initResponse.headers["mcp-session-id"]; + expect(sessionId).toBeDefined(); + + const deleteResponse = await app.inject({ + method: "DELETE", + url: "/mcp", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": sessionId, + }, + }); + + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.headers["mcp-session-id"]).toBe(sessionId); + }); + }); +}); + +async function lines(reader: Interface) { + const values: string[] = []; + for await (const line of reader) { + values.push(line); + } + return values; +} diff --git a/src/streamable-http.ts b/src/streamable-http.ts new file mode 100644 index 0000000..ad9259c --- /dev/null +++ b/src/streamable-http.ts @@ -0,0 +1,174 @@ +import type { Server } from "@modelcontextprotocol/sdk/server/index.d.ts"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { FastifyPluginAsync, FastifyReply } from "fastify"; +import { randomUUID } from "node:crypto"; +import { Sessions } from "./session-storage"; + +type StreamableHttpPluginOptions = + | StatefulStreamableHttpPluginOptions + | StatelessStreamableHttpPluginOptions; + +export const streamableHttp: FastifyPluginAsync< + StreamableHttpPluginOptions +> = async (fastify, options) => { + if (options.stateful) { + return statefulPlugin(fastify, options); + } + + return statelessPlugin(fastify, options); +}; + +type StatelessStreamableHttpPluginOptions = { + stateful: false; + mcpEndpoint: string; + createServer: () => Server | Promise; +}; + +const statelessPlugin: FastifyPluginAsync< + StatelessStreamableHttpPluginOptions +> = async (fastify, options) => { + const { createServer, mcpEndpoint } = options; + + fastify.post(mcpEndpoint, async (req, reply) => { + const server = await createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + reply.raw.on("close", async () => { + await transport.close(); + await server.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req.raw, reply.raw, req.body); + }); + + fastify.get(mcpEndpoint, async (req, reply) => { + return methodNotAllowed(reply); + }); + + fastify.delete(mcpEndpoint, async (req, reply) => { + return methodNotAllowed(reply); + }); +}; + +type StatefulStreamableHttpPluginOptions = { + stateful: true; + mcpEndpoint: string; + createServer: () => Server | Promise; + sessions: Sessions; +}; + +const statefulPlugin: FastifyPluginAsync< + StatefulStreamableHttpPluginOptions +> = async (fastify, options) => { + const { createServer, mcpEndpoint, sessions } = options; + + fastify.post(mcpEndpoint, async (req, reply) => { + const sessionId = req.headers["mcp-session-id"]; + if (Array.isArray(sessionId)) { + return invalidSessionId(reply); + } + + if (!sessionId) { + if (!isInitializeRequest(req.body)) { + return invalidSessionId(reply); + } + + const transport = createStatefulTransport(sessions); + + const server = await createServer(); + await server.connect(transport); + + await transport.handleRequest(req.raw, reply.raw, req.body); + } else { + const transport = sessions.get(sessionId); + if (!transport) { + return invalidSessionId(reply); + } + + await transport.handleRequest(req.raw, reply.raw, req.body); + } + }); + + fastify.get(mcpEndpoint, async (req, reply) => { + const sessionId = req.headers["mcp-session-id"]; + if (!sessionId) { + return invalidSessionId(reply); + } + + if (Array.isArray(sessionId)) { + return invalidSessionId(reply); + } + + const transport = sessions.get(sessionId); + if (!transport) { + return invalidSessionId(reply); + } + + await transport.handleRequest(req.raw, reply.raw, req.body); + }); + + fastify.delete(mcpEndpoint, async (req, reply) => { + const sessionId = req.headers["mcp-session-id"]; + if (!sessionId) { + return invalidSessionId(reply); + } + + if (Array.isArray(sessionId)) { + return invalidSessionId(reply); + } + + const transport = sessions.get(sessionId); + if (!transport) { + return invalidSessionId(reply); + } + + await transport.handleRequest(req.raw, reply.raw, req.body); + }); +}; + +function createStatefulTransport( + sessions: Sessions, +): StreamableHTTPServerTransport { + const newTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.add(id, newTransport); + }, + }); + + newTransport.onclose = () => { + if (!newTransport.sessionId) { + return; + } + + sessions.remove(newTransport.sessionId); + }; + + return newTransport; +} + +function invalidSessionId(reply: FastifyReply): void { + reply.status(400).send({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }); +} + +function methodNotAllowed(reply: FastifyReply): void { + reply.status(405).send({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }); +} diff --git a/yarn.lock b/yarn.lock index ae3e6ad..b41b8a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -888,9 +888,9 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.10.0": - version: 1.10.0 - resolution: "@modelcontextprotocol/sdk@npm:1.10.0" +"@modelcontextprotocol/sdk@npm:^1.11.0": + version: 1.11.0 + resolution: "@modelcontextprotocol/sdk@npm:1.11.0" dependencies: content-type: "npm:^1.0.5" cors: "npm:^2.8.5" @@ -902,7 +902,7 @@ __metadata: raw-body: "npm:^3.0.0" zod: "npm:^3.23.8" zod-to-json-schema: "npm:^3.24.1" - checksum: 10c0/597cc151b28345f4d273600f9f80915ad2f82c8fd542a156f807b35fe2d87669957d01fc6ac702af04b5445fc8fa2cc40253c067ef09e797e00f6bcf7342c2d3 + checksum: 10c0/10ce5ebe54b238df614051e0f2ef8f037fee6ceda7a870f5892c84efe21cbdcdb7e932d9be25e91982e0eb40e4c8ed33da9b0b2ca01df6baa76eb0cd5cb89ce6 languageName: node linkType: hard @@ -1781,6 +1781,13 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.7": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -1877,6 +1884,13 @@ __metadata: languageName: node linkType: hard +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10c0/e2023b905e8cfe2eb8444fb558562b524807a51cdfe712570f360f873271600b5c94aebffaf11efb285e2c072264a7cf243eadb68f3eba0f8cc85fb86cd25df6 + languageName: node + linkType: hard + "debug@npm:2.6.9": version: 2.6.9 resolution: "debug@npm:2.6.9" @@ -2065,6 +2079,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: "npm:^1.4.0" + checksum: 10c0/870b423afb2d54bb8d243c63e07c170409d41e20b47eeef0727547aea5740bd6717aca45597a9f2745525667a6b804c1e7bede41f856818faee5806dd9ff3975 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2392,6 +2415,13 @@ __metadata: languageName: node linkType: hard +"fast-copy@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-copy@npm:3.0.2" + checksum: 10c0/02e8b9fd03c8c024d2987760ce126456a0e17470850b51e11a1c3254eed6832e4733ded2d93316c82bc0b36aeb991ad1ff48d1ba95effe7add7c3ab8d8eb554a + languageName: node + linkType: hard + "fast-decode-uri-component@npm:^1.0.1": version: 1.0.1 resolution: "fast-decode-uri-component@npm:1.0.1" @@ -2463,6 +2493,13 @@ __metadata: languageName: node linkType: hard +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10c0/d90ec1c963394919828872f21edaa3ad6f1dddd288d2bd4e977027afff09f5db40f94e39536d4646f7e01761d704d72d51dce5af1b93717f3489ef808f5f4e4d + languageName: node + linkType: hard + "fast-uri@npm:^3.0.0, fast-uri@npm:^3.0.1": version: 3.0.6 resolution: "fast-uri@npm:3.0.6" @@ -2475,7 +2512,7 @@ __metadata: resolution: "fastify-mcp@workspace:." dependencies: "@eslint/js": "npm:^9.21.0" - "@modelcontextprotocol/sdk": "npm:^1.10.0" + "@modelcontextprotocol/sdk": "npm:^1.11.0" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.13.8" eslint: "npm:^9.21.0" @@ -2483,6 +2520,7 @@ __metadata: fastify: "npm:^5.2.1" globals: "npm:^16.0.0" jest: "npm:^29.7.0" + pino-pretty: "npm:^13.0.0" prettier: "npm:3.5.3" ts-jest: "npm:^29.2.6" ts-node: "npm:^10.9.2" @@ -2864,6 +2902,13 @@ __metadata: languageName: node linkType: hard +"help-me@npm:^5.0.0": + version: 5.0.0 + resolution: "help-me@npm:5.0.0" + checksum: 10c0/054c0e2e9ae2231c85ab5e04f75109b9d068ffcc54e58fb22079822a5ace8ff3d02c66fd45379c902ad5ab825e5d2e1451fcc2f7eab1eb49e7d488133ba4cacb + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -3625,6 +3670,13 @@ __metadata: languageName: node linkType: hard +"joycon@npm:^3.1.1": + version: 3.1.1 + resolution: "joycon@npm:3.1.1" + checksum: 10c0/131fb1e98c9065d067fd49b6e685487ac4ad4d254191d7aa2c9e3b90f4e9ca70430c43cad001602bdbdabcf58717d3b5c5b7461c1bd8e39478c8de706b3fe6ae + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -3985,6 +4037,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -4197,7 +4256,7 @@ __metadata: languageName: node linkType: hard -"once@npm:1.4.0, once@npm:^1.3.0": +"once@npm:1.4.0, once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -4382,6 +4441,29 @@ __metadata: languageName: node linkType: hard +"pino-pretty@npm:^13.0.0": + version: 13.0.0 + resolution: "pino-pretty@npm:13.0.0" + dependencies: + colorette: "npm:^2.0.7" + dateformat: "npm:^4.6.3" + fast-copy: "npm:^3.0.2" + fast-safe-stringify: "npm:^2.1.1" + help-me: "npm:^5.0.0" + joycon: "npm:^3.1.1" + minimist: "npm:^1.2.6" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pump: "npm:^3.0.0" + secure-json-parse: "npm:^2.4.0" + sonic-boom: "npm:^4.0.1" + strip-json-comments: "npm:^3.1.1" + bin: + pino-pretty: bin.js + checksum: 10c0/015dac25006c1b9820b9e01fccb8a392a019e12b30e6bfc3f3f61ecca8dbabcd000a8f3f64410b620b7f5d08579ba85e6ef137f7fbeaad70d46397a97a5f75ea + languageName: node + linkType: hard + "pino-std-serializers@npm:^7.0.0": version: 7.0.0 resolution: "pino-std-serializers@npm:7.0.0" @@ -4504,6 +4586,16 @@ __metadata: languageName: node linkType: hard +"pump@npm:^3.0.0": + version: 3.0.2 + resolution: "pump@npm:3.0.2" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10c0/5ad655cb2a7738b4bcf6406b24ad0970d680649d996b55ad20d1be8e0c02394034e4c45ff7cd105d87f1e9b96a0e3d06fd28e11fae8875da26e7f7a8e2c9726f + languageName: node + linkType: hard + "punycode@npm:^2.1.0": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -4742,6 +4834,13 @@ __metadata: languageName: node linkType: hard +"secure-json-parse@npm:^2.4.0": + version: 2.7.0 + resolution: "secure-json-parse@npm:2.7.0" + checksum: 10c0/f57eb6a44a38a3eeaf3548228585d769d788f59007454214fab9ed7f01fbf2e0f1929111da6db28cf0bcc1a2e89db5219a59e83eeaec3a54e413a0197ce879e4 + languageName: node + linkType: hard + "secure-json-parse@npm:^3.0.1": version: 3.0.2 resolution: "secure-json-parse@npm:3.0.2" From 00e416d8fcdcd412a60ed162a5a8a7f0f9f63395 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 12:20:38 +0100 Subject: [PATCH 3/8] Deprecate fastifyMCPSSE plugin --- src/mcp-sse-plugin.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mcp-sse-plugin.ts b/src/mcp-sse-plugin.ts index 6ff9386..15007c4 100644 --- a/src/mcp-sse-plugin.ts +++ b/src/mcp-sse-plugin.ts @@ -10,6 +10,13 @@ type MCPSSEPluginOptions = { messagesEndpoint?: string; }; +/** + * A plugin to run MCP servers using HTTP with SSE Transport over Fastify. + * + * @deprecated Use {@link streamableHttp} instead. The HTTP with SSE Transport + * has been deprecated from MCP protocol version 2025-03-26 onwards. Consider + * migrating to the Streamable HTTP Transport instead. + */ export const fastifyMCPSSE: FastifyPluginCallback = ( fastify, options, From cbfe17a410c305e471e2b43ba5889fd767b4b5c5 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 12:22:52 +0100 Subject: [PATCH 4/8] JSDoc on streamable http plugin --- src/streamable-http.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/streamable-http.ts b/src/streamable-http.ts index ad9259c..f6ec572 100644 --- a/src/streamable-http.ts +++ b/src/streamable-http.ts @@ -9,6 +9,27 @@ type StreamableHttpPluginOptions = | StatefulStreamableHttpPluginOptions | StatelessStreamableHttpPluginOptions; +/** + * A plugin to run MCP servers using the Streamable HTTP Transport over Fastify. + * Supports both stateless and stateful sessions. + * + * @example + * ```ts + * import { fastify } from "fastify"; + * import { streamableHttp } from "@modelcontextprotocol/fastify-streamable-http"; + * import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + * + * const app = fastify(); + * + * app.register(streamableHttp, { + * stateful: false, + * mcpEndpoint: '/mcp', + * createServer: () => new McpServer({ name: 'my-server', version: '1.0.0' }).server, + * }); + * + * await app.listen({ port: 3000 }); + * ``` + */ export const streamableHttp: FastifyPluginAsync< StreamableHttpPluginOptions > = async (fastify, options) => { From 86e799a886934a2a4aa4f857e4402eb9c204de38 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 12:57:23 +0100 Subject: [PATCH 5/8] Fix tests --- src/streamable-http.test.ts | 7 ++++--- src/streamable-http.ts | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/streamable-http.test.ts b/src/streamable-http.test.ts index 68d4903..03ed634 100644 --- a/src/streamable-http.test.ts +++ b/src/streamable-http.test.ts @@ -422,12 +422,13 @@ describe(streamableHttp.name, () => { it("should handle a DELETE request with a session ID for an existing session", async () => { const app = fastify(); + const sessions = new Sessions(); app.register(streamableHttp, { stateful: true, createServer, mcpEndpoint: "/mcp", - sessions: new Sessions(), + sessions, }); const initResponse = await app.inject({ @@ -454,19 +455,19 @@ describe(streamableHttp.name, () => { const sessionId = initResponse.headers["mcp-session-id"]; expect(sessionId).toBeDefined(); + expect(sessions.get(sessionId as string)).toBeDefined(); const deleteResponse = await app.inject({ method: "DELETE", url: "/mcp", headers: { accept: "application/json, text/event-stream", - "content-type": "application/json", "mcp-session-id": sessionId, }, }); expect(deleteResponse.statusCode).toBe(200); - expect(deleteResponse.headers["mcp-session-id"]).toBe(sessionId); + expect(sessions.get(sessionId as string)).toBeUndefined(); }); }); }); diff --git a/src/streamable-http.ts b/src/streamable-http.ts index f6ec572..ec65858 100644 --- a/src/streamable-http.ts +++ b/src/streamable-http.ts @@ -148,6 +148,10 @@ const statefulPlugin: FastifyPluginAsync< } await transport.handleRequest(req.raw, reply.raw, req.body); + + // The SDK never seems to trigger the onclose event, so we + // should manually remove the session on a DELETE request + sessions.remove(sessionId); }); }; From 817406d56cfedbe989b9da356c640f4544bd2a4f Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 12:59:35 +0100 Subject: [PATCH 6/8] Update stateless example --- examples/stateless-streamable-http-server.ts | 75 ++++++++++---------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/examples/stateless-streamable-http-server.ts b/examples/stateless-streamable-http-server.ts index 267d0f9..1d26e0b 100644 --- a/examples/stateless-streamable-http-server.ts +++ b/examples/stateless-streamable-http-server.ts @@ -1,48 +1,47 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.d.ts"; import { fastify } from "fastify"; -import { Sessions, streamableHttp } from "../src"; +import { streamableHttp } from "../src"; -const mcpServer = new McpServer({ - name: "stateless-streamable-http-server", - version: "0.0.1", +const app = fastify({ + logger: { + level: "error", + transport: { + target: "pino-pretty", + options: { + translateTime: "HH:MM:ss Z", + ignore: "pid,hostname", + }, + }, + }, }); -mcpServer.tool("greet", () => { - return { - content: [{ type: "text", text: "Hello, world!" }], - }; -}); +app.register(streamableHttp, { + stateful: false, + mcpEndpoint: "/mcp", + createServer: () => { + const mcpServer = new McpServer({ + name: "stateless-streamable-http-server", + version: "0.0.1", + }); -async function main() { - const app = fastify({ - logger: { - level: "error", - transport: { - target: "pino-pretty", - options: { - translateTime: "HH:MM:ss Z", - ignore: "pid,hostname", - }, - }, - }, - }); + mcpServer.tool("greet", () => { + return { + content: [{ type: "text", text: "Hello, world!" }], + }; + }); - app.register(streamableHttp, { - stateful: true, - mcpEndpoint: "/mcp", - createServer: () => mcpServer.server, - sessions: new Sessions(), - }); + return mcpServer.server; + }, +}); - await app.listen({ +app + .listen({ port: 3000, + }) + .then(() => { + console.log("Server is running on port 3000"); + }) + .catch((err) => { + console.error(err); + process.exit(1); }); - - console.log("Server is running on port 3000"); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); From a00093fc14c6a2bf15ab33b080e4dbd0d4733a8c Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 13:01:19 +0100 Subject: [PATCH 7/8] Add example for stateful server --- examples/stateful-streamable-http-server.ts | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 examples/stateful-streamable-http-server.ts diff --git a/examples/stateful-streamable-http-server.ts b/examples/stateful-streamable-http-server.ts new file mode 100644 index 0000000..56d4c47 --- /dev/null +++ b/examples/stateful-streamable-http-server.ts @@ -0,0 +1,38 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import fastify from "fastify"; +import { Sessions, streamableHttp } from "../src"; + +const app = fastify(); + +app.register(streamableHttp, { + stateful: true, + mcpEndpoint: "/mcp", + createServer: () => { + const mcpServer = new McpServer({ + name: "stateful-streamable-http-server", + version: "0.0.1", + }); + + mcpServer.tool("greet", () => { + return { + content: [{ type: "text", text: "Hello, world!" }], + }; + }); + + return mcpServer.server; + }, + sessions: new Sessions(), +}); + +app + .listen({ + port: 3000, + }) + .then(() => { + console.log("Server is running on port 3000"); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); From 13767f870ed6f9ac02c755ce3c376919b725be88 Mon Sep 17 00:00:00 2001 From: Kshitij Chauhan Date: Sun, 4 May 2025 13:06:04 +0100 Subject: [PATCH 8/8] Update README --- README.md | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 18d2140..fa59268 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,42 @@ # fastify-mcp -Integrate [Model Context Protocol](https://modelcontextprotocol.io/) servers with your [Fastify](https://www.fastify.dev) app over [SSE](https://en.wikipedia.org/wiki/Server-sent_events) connections. +Integrate [Model Context Protocol](https://modelcontextprotocol.io/) servers with your [Fastify](https://www.fastify.dev) app. + +Supports the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) as well as the legacy [HTTP+SSE transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse). ## Usage First, define your MCP server. ```ts -const mcpServer = new McpServer({ - name: "...", - version: "...", -}); +function createServer() { + const mcpServer = new McpServer({ + name: "...", + version: "...", + }); -mcpServer.tool("..."); -mcpServer.resource("..."); + mcpServer.tool("..."); + mcpServer.resource("..."); + + return mcpServer.server; +} ``` Create a Fastify app and register the plugin. ```ts import { fastify } from "fastify"; -import { fastifyMCPSSE } from "fastify-mcp"; +import { streamableHttp } from "fastify-mcp"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const app = fastify(); -app.register(fastifyMCPSSE, { - // Pass the lower-level Server class to the plugin - server: mcpServer.server, +app.register(streamableHttp, { + // Set to `true` if you want a stateful server + stateful: false, + mcpEndpoint: "/mcp", + sessions: new Sessions() + createServer, }); app.listen({ port: 8080 }); @@ -46,12 +56,9 @@ yarn add fastify-mcp ## Session Management -The official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) does not support managing multiple SSE sessions out of the box, and therefore it's the host server's responsibility to do so. - -This package uses an in-memory mapping of each active session against its session ID to manage multiple sessions. +The official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) does not support managing multiple sessions out of the box, and therefore it's the host server's responsibility to do so. -- The ID of a session is generated by `SSEServerTransport` from the official SDK. -- The session is removed from memory whenever the response stream is closed, either by the server or due to a client disconnection. +This package uses an in-memory mapping of each active session against its session ID to manage multiple sessions, as recommended by the MCP SDK examples. ### Session Events @@ -62,7 +69,7 @@ The `Sessions` class emits the following events: - `error`: Emitted when an asynchronous event handler throws an error. ```ts -const sessions = new Sessions(); +const sessions = new Sessions(); sessions.on("connected", (sessionId) => { console.log(`Session ${sessionId} connected`);