diff --git a/api/server.ts b/api/server.ts index 3459a78..47572cd 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { initializeMcpApiHandler } from "../lib/mcp-api-handler"; -const handler = initializeMcpApiHandler( +export default initializeMcpApiHandler( (server) => { // Add more tools, resources, and prompts here server.tool("echo", { message: z.string() }, async ({ message }) => ({ @@ -18,5 +18,3 @@ const handler = initializeMcpApiHandler( }, } ); - -export default handler; diff --git a/lib/mcp-api-handler.ts b/lib/mcp-api-handler.ts index 1319841..b7571cc 100644 --- a/lib/mcp-api-handler.ts +++ b/lib/mcp-api-handler.ts @@ -1,279 +1,68 @@ -import getRawBody from "raw-body"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "http"; -import { createClient } from "redis"; -import { Socket } from "net"; -import { Readable } from "stream"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { IncomingMessage, ServerResponse } from "http"; import { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js"; -import vercelJson from "../vercel.json"; - -interface SerializedRequest { - requestId: string; - url: string; - method: string; - body: string; - headers: IncomingHttpHeaders; -} export function initializeMcpApiHandler( initializeServer: (server: McpServer) => void, serverOptions: ServerOptions = {} ) { - const maxDuration = - vercelJson?.functions?.["api/server.ts"]?.maxDuration || 800; - const redisUrl = process.env.REDIS_URL || process.env.KV_URL; - if (!redisUrl) { - throw new Error("REDIS_URL environment variable is not set"); - } - const redis = createClient({ - url: redisUrl, - }); - const redisPublisher = createClient({ - url: redisUrl, + let statelessServer: McpServer; + const statelessTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, }); - redis.on("error", (err) => { - console.error("Redis error", err); - }); - redisPublisher.on("error", (err) => { - console.error("Redis error", err); - }); - const redisPromise = Promise.all([redis.connect(), redisPublisher.connect()]); - - let servers: McpServer[] = []; return async function mcpApiHandler( req: IncomingMessage, res: ServerResponse ) { - await redisPromise; const url = new URL(req.url || "", "https://example.com"); - if (url.pathname === "/sse") { - console.log("Got new SSE connection"); - - const transport = new SSEServerTransport("/message", res); - const sessionId = transport.sessionId; - const server = new McpServer( - { - name: "mcp-typescript server on vercel", - version: "0.1.0", - }, - serverOptions - ); - initializeServer(server); - - servers.push(server); - - server.server.onclose = () => { - console.log("SSE connection closed"); - servers = servers.filter((s) => s !== server); - }; - - let logs: { - type: "log" | "error"; - messages: string[]; - }[] = []; - // This ensures that we logs in the context of the right invocation since the subscriber - // is not itself invoked in request context. - function logInContext(severity: "log" | "error", ...messages: string[]) { - logs.push({ - type: severity, - messages, - }); - } - - // Handles messages originally received via /message - const handleMessage = async (message: string) => { - console.log("Received message from Redis", message); - logInContext("log", "Received message from Redis", message); - const request = JSON.parse(message) as SerializedRequest; - - // Make in IncomingMessage object because that is what the SDK expects. - const req = createFakeIncomingMessage({ - method: request.method, - url: request.url, - headers: request.headers, - body: request.body, - }); - const syntheticRes = new ServerResponse(req); - let status = 100; - let body = ""; - syntheticRes.writeHead = (statusCode: number) => { - status = statusCode; - return syntheticRes; - }; - syntheticRes.end = (b: unknown) => { - body = b as string; - return syntheticRes; - }; - await transport.handlePostMessage(req, syntheticRes); - - await redisPublisher.publish( - `responses:${sessionId}:${request.requestId}`, + if (url.pathname === "/mcp") { + if (req.method === "GET") { + console.log("Received GET MCP request"); + res.writeHead(405).end( JSON.stringify({ - status, - body, + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, }) ); - - if (status >= 200 && status < 300) { - logInContext( - "log", - `Request ${sessionId}:${request.requestId} succeeded: ${body}` - ); - } else { - logInContext( - "error", - `Message for ${sessionId}:${request.requestId} failed with status ${status}: ${body}` - ); - } - }; - - const interval = setInterval(() => { - for (const log of logs) { - console[log.type].call(console, ...log.messages); - } - logs = []; - }, 100); - - await redis.subscribe(`requests:${sessionId}`, handleMessage); - console.log(`Subscribed to requests:${sessionId}`); - - let timeout: NodeJS.Timeout; - let resolveTimeout: (value: unknown) => void; - const waitPromise = new Promise((resolve) => { - resolveTimeout = resolve; - timeout = setTimeout(() => { - resolve("max duration reached"); - }, (maxDuration - 5) * 1000); - }); - - async function cleanup() { - clearTimeout(timeout); - clearInterval(interval); - await redis.unsubscribe(`requests:${sessionId}`, handleMessage); - console.log("Done"); - res.statusCode = 200; - res.end(); + return; } - req.on("close", () => resolveTimeout("client hang up")); - - await server.connect(transport); - const closeReason = await waitPromise; - console.log(closeReason); - await cleanup(); - } else if (url.pathname === "/message") { - console.log("Received message"); - - const body = await getRawBody(req, { - length: req.headers["content-length"], - encoding: "utf-8", - }); - - const sessionId = url.searchParams.get("sessionId") || ""; - if (!sessionId) { - res.statusCode = 400; - res.end("No sessionId provided"); + if (req.method === "DELETE") { + console.log("Received DELETE MCP request"); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }) + ); return; } - const requestId = crypto.randomUUID(); - const serializedRequest: SerializedRequest = { - requestId, - url: req.url || "", - method: req.method || "", - body: body, - headers: req.headers, - }; - - // Handles responses from the /sse endpoint. - await redis.subscribe( - `responses:${sessionId}:${requestId}`, - (message) => { - clearTimeout(timeout); - const response = JSON.parse(message) as { - status: number; - body: string; - }; - res.statusCode = response.status; - res.end(response.body); - } - ); - - // Queue the request in Redis so that a subscriber can pick it up. - // One queue per session. - await redisPublisher.publish( - `requests:${sessionId}`, - JSON.stringify(serializedRequest) - ); - console.log(`Published requests:${sessionId}`, serializedRequest); - - let timeout = setTimeout(async () => { - await redis.unsubscribe(`responses:${sessionId}:${requestId}`); - res.statusCode = 408; - res.end("Request timed out"); - }, 10 * 1000); + console.log("Got new MCP connection", req.url, req.method); + + if (!statelessServer) { + statelessServer = new McpServer( + { + name: "mcp-typescript server on vercel", + version: "0.1.0", + }, + serverOptions + ); - res.on("close", async () => { - clearTimeout(timeout); - await redis.unsubscribe(`responses:${sessionId}:${requestId}`); - }); + initializeServer(statelessServer); + await statelessServer.connect(statelessTransport); + } + await statelessTransport.handleRequest(req, res); } else { - res.statusCode = 404; - res.end("Not found"); + res.writeHead(404).end("Not found"); } }; } - -// Define the options interface -interface FakeIncomingMessageOptions { - method?: string; - url?: string; - headers?: IncomingHttpHeaders; - body?: string | Buffer | Record | null; - socket?: Socket; -} - -// Create a fake IncomingMessage -function createFakeIncomingMessage( - options: FakeIncomingMessageOptions = {} -): IncomingMessage { - const { - method = "GET", - url = "/", - headers = {}, - body = null, - socket = new Socket(), - } = options; - - // Create a readable stream that will be used as the base for IncomingMessage - const readable = new Readable(); - readable._read = (): void => {}; // Required implementation - - // Add the body content if provided - if (body) { - if (typeof body === "string") { - readable.push(body); - } else if (Buffer.isBuffer(body)) { - readable.push(body); - } else { - readable.push(JSON.stringify(body)); - } - readable.push(null); // Signal the end of the stream - } - - // Create the IncomingMessage instance - const req = new IncomingMessage(socket); - - // Set the properties - req.method = method; - req.url = url; - req.headers = headers; - - // Copy over the stream methods - req.push = readable.push.bind(readable); - req.read = readable.read.bind(readable); - req.on = readable.on.bind(readable); - req.pipe = readable.pipe.bind(readable); - - return req; -} diff --git a/package.json b/package.json index 9b7d72d..9cf62bb 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,7 @@ "license": "ISC", "packageManager": "pnpm@8.15.7+sha512.c85cd21b6da10332156b1ca2aa79c0a61ee7ad2eb0453b88ab299289e9e8ca93e6091232b25c07cbf61f6df77128d9c849e5c9ac6e44854dbd211c49f3a67adc", "dependencies": { - "@modelcontextprotocol/sdk": "^1.6.1", - "content-type": "^1.0.5", - "raw-body": "^3.0.0", - "redis": "^4.7.0", + "@modelcontextprotocol/sdk": "^1.10.2", "zod": "^3.24.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2681aa1..e1eeec8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,17 +6,8 @@ settings: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.6.1 - version: 1.6.1 - content-type: - specifier: ^1.0.5 - version: 1.0.5 - raw-body: - specifier: ^3.0.0 - version: 3.0.0 - redis: - specifier: ^4.7.0 - version: 4.7.0 + specifier: ^1.10.2 + version: 1.10.2 zod: specifier: ^3.24.2 version: 3.24.2 @@ -28,16 +19,17 @@ devDependencies: packages: - /@modelcontextprotocol/sdk@1.6.1: - resolution: {integrity: sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA==} + /@modelcontextprotocol/sdk@1.10.2: + resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==} engines: {node: '>=18'} dependencies: content-type: 1.0.5 cors: 2.8.5 + cross-spawn: 7.0.6 eventsource: 3.0.5 express: 5.0.1 express-rate-limit: 7.5.0(express@5.0.1) - pkce-challenge: 4.1.0 + pkce-challenge: 5.0.0 raw-body: 3.0.0 zod: 3.24.2 zod-to-json-schema: 3.24.3(zod@3.24.2) @@ -45,55 +37,6 @@ packages: - supports-color dev: false - /@redis/bloom@1.2.0(@redis/client@1.6.0): - resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} - peerDependencies: - '@redis/client': ^1.0.0 - dependencies: - '@redis/client': 1.6.0 - dev: false - - /@redis/client@1.6.0: - resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} - engines: {node: '>=14'} - dependencies: - cluster-key-slot: 1.1.2 - generic-pool: 3.9.0 - yallist: 4.0.0 - dev: false - - /@redis/graph@1.1.1(@redis/client@1.6.0): - resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} - peerDependencies: - '@redis/client': ^1.0.0 - dependencies: - '@redis/client': 1.6.0 - dev: false - - /@redis/json@1.0.7(@redis/client@1.6.0): - resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} - peerDependencies: - '@redis/client': ^1.0.0 - dependencies: - '@redis/client': 1.6.0 - dev: false - - /@redis/search@1.2.0(@redis/client@1.6.0): - resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} - peerDependencies: - '@redis/client': ^1.0.0 - dependencies: - '@redis/client': 1.6.0 - dev: false - - /@redis/time-series@1.1.0(@redis/client@1.6.0): - resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} - peerDependencies: - '@redis/client': ^1.0.0 - dependencies: - '@redis/client': 1.6.0 - dev: false - /@types/node@22.13.10: resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} dependencies: @@ -146,11 +89,6 @@ packages: get-intrinsic: 1.3.0 dev: false - /cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - dev: false - /content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -181,6 +119,15 @@ packages: vary: 1.1.2 dev: false + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: false + /debug@4.3.6: resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} engines: {node: '>=6.0'} @@ -353,11 +300,6 @@ packages: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} dev: false - /generic-pool@3.9.0: - resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} - engines: {node: '>= 4'} - dev: false - /get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -437,6 +379,10 @@ packages: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} dev: false + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: false + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -522,13 +468,18 @@ packages: engines: {node: '>= 0.8'} dev: false + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: false + /path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} dev: false - /pkce-challenge@4.1.0: - resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==} + /pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} dev: false @@ -569,17 +520,6 @@ packages: unpipe: 1.0.0 dev: false - /redis@4.7.0: - resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} - dependencies: - '@redis/bloom': 1.2.0(@redis/client@1.6.0) - '@redis/client': 1.6.0 - '@redis/graph': 1.1.1(@redis/client@1.6.0) - '@redis/json': 1.0.7(@redis/client@1.6.0) - '@redis/search': 1.2.0(@redis/client@1.6.0) - '@redis/time-series': 1.1.0(@redis/client@1.6.0) - dev: false - /router@2.1.0: resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==} engines: {node: '>= 18'} @@ -601,7 +541,7 @@ packages: resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} dependencies: - debug: 4.3.6 + debug: 4.4.0 destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -633,6 +573,18 @@ packages: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: false + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: false + /side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -711,12 +663,16 @@ packages: engines: {node: '>= 0.8'} dev: false - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 dev: false - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: false /zod-to-json-schema@3.24.3(zod@3.24.2): diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 4d6485b..42431bf 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -1,10 +1,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const origin = process.argv[2] || "https://mcp-on-vercel.vercel.app"; async function main() { - const transport = new SSEClientTransport(new URL(`${origin}/sse`)); + const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`)); const client = new Client( { @@ -29,3 +29,4 @@ async function main() { } main(); +main();