From cc9cf5950e4af4cbdaf4ff56178964f6694eb686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Freitas?= Date: Tue, 11 Mar 2025 19:17:21 +0000 Subject: [PATCH 1/2] Add MCP API handler, SSE routes, and Redis-based request routing --- .gitignore | 1 + app/message/route.ts | 11 + app/sse/route.ts | 32 ++ lib/mcp-api-handler-next.ts | 384 ++++++++++++++++++++++++ lib/mcp-api-handler.ts | 279 ++++++++++++++++++ package.json | 6 +- pnpm-lock.yaml | 569 +++++++++++++++++++++++++++++++++++- scripts/test-client.mjs | 31 ++ vercel.json | 8 + 9 files changed, 1318 insertions(+), 3 deletions(-) create mode 100644 app/message/route.ts create mode 100644 app/sse/route.ts create mode 100644 lib/mcp-api-handler-next.ts create mode 100644 lib/mcp-api-handler.ts create mode 100644 scripts/test-client.mjs create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index 5ef6a52..e3a7542 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.env*.local diff --git a/app/message/route.ts b/app/message/route.ts new file mode 100644 index 0000000..c8c3ae9 --- /dev/null +++ b/app/message/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from "next/server"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + console.log("POST request received", body); + return new Response("POST Hello, world!"); +} + +export async function GET(request: NextRequest) { + return new Response("GET Hello, world!"); +} diff --git a/app/sse/route.ts b/app/sse/route.ts new file mode 100644 index 0000000..9cec62b --- /dev/null +++ b/app/sse/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { initializeMcpApiHandler, createNextJsAdapter } from "@/lib/mcp-api-handler-next"; + +const mcpHandler = initializeMcpApiHandler( + (server) => { + // Add more tools, resources, and prompts here + server.tool("echo", { message: z.string() }, async ({ message }) => ({ + content: [{ type: "text", text: `Tool echo: ${message}` }], + })); + }, + { + capabilities: { + tools: { + echo: { + description: "Echo a message", + }, + }, + }, + } +); + +// Create a Next.js-compatible handler using our adapter +const nextApiHandler = createNextJsAdapter(mcpHandler); + +export async function POST(request: NextRequest) { + return nextApiHandler(request); +} + +export async function GET(request: NextRequest) { + return nextApiHandler(request); +} diff --git a/lib/mcp-api-handler-next.ts b/lib/mcp-api-handler-next.ts new file mode 100644 index 0000000..bbf35fe --- /dev/null +++ b/lib/mcp-api-handler-next.ts @@ -0,0 +1,384 @@ +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 { 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, + }); + 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}`, + JSON.stringify({ + status, + body, + }) + ); + + 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); + + try { + console.log(`Attempting to subscribe to requests:${sessionId}`); + await redis.subscribe(`requests:${sessionId}`, handleMessage).then(() => { + console.log(`Successfully subscribed to requests:${sessionId}`); + }).catch((error) => { + console.error(`Failed to subscribe to requests:${sessionId}:`, error); + throw error; // Re-throw to handle it at a higher level if needed + }); + } catch (error) { + console.error(`Failed to subscribe to requests:${sessionId}:`, error); + // Check if redis is connected + console.log(`Redis connection status: ${redis.isOpen ? 'open' : 'closed'}`); + throw error; // Re-throw to handle it at a higher level if needed + } + + 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(); + } + 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"); + 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); + + res.on("close", async () => { + clearTimeout(timeout); + await redis.unsubscribe(`responses:${sessionId}:${requestId}`); + }); + } else { + res.statusCode = 404; + res.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; + + // Create wrapper methods that maintain the correct 'this' context and return type + req.push = function(chunk: any, encoding?: BufferEncoding) { + return readable.push(chunk, encoding); + }; + + req.read = function(size?: number) { + return readable.read(size); + }; + + req.on = function(event: string, listener: (...args: any[]) => void) { + readable.on(event, listener); + return this; + }; + + req.pipe = function(destination: T, options?: { end?: boolean }): T { + return readable.pipe(destination, options); + }; + + return req; +} + +// Create a NextJS adapter for the MCP API handler +export function createNextJsAdapter(mcpApiHandler: ReturnType) { + return async function nextJsApiHandler(req: Request) { + // Convert the NextJS Request to an IncomingMessage + const url = new URL(req.url); + const headers: IncomingHttpHeaders = {}; + req.headers.forEach((value, key) => { + headers[key] = value; + }); + + let body = ''; + if (req.body) { + const clonedReq = req.clone(); + body = await clonedReq.text(); + } + + const incomingMessage = createFakeIncomingMessage({ + method: req.method, + url: url.pathname + url.search, + headers, + body, + }); + + // Create a Promise that will be resolved with the response data + return new Promise(async (resolve) => { + const serverResponse = new ServerResponse(incomingMessage); + + // Capture the response when it's ended + const originalEnd = serverResponse.end; + serverResponse.end = function(this: ServerResponse) { + // Get the response data + const statusCode = this.statusCode; + const headers: Record = {}; + + // Convert headers from ServerResponse to Response headers + const headerNames = this.getHeaderNames(); + for (const name of headerNames) { + const value = this.getHeader(name); + if (typeof value === 'string') { + headers[name] = value; + } else if (Array.isArray(value)) { + headers[name] = value.join(', '); + } + } + + // Create and resolve with the Response + const response = new Response(arguments[0], { + status: statusCode, + headers: headers, + }); + + console.log("NextJS adapter: Resolving with Response", statusCode); + resolve(response); + + // Call the original end method + return originalEnd.apply(this, arguments as any); + }; + + // Process the request with the MCP API handler + await mcpApiHandler(incomingMessage, serverResponse); + + // If response.end() was never called, resolve with a 204 response + if (!serverResponse.writableEnded) { + resolve(new Response(null, { status: 204 })); + } + }); + }; +} diff --git a/lib/mcp-api-handler.ts b/lib/mcp-api-handler.ts new file mode 100644 index 0000000..1319841 --- /dev/null +++ b/lib/mcp-api-handler.ts @@ -0,0 +1,279 @@ +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 { 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, + }); + 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}`, + JSON.stringify({ + status, + body, + }) + ); + + 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(); + } + 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"); + 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); + + res.on("close", async () => { + clearTimeout(timeout); + await redis.unsubscribe(`responses:${sessionId}:${requestId}`); + }); + } else { + res.statusCode = 404; + res.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 eb2c9a7..f1efc00 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@ai-sdk/openai": "^1.1.11", "@google-cloud/firestore": "^7.11.0", "@jest/globals": "^29.7.0", + "@modelcontextprotocol/sdk": "^1.7.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-slot": "^1.1.2", "@scalar/nextjs-api-reference": "^0.5.7", @@ -31,13 +32,16 @@ "lucide-react": "^0.475.0", "next": "15.1.0", "openapi-types": "^12.1.3", + "raw-body": "^3.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.6.1", "react-resizable-panels": "^2.1.7", + "redis": "^4.7.0", "sonner": "^2.0.1", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a22af7..a3afedd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@jest/globals': specifier: ^29.7.0 version: 29.7.0 + '@modelcontextprotocol/sdk': + specifier: ^1.7.0 + version: 1.7.0 '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -68,6 +71,9 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 + raw-body: + specifier: ^3.0.0 + version: 3.0.0 react: specifier: ^19.0.0 version: 19.0.0 @@ -80,6 +86,9 @@ importers: react-resizable-panels: specifier: ^2.1.7 version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + redis: + specifier: ^4.7.0 + version: 4.7.0 sonner: specifier: ^2.0.1 version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -89,6 +98,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) + zod: + specifier: ^3.24.2 + version: 3.24.2 devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -638,6 +650,10 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@modelcontextprotocol/sdk@1.7.0': + resolution: {integrity: sha512-IYPe/FLpvF3IZrd/f5p5ffmWhMc3aEMuM2wGJASDqC2Ge7qatVCdbfPx3n/5xFeb19xN0j/911M2AaFuircsWA==} + engines: {node: '>=18'} + '@next/env@15.1.0': resolution: {integrity: sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==} @@ -1073,6 +1089,35 @@ packages: '@types/react': optional: true + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.0': + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -1449,6 +1494,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1631,6 +1680,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + body-parser@2.1.0: + resolution: {integrity: sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1666,6 +1719,10 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.1: resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==} engines: {node: '>= 0.4'} @@ -1753,6 +1810,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.0.0: resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==} peerDependencies: @@ -1794,9 +1855,29 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1852,6 +1933,15 @@ packages: supports-color: optional: true + debug@4.3.6: + resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -1891,10 +1981,18 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -1946,6 +2044,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1964,6 +2065,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -2013,6 +2118,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -2149,6 +2257,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -2157,6 +2269,10 @@ packages: resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} engines: {node: '>=18.0.0'} + eventsource@3.0.5: + resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2169,6 +2285,16 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-rate-limit@7.5.0: + resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + engines: {node: '>= 16'} + peerDependencies: + express: ^4.11 || 5 || ^5.0.0-beta.1 + + express@5.0.1: + resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2206,6 +2332,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2237,6 +2367,10 @@ packages: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + framer-motion@12.4.2: resolution: {integrity: sha512-pW307cQKjDqEuO1flEoIFf6TkuJRfKr+c7qsHAJhDo4368N/5U8/7WU8J+xhd9+gjmOgJfgp+46evxRRFM39dA==} peerDependencies: @@ -2251,6 +2385,14 @@ packages: react-dom: optional: true + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2280,6 +2422,10 @@ packages: resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} engines: {node: '>=14'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2459,6 +2605,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -2475,6 +2625,14 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + iconv-lite@0.5.2: + resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2510,6 +2668,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2607,6 +2769,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3022,6 +3187,14 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3029,6 +3202,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.2: resolution: {integrity: sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==} @@ -3121,10 +3298,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.53.0: + resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.0: + resolution: {integrity: sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==} + engines: {node: '>= 0.6'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3157,6 +3342,9 @@ packages: motion-utils@12.0.0: resolution: {integrity: sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3171,6 +3359,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next@15.1.0: resolution: {integrity: sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3254,6 +3446,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3315,6 +3511,10 @@ packages: parse5@7.2.1: resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3334,6 +3534,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3349,6 +3553,10 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkce-challenge@4.1.0: + resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==} + engines: {node: '>=16.20.0'} + pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -3489,6 +3697,10 @@ packages: resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -3500,9 +3712,25 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + react-dom@19.0.0: resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==} peerDependencies: @@ -3591,6 +3819,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis@4.7.0: + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3697,6 +3928,10 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.1.0: + resolution: {integrity: sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3715,6 +3950,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -3730,6 +3968,14 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.1.0: + resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} + engines: {node: '>= 18'} + + serve-static@2.1.0: + resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==} + engines: {node: '>= 18'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3742,6 +3988,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3817,6 +4066,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} @@ -3983,6 +4236,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -4043,6 +4300,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-is@2.0.0: + resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4095,6 +4356,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.1.2: resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} hasBin: true @@ -4132,6 +4397,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -4140,6 +4409,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4211,6 +4484,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.7.0: resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} @@ -4843,6 +5119,20 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@modelcontextprotocol/sdk@1.7.0': + dependencies: + content-type: 1.0.5 + cors: 2.8.5 + eventsource: 3.0.5 + express: 5.0.1 + express-rate-limit: 7.5.0(express@5.0.1) + pkce-challenge: 4.1.0 + raw-body: 3.0.0 + zod: 3.24.2 + zod-to-json-schema: 3.24.1(zod@3.24.2) + transitivePeerDependencies: + - supports-color + '@next/env@15.1.0': {} '@next/eslint-plugin-next@15.1.0': @@ -5203,6 +5493,32 @@ snapshots: optionalDependencies: '@types/react': 19.0.8 + '@redis/bloom@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/client@1.6.0': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/json@1.0.7(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/search@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/time-series@1.1.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + '@remirror/core-constants@3.0.0': {} '@rtsao/scc@1.1.0': {} @@ -5665,6 +5981,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.0 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -5882,6 +6203,20 @@ snapshots: binary-extensions@2.3.0: {} + body-parser@2.1.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.0 + http-errors: 2.0.0 + iconv-lite: 0.5.2 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} brace-expansion@1.1.11: @@ -5920,6 +6255,8 @@ snapshots: dependencies: streamsearch: 1.1.0 + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.1: dependencies: es-errors: 1.3.0 @@ -6001,6 +6338,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.0.0(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@radix-ui/react-dialog': 1.0.5(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -6043,8 +6382,23 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.1: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + create-jest@29.7.0(@types/node@20.17.17): dependencies: '@jest/types': 29.6.3 @@ -6108,6 +6462,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.6: + dependencies: + ms: 2.1.2 + debug@4.4.0: dependencies: ms: 2.1.3 @@ -6136,8 +6494,12 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.0.3: optional: true @@ -6184,6 +6546,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.2 @@ -6196,6 +6560,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -6311,6 +6677,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -6361,7 +6729,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.0(jiti@1.21.7))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@9.20.0(jiti@1.21.7)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.0(jiti@1.21.7))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@9.20.0(jiti@1.21.7)))(eslint@9.20.0(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: @@ -6383,7 +6751,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.20.0(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.0(jiti@1.21.7))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@9.20.0(jiti@1.21.7)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.0(jiti@1.21.7))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@9.20.0(jiti@1.21.7)))(eslint@9.20.0(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6518,10 +6886,16 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + event-target-shim@5.0.1: {} eventsource-parser@3.0.0: {} + eventsource@3.0.5: + dependencies: + eventsource-parser: 3.0.0 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -6544,6 +6918,47 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-rate-limit@7.5.0(express@5.0.1): + dependencies: + express: 5.0.1 + + express@5.0.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.1.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.3.6 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + methods: 1.1.2 + mime-types: 3.0.0 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + router: 2.1.0 + safe-buffer: 5.2.1 + send: 1.1.0 + serve-static: 2.1.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 2.0.0 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -6588,6 +7003,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.0: + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6628,6 +7054,8 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + forwarded@0.2.0: {} + framer-motion@12.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: motion-dom: 12.0.0 @@ -6637,6 +7065,10 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) + fresh@0.5.2: {} + + fresh@2.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -6677,6 +7109,8 @@ snapshots: - encoding - supports-color + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6970,6 +7404,14 @@ snapshots: html-void-elements@3.0.0: {} + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + http-proxy-agent@5.0.0: dependencies: '@tootallnate/once': 2.0.0 @@ -6994,6 +7436,14 @@ snapshots: human-signals@2.1.0: {} + iconv-lite@0.5.2: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -7025,6 +7475,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7121,6 +7573,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.3 @@ -7835,10 +8289,16 @@ snapshots: mdurl@2.0.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromark-core-commonmark@2.0.2: dependencies: decode-named-character-reference: 1.0.2 @@ -8037,10 +8497,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.53.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.0: + dependencies: + mime-db: 1.53.0 + mimic-fn@2.1.0: {} min-indent@1.0.1: {} @@ -8067,6 +8533,8 @@ snapshots: motion-utils@12.0.0: {} + ms@2.1.2: {} + ms@2.1.3: {} mz@2.7.0: @@ -8079,6 +8547,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + next@15.1.0(@babel/core@7.26.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.1.0 @@ -8166,6 +8636,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8240,6 +8714,8 @@ snapshots: dependencies: entities: 4.5.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -8253,6 +8729,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@8.2.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -8261,6 +8739,8 @@ snapshots: pirates@4.0.6: {} + pkce-challenge@4.1.0: {} + pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -8455,14 +8935,36 @@ snapshots: '@types/node': 20.17.17 long: 5.3.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} punycode@2.3.1: {} pure-rand@6.1.0: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + react-dom@19.0.0(react@19.0.0): dependencies: react: 19.0.0 @@ -8558,6 +9060,15 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis@4.7.0: + 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) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -8732,6 +9243,12 @@ snapshots: rope-sequence@1.3.4: {} + router@2.1.0: + dependencies: + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8757,6 +9274,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + scheduler@0.25.0: {} secure-json-parse@2.7.0: {} @@ -8765,6 +9284,32 @@ snapshots: semver@7.7.1: {} + send@1.1.0: + dependencies: + debug: 4.4.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime-types: 2.1.35 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@2.1.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.1.0 + transitivePeerDependencies: + - supports-color + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8787,6 +9332,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + sharp@0.33.5: dependencies: color: 4.2.3 @@ -8885,6 +9432,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + statuses@2.0.1: {} + stream-events@1.0.5: dependencies: stubs: 3.0.0 @@ -9100,6 +9649,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tr46@0.0.3: {} trim-lines@3.0.1: {} @@ -9148,6 +9699,12 @@ snapshots: type-fest@0.21.3: {} + type-is@2.0.0: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.3 @@ -9233,6 +9790,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unpipe@1.0.0: {} + update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: browserslist: 4.24.4 @@ -9264,6 +9823,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + uuid@9.0.1: {} v8-to-istanbul@9.3.0: @@ -9272,6 +9833,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + vary@1.1.2: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -9371,6 +9934,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.7.0: {} yargs-parser@21.1.1: {} diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs new file mode 100644 index 0000000..4d6485b --- /dev/null +++ b/scripts/test-client.mjs @@ -0,0 +1,31 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +const origin = process.argv[2] || "https://mcp-on-vercel.vercel.app"; + +async function main() { + const transport = new SSEClientTransport(new URL(`${origin}/sse`)); + + const client = new Client( + { + name: "example-client", + version: "1.0.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + }, + } + ); + + await client.connect(transport); + + console.log("Connected", client.getServerCapabilities()); + + const result = await client.listTools(); + console.log(result); +} + +main(); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..b679dc0 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [{ "source": "/(.+)", "destination": "/api/server" }], + "functions": { + "api/server.ts": { + "maxDuration": 180 + } + } +} From e031c5d95ae2a4190600415c1a1c6cd1dbd6d8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Freitas?= Date: Thu, 13 Mar 2025 16:11:52 +0000 Subject: [PATCH 2/2] Add Redis support for message handling and SSE connections; implement dynamic routing for message and SSE endpoints; enhance error handling and logging; introduce new MCP tools for testing. --- app/message/route.ts | 79 ++- app/sse/route.ts | 189 ++++++- lib/mcp-api-handler-next.ts | 987 ++++++++++++++++++++++++++---------- lib/mcp-handler.ts | 32 ++ package.json | 1 + pnpm-lock.yaml | 3 + scripts/test-client.mjs | 2 +- scripts/test-mcp.mjs | 61 +++ 8 files changed, 1052 insertions(+), 302 deletions(-) create mode 100644 lib/mcp-handler.ts create mode 100644 scripts/test-mcp.mjs diff --git a/app/message/route.ts b/app/message/route.ts index c8c3ae9..cb4610c 100644 --- a/app/message/route.ts +++ b/app/message/route.ts @@ -1,11 +1,76 @@ import { NextRequest } from "next/server"; +import { createClient } from "redis"; -export async function POST(request: NextRequest) { - const body = await request.json(); - console.log("POST request received", body); - return new Response("POST Hello, world!"); -} +// Configure route to be dynamic +export const dynamic = 'force-dynamic'; -export async function GET(request: NextRequest) { - return new Response("GET Hello, world!"); +// Handler for POST requests to send messages to SSE clients +export async function POST(request: NextRequest) { + console.log("Message POST received"); + + try { + // Get the message data + const data = await request.json(); + console.log("Message data:", data); + + // Validate required fields + if (!data.sessionId) { + console.error("Missing sessionId in message request"); + return new Response(JSON.stringify({ error: "Missing sessionId" }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + } + + // Get Redis URL from environment + const redisUrl = process.env.REDIS_URL || process.env.KV_URL; + if (!redisUrl) { + console.error("No Redis URL available for message endpoint"); + return new Response(JSON.stringify({ error: "Server configuration error" }), { + status: 500, + headers: { "Content-Type": "application/json" } + }); + } + + // Create Redis client + console.log("Creating Redis client for message endpoint"); + const redisPublisher = createClient({ url: redisUrl }); + + // Connect to Redis + console.log("Connecting to Redis for message endpoint"); + await redisPublisher.connect(); + console.log("Redis connected for message endpoint"); + + // Publish message to the events channel for the session + const sessionId = data.sessionId; + console.log(`Publishing message to events:${sessionId}`); + + // Create the event message + const eventMessage = JSON.stringify({ + type: data.type || "message", + data: data.data || data, + timestamp: Date.now() + }); + + // Publish to Redis + await redisPublisher.publish(`events:${sessionId}`, eventMessage); + console.log(`Message published to events:${sessionId}`); + + // Disconnect from Redis + await redisPublisher.disconnect(); + console.log("Redis disconnected after message publish"); + + // Return success response + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + + } catch (error) { + console.error("Error in message endpoint:", error); + return new Response(JSON.stringify({ error: String(error) }), { + status: 500, + headers: { "Content-Type": "application/json" } + }); + } } diff --git a/app/sse/route.ts b/app/sse/route.ts index 9cec62b..4f06336 100644 --- a/app/sse/route.ts +++ b/app/sse/route.ts @@ -1,32 +1,177 @@ import { NextRequest } from "next/server"; -import { z } from "zod"; -import { initializeMcpApiHandler, createNextJsAdapter } from "@/lib/mcp-api-handler-next"; +import { nextApiHandler } from "@/lib/mcp-handler"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createClient } from "redis"; +import crypto from "crypto"; -const mcpHandler = initializeMcpApiHandler( - (server) => { - // Add more tools, resources, and prompts here - server.tool("echo", { message: z.string() }, async ({ message }) => ({ - content: [{ type: "text", text: `Tool echo: ${message}` }], - })); - }, - { - capabilities: { - tools: { - echo: { - description: "Echo a message", - }, - }, - }, - } -); - -// Create a Next.js-compatible handler using our adapter -const nextApiHandler = createNextJsAdapter(mcpHandler); +// Configure route to handle streaming responses +export const dynamic = 'force-dynamic'; +export const fetchCache = 'force-no-store'; export async function POST(request: NextRequest) { + console.log("POST request received"); return nextApiHandler(request); } export async function GET(request: NextRequest) { + console.log("GET SSE request received"); + + // If this is an SSE request, handle it with direct streaming + if (request.headers.get("accept") === "text/event-stream") { + return createSseStream(request); + } + + // Otherwise use the regular handler return nextApiHandler(request); } + +async function createSseStream(request: NextRequest) { + console.log("Creating direct SSE stream"); + + const encoder = new TextEncoder(); + const sessionId = crypto.randomUUID(); + console.log(`Created session ID for direct SSE stream: ${sessionId}`); + + // Get Redis URL from environment + const redisUrl = process.env.REDIS_URL || process.env.KV_URL; + if (!redisUrl) { + console.error("No Redis URL available for SSE stream"); + return new Response("Configuration error: No Redis connection available", { status: 500 }); + } + + // Create Redis clients + let redisSubscriber; + let redisPublisher; + + try { + console.log("Creating Redis clients for SSE stream"); + redisSubscriber = createClient({ url: redisUrl }); + redisPublisher = createClient({ url: redisUrl }); + + // Connect to Redis + console.log("Connecting to Redis for SSE stream"); + await Promise.all([ + redisSubscriber.connect(), + redisPublisher.connect() + ]); + console.log("Redis connected for SSE stream"); + } catch (error) { + console.error("Failed to connect to Redis for SSE:", error); + return new Response("Failed to establish server connection", { status: 500 }); + } + + // Create a stream with a custom controller we can write to + const stream = new ReadableStream({ + start(controller) { + console.log("SSE stream started"); + + // Function to send SSE formatted messages + const sendMessage = (data: string) => { + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + }; + + // Send initial connection message with session ID + sendMessage(JSON.stringify({ + type: "connection", + status: "established", + sessionId + })); + + // Set up Redis subscriber + const setupRedisSubscription = async () => { + try { + console.log(`Subscribing to Redis channel events:${sessionId}`); + + // Subscribe to events channel for this session + await redisSubscriber.subscribe(`events:${sessionId}`, (message) => { + console.log(`Received event for session ${sessionId}:`, message); + sendMessage(message); + }); + + // Publish the connection event + console.log(`Publishing connection event for session ${sessionId}`); + await redisPublisher.publish( + `session:${sessionId}`, + JSON.stringify({ + type: "client_connected", + sessionId, + timestamp: Date.now() + }) + ); + + console.log(`Redis subscription established for session ${sessionId}`); + } catch (error) { + console.error(`Redis subscription error for session ${sessionId}:`, error); + sendMessage(JSON.stringify({ + type: "error", + message: "Failed to establish subscription", + error: String(error) + })); + } + }; + + // Set up the subscription + setupRedisSubscription(); + + // Set up keep-alive interval + const keepAlive = setInterval(() => { + sendMessage(JSON.stringify({ type: "ping", timestamp: Date.now() })); + }, 30000); + + // Store references in request object for cleanup + (request as any).sseCleanup = async () => { + console.log(`Cleaning up SSE resources for session ${sessionId}`); + clearInterval(keepAlive); + + try { + // Unsubscribe and publish disconnection event + console.log(`Unsubscribing from events:${sessionId}`); + await redisSubscriber.unsubscribe(`events:${sessionId}`); + + console.log(`Publishing disconnection event for session ${sessionId}`); + await redisPublisher.publish( + `session:${sessionId}`, + JSON.stringify({ + type: "client_disconnected", + sessionId, + timestamp: Date.now() + }) + ); + + // Disconnect Redis clients + await Promise.all([ + redisSubscriber.disconnect(), + redisPublisher.disconnect() + ]); + console.log(`Redis clients disconnected for session ${sessionId}`); + } catch (cleanupError) { + console.error(`Error during SSE cleanup for session ${sessionId}:`, cleanupError); + } + }; + + // Handle stream closing + request.signal.addEventListener("abort", () => { + console.log(`SSE request aborted for session ${sessionId}`); + (request as any).sseCleanup(); + }); + }, + async cancel() { + console.log(`SSE stream cancelled for session ${sessionId}`); + if ((request as any).sseCleanup) { + await (request as any).sseCleanup(); + } + } + }); + + console.log("SSE stream created"); + + // Return a Response with the appropriate headers for SSE + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + }); +} diff --git a/lib/mcp-api-handler-next.ts b/lib/mcp-api-handler-next.ts index bbf35fe..811eb92 100644 --- a/lib/mcp-api-handler-next.ts +++ b/lib/mcp-api-handler-next.ts @@ -1,3 +1,10 @@ +/** + * MCP API Handler for Next.js + * + * This file provides a server implementation for the Model Context Protocol (MCP) + * using Redis for communication between serverless functions. + */ + import getRawBody from "raw-body"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; @@ -8,6 +15,11 @@ import { Readable } from "stream"; import { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js"; import vercelJson from "../vercel.json"; +// =================== INTERFACES AND TYPES =================== + +/** + * Represents a serialized HTTP request that can be passed through Redis + */ interface SerializedRequest { requestId: string; url: string; @@ -16,191 +28,456 @@ interface SerializedRequest { headers: IncomingHttpHeaders; } +/** + * Represents a serialized HTTP response that can be passed through Redis + */ +interface SerializedResponse { + status: number; + body: string; +} + +/** + * Options for creating a fake IncomingMessage + */ +interface FakeIncomingMessageOptions { + method?: string; + url?: string; + headers?: IncomingHttpHeaders; + body?: string | Buffer | Record | null; + socket?: Socket; +} + +/** + * Configuration for Redis connection + */ +interface RedisConfig { + redisUrl: string; + client: any; // Using any to avoid complex Redis typing issues + publisher: any; // Using any to avoid complex Redis typing issues +} + +/** + * Logger with contextual information + */ +interface ContextualLogger { + log: (...messages: string[]) => void; + error: (...messages: string[]) => void; + clearInterval: () => void; +} + +// =================== UTILITY FUNCTIONS =================== + +/** + * Creates a fake IncomingMessage for testing or simulation purposes + */ +function createFakeIncomingMessage( + options: FakeIncomingMessageOptions = {} +): IncomingMessage { + console.log("Creating fake IncomingMessage with options:", JSON.stringify(options)); + 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) { + console.log("Adding body to fake IncomingMessage:", typeof 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); + console.log("Created IncomingMessage instance"); + + // Set the properties + req.method = method; + req.url = url; + req.headers = headers; + + // Create wrapper methods that maintain the correct 'this' context and return type + req.push = function(chunk: any, encoding?: BufferEncoding) { + console.log("push called on fake IncomingMessage"); + return readable.push(chunk, encoding); + }; + + req.read = function(size?: number) { + console.log("read called on fake IncomingMessage"); + return readable.read(size); + }; + + req.on = function(event: string, listener: (...args: any[]) => void) { + console.log(`Event listener attached to fake IncomingMessage: ${event}`); + readable.on(event, listener); + return this; + }; + + req.pipe = function(destination: T, options?: { end?: boolean }): T { + console.log("pipe called on fake IncomingMessage"); + return readable.pipe(destination, options); + }; + + console.log("Fake IncomingMessage created successfully"); + return req; +} + +/** + * Creates a contextual logger that can be used across different scopes + */ +function createContextualLogger(): ContextualLogger { + console.log("Creating contextual logger"); + let logs: { type: "log" | "error"; messages: string[] }[] = []; + + const interval = setInterval(() => { + if (logs.length > 0) { + console.log(`Processing ${logs.length} buffered logs`); + for (const log of logs) { + console[log.type].call(console, ...log.messages); + } + logs = []; + } + }, 100); + + const logger: ContextualLogger = { + log: (...messages: string[]) => { + logs.push({ type: "log", messages }); + }, + error: (...messages: string[]) => { + logs.push({ type: "error", messages }); + }, + clearInterval: () => { + console.log("Clearing logger interval"); + clearInterval(interval); + } + }; + + console.log("Contextual logger created"); + return logger; +} + +/** + * Initializes Redis clients for pub/sub communication + */ +async function setupRedisClients(redisUrl: string): Promise { + console.log("Setting up Redis clients with URL:", redisUrl); + if (!redisUrl) { + console.error("Redis URL is not provided"); + throw new Error("Redis URL is not provided"); + } + + console.log("Creating Redis client instances"); + const client = createClient({ url: redisUrl }); + const publisher = createClient({ url: redisUrl }); + + client.on("error", (err: Error) => console.error("Redis client error:", err)); + publisher.on("error", (err: Error) => console.error("Redis publisher error:", err)); + + try { + console.log("Attempting to connect to Redis"); + await Promise.all([client.connect(), publisher.connect()]); + console.log("Redis connections established successfully"); + + return { redisUrl, client, publisher }; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("Failed to connect to Redis:", error); + throw new Error(`Redis connection failed: ${errorMessage}`); + } +} + +// =================== MAIN MCP API HANDLER =================== + +/** + * Initializes an MCP API handler for Next.js + * + * @param initializeServer Function to initialize the MCP server + * @param serverOptions Options for the MCP server + * @returns An API handler function for handling MCP requests + */ export function initializeMcpApiHandler( initializeServer: (server: McpServer) => void, serverOptions: ServerOptions = {} ) { - const maxDuration = + console.log("Initializing MCP API handler with options:", JSON.stringify(serverOptions)); + // Get max duration from vercel.json or default to 800 seconds + const maxDuration = vercelJson?.functions?.["api/server.ts"]?.maxDuration || 800; + console.log(`Max duration set to ${maxDuration} seconds`); const redisUrl = process.env.REDIS_URL || process.env.KV_URL; + console.log("Redis URL from environment:", redisUrl); if (!redisUrl) { + console.error("REDIS_URL environment variable is not set"); throw new Error("REDIS_URL environment variable is not set"); } - const redis = createClient({ - url: redisUrl, - }); - const redisPublisher = createClient({ - url: redisUrl, - }); - redis.on("error", (err) => { - console.error("Redis error", err); - }); - redisPublisher.on("error", (err) => { - console.error("Redis error", err); + + // Initialize Redis clients + let redisPromise: Promise; + let redisConfig: RedisConfig; + + // Initialize the promise but don't await it yet + console.log("Creating Redis setup promise"); + redisPromise = setupRedisClients(redisUrl).then(config => { + console.log("Redis setup completed"); + redisConfig = config; + return config; }); - - const redisPromise = Promise.all([redis.connect(), redisPublisher.connect()]); - - let servers: McpServer[] = []; - - return async function mcpApiHandler( - req: IncomingMessage, - res: ServerResponse + + // Keep track of active servers + let activeServers: McpServer[] = []; + console.log("Active servers array initialized"); + + /** + * Handles the SSE endpoint for long-lived connections + */ + async function handleSseConnection( + req: IncomingMessage, + res: ServerResponse, + redis: RedisConfig ) { - await redisPromise; - const url = new URL(req.url || "", "https://example.com"); + console.log("Got new SSE connection"); - if (url.pathname === "/sse") { - console.log("Got new SSE connection"); - - const transport = new SSEServerTransport("/message", res); - - const sessionId = transport.sessionId; + // Create SSE transport + console.log("Creating SSE transport"); + const transport = new SSEServerTransport("/message", res); + const sessionId = transport.sessionId; + console.log(`Created SSE transport with session ID: ${sessionId}`); + + // Create and initialize MCP server + console.log("Creating MCP server"); + const server = new McpServer( + { + name: "mcp-typescript server on vercel", + version: "0.1.0", + }, + serverOptions + ); + + console.log("Initializing server with provided function"); + initializeServer(server); + activeServers.push(server); + console.log(`Active servers count: ${activeServers.length}`); + + // Set up server close handler + server.server.onclose = () => { + console.log(`SSE connection closed for session: ${sessionId}`); + activeServers = activeServers.filter((s) => s !== server); + console.log(`Active servers count after removal: ${activeServers.length}`); + }; + + // Set up contextual logger + const logger = createContextualLogger(); + console.log("Contextual logger set up for SSE connection"); + + // Handler for messages from Redis + const handleMessage = async (message: string) => { + console.log(`Received message from Redis for session ${sessionId}:`, message); + logger.log(`Received message from Redis for session ${sessionId}:`, message); - 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); - + try { + console.log("Parsing received message"); const request = JSON.parse(message) as SerializedRequest; - - // Make in IncomingMessage object because that is what the SDK expects. + console.log(`Parsed request with ID: ${request.requestId}`); + + // Create synthetic request and response objects + console.log("Creating synthetic request and response objects"); 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 = ""; + + // Override response methods to capture status and body + console.log("Overriding response methods"); syntheticRes.writeHead = (statusCode: number) => { + console.log(`Setting status code to ${statusCode}`); status = statusCode; return syntheticRes; }; + syntheticRes.end = (b: unknown) => { + console.log(`Setting response body: ${typeof b === 'string' ? b : JSON.stringify(b)}`); body = b as string; return syntheticRes; }; + + // Process the message with the transport + console.log(`Processing message with transport for request ${request.requestId}`); await transport.handlePostMessage(req, syntheticRes); - - await redisPublisher.publish( + console.log(`Message processed for request ${request.requestId}`); + + // Publish response back to Redis + console.log(`Publishing response to Redis for ${sessionId}:${request.requestId}`); + await redis.publisher.publish( `responses:${sessionId}:${request.requestId}`, JSON.stringify({ status, body, }) ); - + console.log(`Response published to Redis for ${sessionId}:${request.requestId}`); + + // Log the result if (status >= 200 && status < 300) { - logInContext( - "log", + logger.log( `Request ${sessionId}:${request.requestId} succeeded: ${body}` ); } else { - logInContext( - "error", + logger.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); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Error processing message for session ${sessionId}:`, errorMessage); + logger.error(`Error processing message: ${errorMessage}`); + // Try to send an error response + try { + console.log(`Attempting to publish error response for ${sessionId}`); + await redis.publisher.publish( + `responses:${sessionId}:${JSON.parse(message).requestId}`, + JSON.stringify({ + status: 500, + body: `Internal server error: ${errorMessage}`, + }) + ); + console.log("Error response published successfully"); + } catch (publishError: unknown) { + const publishErrorMessage = publishError instanceof Error ? publishError.message : String(publishError); + console.error(`Failed to publish error response: ${publishErrorMessage}`); + logger.error(`Failed to publish error response: ${publishErrorMessage}`); } - logs = []; - }, 100); - - try { - console.log(`Attempting to subscribe to requests:${sessionId}`); - await redis.subscribe(`requests:${sessionId}`, handleMessage).then(() => { - console.log(`Successfully subscribed to requests:${sessionId}`); - }).catch((error) => { - console.error(`Failed to subscribe to requests:${sessionId}:`, error); - throw error; // Re-throw to handle it at a higher level if needed - }); - } catch (error) { - console.error(`Failed to subscribe to requests:${sessionId}:`, error); - // Check if redis is connected - console.log(`Redis connection status: ${redis.isOpen ? 'open' : 'closed'}`); - throw error; // Re-throw to handle it at a higher level if needed } - - 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(); + }; + + // Set up timeout for maximum duration + console.log(`Setting up timeout for ${maxDuration} seconds`); + let timeout: NodeJS.Timeout; + let resolveTimeout: (value: unknown) => void; + + const waitPromise = new Promise((resolve) => { + resolveTimeout = resolve; + timeout = setTimeout(() => { + console.log(`Max duration of ${maxDuration} seconds reached for session ${sessionId}`); + resolve("max duration reached"); + }, (maxDuration - 5) * 1000); + }); + + // Clean up function to be called when connection ends + async function cleanup() { + console.log(`Starting cleanup for session ${sessionId}`); + clearTimeout(timeout); + logger.clearInterval(); + try { + console.log(`Unsubscribing from Redis channel requests:${sessionId}`); + await redis.client.unsubscribe(`requests:${sessionId}`, handleMessage); + console.log("Unsubscribed from Redis channel"); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Failed to unsubscribe for session ${sessionId}: ${errorMessage}`); } - req.on("close", () => resolveTimeout("client hang up")); - + console.log(`Cleanup completed for session ${sessionId}`); + res.statusCode = 200; + res.end(); + } + + // Handle client disconnection + console.log("Setting up client disconnection handler"); + req.on("close", () => { + console.log(`Client disconnected for session ${sessionId}`); + resolveTimeout("client hang up"); + }); + + // Subscribe to Redis channel for this session + try { + console.log(`Attempting to subscribe to requests:${sessionId}`); + await redis.client.subscribe(`requests:${sessionId}`, handleMessage); + console.log(`Successfully subscribed to requests:${sessionId}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Failed to subscribe to requests:${sessionId}:`, error); + console.log(`Redis connection status: ${redis.client.isOpen ? 'open' : 'closed'}`); + res.statusCode = 500; + res.end(`Failed to subscribe to Redis: ${errorMessage}`); + return; + } + + // Connect the server to the transport + try { + console.log(`Attempting to connect server to transport for session: ${sessionId}`); await server.connect(transport); + console.log(`Server successfully connected to transport for session: ${sessionId}`); + + console.log(`Waiting for connection to close or timeout for session: ${sessionId}`); const closeReason = await waitPromise; - console.log(closeReason); + console.log(`Connection closing: ${closeReason} for session: ${sessionId}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Server connection error for session ${sessionId}: ${errorMessage}`); + console.error(`Connection details: transport=${transport.constructor.name}`); + } finally { + console.log(`Entering cleanup phase for session: ${sessionId}`); await cleanup(); - - } else if (url.pathname === "/message") { - console.log("Received message"); - + console.log(`Cleanup completed for session: ${sessionId}`); + } + } + + /** + * Handles the message endpoint for individual messages + */ + async function handleMessageEndpoint( + req: IncomingMessage, + res: ServerResponse, + redis: RedisConfig + ) { + console.log("Received message request"); + + try { + // Parse the request body + console.log("Parsing request body"); const body = await getRawBody(req, { length: req.headers["content-length"], encoding: "utf-8", }); - + console.log("Request body parsed successfully:", body); + + // Get the session ID from the URL + const url = new URL(req.url || "", "https://example.com"); const sessionId = url.searchParams.get("sessionId") || ""; + console.log(`Session ID from URL: ${sessionId}`); + if (!sessionId) { + console.log("No sessionId provided, returning 400"); res.statusCode = 400; res.end("No sessionId provided"); return; } + + // Create a unique request ID const requestId = crypto.randomUUID(); + console.log(`Generated request ID: ${requestId}`); + + // Serialize the request + console.log("Serializing request"); const serializedRequest: SerializedRequest = { requestId, url: req.url || "", @@ -208,177 +485,343 @@ export function initializeMcpApiHandler( 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); - + console.log("Request serialized:", JSON.stringify(serializedRequest)); + + // Set up timeout for response + console.log("Setting up response timeout"); let timeout = setTimeout(async () => { - await redis.unsubscribe(`responses:${sessionId}:${requestId}`); + console.log(`Request ${requestId} timed out after 10 seconds`); + try { + console.log(`Unsubscribing from responses:${sessionId}:${requestId} due to timeout`); + await redis.client.unsubscribe(`responses:${sessionId}:${requestId}`); + console.log("Unsubscribed successfully"); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Failed to unsubscribe on timeout: ${errorMessage}`); + } res.statusCode = 408; res.end("Request timed out"); }, 10 * 1000); - + + // Clean up on response close + console.log("Setting up response close handler"); res.on("close", async () => { + console.log(`Response closed for request ${requestId}`); clearTimeout(timeout); - await redis.unsubscribe(`responses:${sessionId}:${requestId}`); + try { + console.log(`Unsubscribing from responses:${sessionId}:${requestId} due to close`); + await redis.client.unsubscribe(`responses:${sessionId}:${requestId}`); + console.log("Unsubscribed successfully"); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Failed to unsubscribe on close: ${errorMessage}`); + } }); - } else { - res.statusCode = 404; - res.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)); + + // Subscribe to responses for this request + console.log(`Subscribing to responses:${sessionId}:${requestId}`); + await redis.client.subscribe( + `responses:${sessionId}:${requestId}`, + (message: string) => { + console.log(`Received response for request ${requestId}:`, message); + clearTimeout(timeout); + try { + console.log("Parsing response message"); + const response = JSON.parse(message) as SerializedResponse; + console.log(`Response status: ${response.status}, body: ${response.body}`); + res.statusCode = response.status; + res.end(response.body); + console.log("Response sent to client"); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Failed to parse response: ${errorMessage}`); + res.statusCode = 500; + res.end(`Failed to parse response: ${errorMessage}`); + } + } + ); + console.log(`Successfully subscribed to responses:${sessionId}:${requestId}`); + + // Publish the request to Redis + console.log(`Publishing request to requests:${sessionId}`); + await redis.publisher.publish( + `requests:${sessionId}`, + JSON.stringify(serializedRequest) + ); + + console.log(`Published request to requests:${sessionId}`, JSON.stringify(serializedRequest)); + + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Error handling message: ${errorMessage}`); + res.statusCode = 500; + res.end(`Internal server error: ${errorMessage}`); } - 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; - - // Create wrapper methods that maintain the correct 'this' context and return type - req.push = function(chunk: any, encoding?: BufferEncoding) { - return readable.push(chunk, encoding); - }; - - req.read = function(size?: number) { - return readable.read(size); - }; - req.on = function(event: string, listener: (...args: any[]) => void) { - readable.on(event, listener); - return this; - }; - - req.pipe = function(destination: T, options?: { end?: boolean }): T { - return readable.pipe(destination, options); + /** + * Main API handler function + */ + return async function mcpApiHandler( + req: IncomingMessage, + res: ServerResponse + ) { + console.log("MCP API handler called with URL:", req.url); + try { + // Ensure Redis is connected + if (!redisConfig) { + console.log("Redis not yet connected, waiting for connection"); + redisConfig = await redisPromise; + console.log("Redis connection established"); + } + + const url = new URL(req.url || "", "https://example.com"); + console.log("Parsed URL:", url.toString()); + + // Route the request based on the path + if (url.pathname === "/sse") { + console.log("Handling SSE connection"); + await handleSseConnection(req, res, redisConfig); + } else if (url.pathname === "/message") { + console.log("Handling message endpoint"); + await handleMessageEndpoint(req, res, redisConfig); + } else { + console.log(`Path not found: ${url.pathname}`); + res.statusCode = 404; + res.end("Not found"); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`API handler error: ${errorMessage}`); + res.statusCode = 500; + res.end(`Internal server error: ${errorMessage}`); + } }; - - return req; } -// Create a NextJS adapter for the MCP API handler -export function createNextJsAdapter(mcpApiHandler: ReturnType) { - return async function nextJsApiHandler(req: Request) { - // Convert the NextJS Request to an IncomingMessage - const url = new URL(req.url); - const headers: IncomingHttpHeaders = {}; - req.headers.forEach((value, key) => { - headers[key] = value; - }); - - let body = ''; - if (req.body) { - const clonedReq = req.clone(); - body = await clonedReq.text(); - } - - const incomingMessage = createFakeIncomingMessage({ - method: req.method, - url: url.pathname + url.search, - headers, - body, - }); +// =================== NEXTJS ADAPTER =================== - // Create a Promise that will be resolved with the response data - return new Promise(async (resolve) => { - const serverResponse = new ServerResponse(incomingMessage); +/** + * Creates a Next.js adapter for the MCP API handler + * Converts between Next.js Request/Response and Node.js IncomingMessage/ServerResponse + * + * @param mcpApiHandler The MCP API handler to adapt + * @returns A function that can be used as a Next.js API handler + */ +export function createNextJsAdapter(mcpApiHandler: ReturnType) { + console.log("Creating NextJS adapter for MCP API handler"); + return async function nextJsApiHandler(req: Request): Promise { + console.log("NextJS adapter called with request URL:", req.url); + try { + // Convert the NextJS Request to an IncomingMessage + const url = new URL(req.url); + console.log("Parsed URL in NextJS adapter:", url.toString()); + const headers: IncomingHttpHeaders = {}; + + console.log("Converting headers"); + req.headers.forEach((value, key) => { + console.log(`Header: ${key} = ${value}`); + headers[key] = value; + }); + + let body = ''; + if (req.body) { + console.log("Request has body, cloning and reading"); + const clonedReq = req.clone(); + body = await clonedReq.text(); + console.log("Request body:", body); + } else { + console.log("Request has no body"); + } - // Capture the response when it's ended - const originalEnd = serverResponse.end; - serverResponse.end = function(this: ServerResponse) { - // Get the response data - const statusCode = this.statusCode; - const headers: Record = {}; + console.log("Creating fake IncomingMessage"); + + const incomingMessage = createFakeIncomingMessage({ + method: req.method, + url: url.pathname + url.search, + headers, + body, + }); + + // Create a Promise that will be resolved with the response data + console.log("Creating response promise"); + + // Use a simpler approach with direct response handling + const responsePromise = new Promise((resolve, reject) => { + console.log("Inside promise executor"); + let isResolved = false; + + const serverResponse = new ServerResponse(incomingMessage); + console.log("Created ServerResponse"); - // Convert headers from ServerResponse to Response headers - const headerNames = this.getHeaderNames(); - for (const name of headerNames) { - const value = this.getHeader(name); - if (typeof value === 'string') { - headers[name] = value; - } else if (Array.isArray(value)) { - headers[name] = value.join(', '); + // Check if this is an SSE connection + const isSSE = url.pathname === "/sse" && + ((req.headers as any)["accept"] === "text/event-stream" || + incomingMessage.headers["accept"] === "text/event-stream"); + console.log(`Is this an SSE connection? ${isSSE}`); + + // Create a function to safely resolve the promise only once + const safeResolve = (response: Response) => { + console.log("safeResolve called with status:", response.status); + if (!isResolved) { + isResolved = true; + console.log("Actually resolving promise with status:", response.status); + resolve(response); + } else { + console.log("Promise already resolved, ignoring additional resolution attempt"); } - } + }; - // Create and resolve with the Response - const response = new Response(arguments[0], { - status: statusCode, - headers: headers, - }); + // Add response monitoring + const responseMonitor = setInterval(() => { + console.log("Response monitor check - writableEnded:", serverResponse.writableEnded); + console.log("Response statusCode:", serverResponse.statusCode); + }, 5000); - console.log("NextJS adapter: Resolving with Response", statusCode); - resolve(response); + // Capture the response when it's ended + console.log("Overriding ServerResponse methods"); - // Call the original end method - return originalEnd.apply(this, arguments as any); - }; - - // Process the request with the MCP API handler - await mcpApiHandler(incomingMessage, serverResponse); + // Override writeHead to track status changes + const originalWriteHead = serverResponse.writeHead; + serverResponse.writeHead = function(statusCode: number, headers?: any) { + console.log(`ServerResponse.writeHead called with status: ${statusCode}`); + + // Add essential SSE headers if this is an SSE connection with 200 status + if (isSSE && statusCode === 200) { + console.log("Adding SSE headers to response"); + this.setHeader('Content-Type', 'text/event-stream'); + this.setHeader('Cache-Control', 'no-cache'); + this.setHeader('Connection', 'keep-alive'); + this.setHeader('X-Accel-Buffering', 'no'); // Helps with Nginx proxy buffering + } + + return originalWriteHead.call(this, statusCode, headers); + }; + + // Override end to capture and convert the response + const originalEnd = serverResponse.end; + serverResponse.end = function( + this: ServerResponse, + chunk?: any, + encodingOrCallback?: BufferEncoding | (() => void), + callback?: () => void + ): ServerResponse { + console.log("ServerResponse.end called with:", + typeof chunk === 'undefined' ? 'undefined' : + typeof chunk === 'string' ? `string(${chunk.length})` : + typeof chunk === 'object' ? `object(${JSON.stringify(chunk).length})` : + typeof chunk); + + clearInterval(responseMonitor); + + // Get the response data + const statusCode = this.statusCode || 200; + const headers: Record = {}; + + // Convert headers from ServerResponse to Response headers + console.log("Converting response headers"); + const headerNames = this.getHeaderNames(); + for (const name of headerNames) { + const value = this.getHeader(name); + console.log(`Response header: ${name} = ${value}`); + if (typeof value === 'string') { + headers[name] = value; + } else if (Array.isArray(value)) { + headers[name] = value.join(', '); + } + } + + // Create and resolve with the Response + console.log(`Creating Response with status ${statusCode}`); + const response = new Response(chunk, { + status: statusCode, + headers: headers, + }); + + // Call the original end method first + // (in case there are side effects in the implementation) + const result = originalEnd.apply(this, arguments as any); + + // Then resolve the promise + console.log("Resolving promise with response"); + + // Only resolve for non-SSE connections or if SSE connection is closing + if (!isSSE || statusCode !== 200) { + safeResolve(response); + } else { + console.log("Not resolving promise for active SSE connection"); + } + + return result; + }; + + // Set up a timeout as a fallback + let timeout: NodeJS.Timeout | null = null; + + // Only set the timeout for non-SSE connections + if (!isSSE) { + console.log("Setting up timeout for non-SSE request"); + timeout = setTimeout(() => { + console.log("TIMEOUT: No response after 30 seconds"); + clearInterval(responseMonitor); + + // Check if we've already responded + if (!isResolved) { + console.log("Force creating a response due to timeout"); + if (serverResponse.statusCode) { + // We have a status code but end() wasn't called + console.log(`Using existing status code: ${serverResponse.statusCode}`); + safeResolve(new Response("Handler timed out, but status was set", { + status: serverResponse.statusCode + })); + } else { + // Complete fallback + console.log("Using fallback 504 status"); + safeResolve(new Response("Handler timed out without setting status", { + status: 504 + })); + } + } + }, 30000); + } else { + console.log("SSE connection detected - no timeout will be applied"); + } + + // Process the request with the MCP API handler + console.log("Calling MCP API handler"); + (async () => { + try { + await mcpApiHandler(incomingMessage, serverResponse); + console.log("MCP API handler completed"); + + // If we get here and response.end() was never called, resolve with 204 + // But don't do this for SSE connections + if (!isResolved && !isSSE) { + console.log("Handler completed but never called end(), resolving with 204"); + if (timeout) clearTimeout(timeout); + clearInterval(responseMonitor); + safeResolve(new Response(null, { status: 204 })); + } + } catch (error) { + console.error("Error in MCP API handler:", error); + if (timeout) clearTimeout(timeout); + clearInterval(responseMonitor); + if (!isResolved) { + safeResolve(new Response(`MCP handler error: ${error instanceof Error ? error.message : String(error)}`, { + status: 500 + })); + } + } + })(); + }); - // If response.end() was never called, resolve with a 204 response - if (!serverResponse.writableEnded) { - resolve(new Response(null, { status: 204 })); - } - }); + return responsePromise; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error("NextJS adapter error:", error); + return new Response(`Internal server error: ${errorMessage}`, { + status: 500 + }); + } }; } diff --git a/lib/mcp-handler.ts b/lib/mcp-handler.ts new file mode 100644 index 0000000..533e633 --- /dev/null +++ b/lib/mcp-handler.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { initializeMcpApiHandler, createNextJsAdapter } from "./mcp-api-handler-next"; + +// Create a single instance of the MCP handler that will be shared across route handlers +export const mcpHandler = initializeMcpApiHandler( + (server) => { + // Add tools, resources, and prompts here + server.tool("echo", { message: z.string() }, async ({ message }) => ({ + content: [{ type: "text", text: `Tool echo: ${message}` }], + })); + + // Add a simpler test tool + server.tool("test", {}, async () => ({ + content: [{ type: "text", text: "Test tool executed successfully" }], + })); + }, + { + capabilities: { + tools: { + echo: { + description: "Echo a message", + }, + test: { + description: "A simple test tool that takes no parameters", + }, + }, + } + } +); + +// Create a Next.js-compatible handler using our adapter +export const nextApiHandler = createNextJsAdapter(mcpHandler); \ No newline at end of file diff --git a/package.json b/package.json index f1efc00..31adb23 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.0", + "eventsource": "^3.0.5", "framer-motion": "^12.4.2", "lucide-react": "^0.475.0", "next": "15.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3afedd..613e8e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: cmdk: specifier: 1.0.0 version: 1.0.0(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + eventsource: + specifier: ^3.0.5 + version: 3.0.5 framer-motion: specifier: ^12.4.2 version: 12.4.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 4d6485b..40942b2 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -const origin = process.argv[2] || "https://mcp-on-vercel.vercel.app"; +const origin = "http://localhost:3000"; async function main() { const transport = new SSEClientTransport(new URL(`${origin}/sse`)); diff --git a/scripts/test-mcp.mjs b/scripts/test-mcp.mjs new file mode 100644 index 0000000..2a22f8e --- /dev/null +++ b/scripts/test-mcp.mjs @@ -0,0 +1,61 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +const origin = "http://localhost:3000"; + +async function main() { + console.log("Starting MCP test client..."); + try { + console.log("Creating SSE transport..."); + const transport = new SSEClientTransport( + new URL(`${origin}/sse`), + new URL(`${origin}/message`) + ); + console.log("SSE transport created"); + + console.log("Creating MCP client..."); + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + }, + } + ); + console.log("MCP client created"); + + console.log("Connecting to server..."); + await client.connect(transport); + console.log("Connected to server!"); + + // Get server capabilities + const capabilities = client.getServerCapabilities(); + console.log("Server capabilities:", JSON.stringify(capabilities, null, 2)); + + // List available tools + console.log("Listing tools..."); + const tools = await client.listTools(); + console.log("Available tools:", JSON.stringify(tools, null, 2)); + + // Test the echo tool + console.log("Testing echo tool..."); + const result = await client.callTool("echo", { message: "Hello, MCP!" }); + console.log("Echo result:", JSON.stringify(result, null, 2)); + + // Test the simple test tool + console.log("Testing simple test tool..."); + const testResult = await client.callTool("test", {}); + console.log("Test tool result:", JSON.stringify(testResult, null, 2)); + + console.log("Test completed successfully"); + } catch (error) { + console.error("Error in MCP test client:", error); + } +} + +main(); \ No newline at end of file