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
JavaScript
/**
* @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);
}