UNPKG

@cyanheads/jinaai-mcp-server

Version:

A Model Context Protocol (MCP) server that provides intelligent web reading capabilities using the Jina AI Reader API. It extracts clean, LLM-ready content from any URL.

287 lines • 12.5 kB
/** * @fileoverview Configures and starts the HTTP MCP transport using Hono. * This file has been refactored to correctly integrate Hono's streaming * capabilities with the Model Context Protocol SDK's transport layer. * @module src/mcp-server/transports/http/httpTransport */ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { stream } from "hono/streaming"; import http from "http"; import { config } from "../../../config/index.js"; import { logger, rateLimiter, requestContextService, } from "../../../utils/index.js"; import { createAuthMiddleware, createAuthStrategy } from "../auth/index.js"; import { StatelessTransportManager } from "../core/statelessTransportManager.js"; import { StatefulTransportManager } from "./../core/statefulTransportManager.js"; import { httpErrorHandler } from "./httpErrorHandler.js"; import { mcpTransportMiddleware } from "./mcpTransportMiddleware.js"; const HTTP_PORT = config.mcpHttpPort; const HTTP_HOST = config.mcpHttpHost; const MCP_ENDPOINT_PATH = config.mcpHttpEndpointPath; /** * Extracts the client IP address from the request, prioritizing common proxy headers. * @param c - The Hono context object. * @returns The client's IP address or a default string if not found. */ function getClientIp(c) { const forwardedFor = c.req.header("x-forwarded-for"); return ((forwardedFor?.split(",")[0] ?? "").trim() || c.req.header("x-real-ip") || "unknown_ip"); } /** * Converts a Fetch API Headers object to Node.js IncomingHttpHeaders. * Hono uses Fetch API Headers, but the underlying transport managers expect * Node's native IncomingHttpHeaders. * @param headers - The Headers object to convert. * @returns An object compatible with IncomingHttpHeaders. */ async function isPortInUse(port, host, parentContext) { const context = { ...parentContext, operation: "isPortInUse", port, host }; logger.debug(`Checking if port ${port} is in use...`, context); return new Promise((resolve) => { const tempServer = http.createServer(); tempServer .once("error", (err) => { const inUse = err.code === "EADDRINUSE"; logger.debug(`Port check resulted in error: ${err.code}. Port in use: ${inUse}`, context); resolve(inUse); }) .once("listening", () => { logger.debug(`Successfully bound to port ${port} temporarily. Port is not in use.`, context); tempServer.close(() => resolve(false)); }) .listen(port, host); }); } function startHttpServerWithRetry(app, initialPort, host, maxRetries, parentContext) { const startContext = { ...parentContext, operation: "startHttpServerWithRetry", }; logger.info(`Attempting to start HTTP server on port ${initialPort} with ${maxRetries} retries.`, startContext); return new Promise((resolve, reject) => { const tryBind = (port, attempt) => { const attemptContext = { ...startContext, port, attempt }; if (attempt > maxRetries + 1) { const error = new Error(`Failed to bind to any port after ${maxRetries} retries.`); logger.fatal(error.message, attemptContext); return reject(error); } isPortInUse(port, host, attemptContext) .then((inUse) => { if (inUse) { logger.warning(`Port ${port} is in use, retrying on port ${port + 1}...`, attemptContext); setTimeout(() => tryBind(port + 1, attempt + 1), config.mcpHttpPortRetryDelayMs); 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, sessionMode: config.mcpSessionMode, }); if (process.stdout.isTTY) { console.log(`\nšŸš€ MCP Server running at: ${serverAddress}`); console.log(` Session Mode: ${config.mcpSessionMode}\n`); } }); resolve(serverInstance); } catch (err) { if (err && typeof err === "object" && "code" in err && err.code !== "EADDRINUSE") { const errorToLog = err instanceof Error ? err : new Error(String(err)); logger.error("An unexpected error occurred while starting the server.", errorToLog, attemptContext); return reject(err); } logger.warning(`Encountered EADDRINUSE race condition on port ${port}, retrying...`, attemptContext); setTimeout(() => tryBind(port + 1, attempt + 1), config.mcpHttpPortRetryDelayMs); } }) .catch((err) => { const error = err instanceof Error ? err : new Error(String(err)); logger.fatal("Failed to check if port is in use.", error, attemptContext); reject(err); }); }; tryBind(initialPort, 1); }); } function createTransportManager(createServerInstanceFn, sessionMode, context) { const opContext = { ...context, operation: "createTransportManager", sessionMode, }; logger.info(`Creating transport manager for session mode: ${sessionMode}`, opContext); const statefulOptions = { staleSessionTimeoutMs: config.mcpStatefulSessionStaleTimeoutMs, mcpHttpEndpointPath: config.mcpHttpEndpointPath, }; switch (sessionMode) { case "stateless": return new StatelessTransportManager(createServerInstanceFn); case "stateful": return new StatefulTransportManager(createServerInstanceFn, statefulOptions); case "auto": default: logger.info("Defaulting to 'auto' mode (stateful with stateless fallback).", opContext); return new StatefulTransportManager(createServerInstanceFn, statefulOptions); } } export function createHttpApp(transportManager, createServerInstanceFn, parentContext) { const app = new Hono(); const transportContext = { ...parentContext, component: "HttpTransportSetup", }; logger.info("Creating Hono HTTP application.", transportContext); 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.env.outgoing.setHeader("X-Content-Type-Options", "nosniff"); await next(); }); app.use(MCP_ENDPOINT_PATH, async (c, next) => { const clientIp = getClientIp(c); const context = requestContextService.createRequestContext({ operation: "httpRateLimitCheck", ipAddress: clientIp, }); try { rateLimiter.check(clientIp, context); logger.debug("Rate limit check passed.", context); } catch (error) { logger.warning("Rate limit check failed.", { ...context, error: error instanceof Error ? error.message : String(error), }); throw error; } await next(); }); const authStrategy = createAuthStrategy(); if (authStrategy) { logger.info("Authentication strategy found, enabling auth middleware.", transportContext); app.use(MCP_ENDPOINT_PATH, createAuthMiddleware(authStrategy)); } else { logger.info("No authentication strategy found, auth middleware disabled.", transportContext); } app.onError(httpErrorHandler); app.get("/healthz", (c) => { return c.json({ status: "ok", timestamp: new Date().toISOString(), }); }); app.get(MCP_ENDPOINT_PATH, async (c) => { const sessionId = c.req.header("mcp-session-id"); if (sessionId) { return c.text("GET requests to existing sessions are not supported.", 405); } // Since this is a stateless endpoint, we create a temporary instance // to report on the server's configuration. const serverInstance = await createServerInstanceFn(); return c.json({ status: "ok", server: { name: serverInstance.name, version: serverInstance.version, description: config.pkg?.description || "No description provided.", nodeVersion: process.version, environment: config.environment, capabilities: serverInstance.capabilities, }, sessionMode: config.mcpSessionMode, tools: serverInstance.getTools(), message: "Server is running. POST to this endpoint to execute a tool call.", }); }); app.post(MCP_ENDPOINT_PATH, mcpTransportMiddleware(transportManager, createServerInstanceFn), (c) => { const response = c.get("mcpResponse"); if (response.sessionId) { c.header("Mcp-Session-Id", response.sessionId); } response.headers.forEach((value, key) => { c.header(key, value); }); c.status(response.statusCode); if (response.type === "stream") { return stream(c, async (s) => { await s.pipe(response.stream); }); } else { const body = typeof response.body === "object" && response.body !== null ? response.body : { body: response.body }; return c.json(body); } }); app.delete(MCP_ENDPOINT_PATH, async (c) => { const sessionId = c.req.header("mcp-session-id"); const context = requestContextService.createRequestContext({ ...transportContext, operation: "handleDeleteRequest", sessionId, }); if (sessionId) { if (transportManager instanceof StatefulTransportManager) { const response = await transportManager.handleDeleteRequest(sessionId, context); if (response.type === "buffered") { const body = typeof response.body === "object" && response.body !== null ? response.body : { body: response.body }; return c.json(body, response.statusCode); } // Fallback for unexpected stream response on DELETE return c.body(null, response.statusCode); } else { return c.json({ error: "Method Not Allowed", message: "DELETE operations are not supported in this mode.", }, 405); } } else { return c.json({ status: "stateless_mode", message: "No sessions to delete in stateless mode", }); } }); logger.info("Hono application setup complete.", transportContext); return app; } export async function startHttpTransport(createServerInstanceFn, parentContext) { const transportContext = { ...parentContext, component: "HttpTransportStart", }; logger.info("Starting HTTP transport.", transportContext); const transportManager = createTransportManager(createServerInstanceFn, config.mcpSessionMode, transportContext); const app = createHttpApp(transportManager, createServerInstanceFn, transportContext); const server = await startHttpServerWithRetry(app, HTTP_PORT, HTTP_HOST, config.mcpHttpMaxPortRetries, transportContext); logger.info("HTTP transport started successfully.", transportContext); return { app, server, transportManager }; } //# sourceMappingURL=httpTransport.js.map