UNPKG

perplexity-mcp-server

Version:

A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with

211 lines (210 loc) • 9.59 kB
/** * @fileoverview Configures and starts the Streamable HTTP MCP transport using Hono. * This module integrates the `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport` * into a Hono web server. Its responsibilities include: * - Creating a Hono server instance. * - Applying and configuring middleware for CORS, rate limiting, and authentication (JWT/OAuth). * - Defining the routes (`/mcp` endpoint for POST, GET, DELETE) to handle the MCP lifecycle. * - Orchestrating session management by mapping session IDs to SDK transport instances. * - Implementing port-binding logic with automatic retry on conflicts. * * The underlying implementation of the MCP Streamable HTTP specification, including * Server-Sent Events (SSE) for streaming, is handled by the SDK's transport class. * * Specification Reference: * https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http * @module src/mcp-server/transports/httpTransport */ import { serve } from "@hono/node-server"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { Hono } from "hono"; import { cors } from "hono/cors"; import http from "http"; import { randomUUID } from "node:crypto"; import { config } from "../../config/index.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { logger, rateLimiter, requestContextService, } from "../../utils/index.js"; import { jwtAuthMiddleware, oauthMiddleware } from "./auth/index.js"; import { httpErrorHandler } from "./httpErrorHandler.js"; const HTTP_PORT = config.mcpHttpPort; const HTTP_HOST = config.mcpHttpHost; const MCP_ENDPOINT_PATH = "/mcp"; const MAX_PORT_RETRIES = 15; // The transports map will store active sessions, keyed by session ID. // NOTE: This is an in-memory session store, which is a known limitation for scalability. // It will not work in a multi-process (clustered) or serverless environment. // For a scalable deployment, this would need to be replaced with a distributed // store like Redis or Memcached. const transports = {}; async function isPortInUse(port, host, _parentContext) { return new Promise((resolve) => { const tempServer = http.createServer(); tempServer .once("error", (err) => { resolve(err.code === "EADDRINUSE"); }) .once("listening", () => { tempServer.close(() => resolve(false)); }) .listen(port, host); }); } function startHttpServerWithRetry(app, initialPort, host, maxRetries, parentContext) { const startContext = requestContextService.createRequestContext({ ...parentContext, operation: "startHttpServerWithRetry", }); return new Promise((resolve, reject) => { const tryBind = (port, attempt) => { if (attempt > maxRetries + 1) { reject(new Error("Failed to bind to any port after multiple retries.")); return; } const attemptContext = { ...startContext, port, attempt }; isPortInUse(port, host, attemptContext) .then((inUse) => { if (inUse) { logger.warning(`Port ${port} is in use, retrying...`, attemptContext); setTimeout(() => tryBind(port + 1, attempt + 1), 50); // Small delay return; } try { const serverInstance = serve({ fetch: app.fetch, port, hostname: host }, (info) => { const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`; logger.info(`HTTP transport listening at ${serverAddress}`, { ...attemptContext, address: serverAddress, }); if (process.stdout.isTTY) { console.log(`\nšŸš€ MCP Server running at: ${serverAddress}\n`); } }); resolve(serverInstance); } catch (err) { if (err && typeof err === "object" && "code" in err && err.code !== "EADDRINUSE") { reject(err); } else { setTimeout(() => tryBind(port + 1, attempt + 1), 50); } } }) .catch((err) => reject(err)); }; tryBind(initialPort, 1); }); } export async function startHttpTransport(createServerInstanceFn, parentContext) { const app = new Hono(); const transportContext = requestContextService.createRequestContext({ ...parentContext, component: "HttpTransportSetup", }); app.use("*", cors({ origin: config.mcpAllowedOrigins || [], allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], allowHeaders: [ "Content-Type", "Mcp-Session-Id", "Last-Event-ID", "Authorization", ], credentials: true, })); app.use("*", async (c, next) => { c.res.headers.set("X-Content-Type-Options", "nosniff"); await next(); }); app.use(MCP_ENDPOINT_PATH, async (c, next) => { // NOTE (Security): The 'x-forwarded-for' header is used for rate limiting. // This is only secure if the server is run behind a trusted proxy that // correctly sets or validates this header. const clientIp = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown_ip"; const context = requestContextService.createRequestContext({ operation: "httpRateLimitCheck", ipAddress: clientIp, }); // Let the centralized error handler catch rate limit errors rateLimiter.check(clientIp, context); await next(); }); if (config.mcpAuthMode === "oauth") { app.use(MCP_ENDPOINT_PATH, oauthMiddleware); } else { app.use(MCP_ENDPOINT_PATH, jwtAuthMiddleware); } // Centralized Error Handling app.onError(httpErrorHandler); app.post(MCP_ENDPOINT_PATH, async (c) => { const postContext = requestContextService.createRequestContext({ ...transportContext, operation: "handlePost", }); const body = await c.req.json(); const sessionId = c.req.header("mcp-session-id"); let transport = sessionId ? transports[sessionId] : undefined; if (isInitializeRequest(body)) { // If a transport already exists for a session, it's a re-initialization. if (transport) { logger.warning("Re-initializing existing session.", { ...postContext, sessionId, }); await transport.close(); // This will trigger the onclose handler. } // Create a new transport for a new session. const newTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newId) => { transports[newId] = newTransport; logger.info(`HTTP Session created: ${newId}`, { ...postContext, newSessionId: newId, }); }, }); // Set up cleanup logic for when the transport is closed. newTransport.onclose = () => { const closedSessionId = newTransport.sessionId; if (closedSessionId && transports[closedSessionId]) { delete transports[closedSessionId]; logger.info(`HTTP Session closed: ${closedSessionId}`, { ...postContext, closedSessionId, }); } }; // Connect the new transport to a new server instance. const server = await createServerInstanceFn(); await server.connect(newTransport); transport = newTransport; } else if (!transport) { // If it's not an initialization request and no transport was found, it's an error. throw new McpError(BaseErrorCode.NOT_FOUND, "Invalid or expired session ID."); } // Pass the request to the transport to handle. return await transport.handleRequest(c.env.incoming, c.env.outgoing, body); }); // A reusable handler for GET and DELETE requests which operate on existing sessions. const handleSessionRequest = async (c) => { const sessionId = c.req.header("mcp-session-id"); const transport = sessionId ? transports[sessionId] : undefined; if (!transport) { throw new McpError(BaseErrorCode.NOT_FOUND, "Session not found or expired."); } // Let the transport handle the streaming (GET) or termination (DELETE) request. return await transport.handleRequest(c.env.incoming, c.env.outgoing); }; app.get(MCP_ENDPOINT_PATH, handleSessionRequest); app.delete(MCP_ENDPOINT_PATH, handleSessionRequest); return startHttpServerWithRetry(app, HTTP_PORT, HTTP_HOST, MAX_PORT_RETRIES, transportContext); }