UNPKG

mcp-proxy

Version:

A TypeScript SSE proxy for MCP servers that use stdio transport.

369 lines (332 loc) 10.5 kB
#!/usr/bin/env node import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; import { EventSource } from "eventsource"; import { createRequire } from "node:module"; import { setTimeout } from "node:timers"; import util from "node:util"; import { pipenet } from "pipenet"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; const require = createRequire(import.meta.url); const packageJson = require("../../package.json") as { version: string }; import { proxyServer } from "../proxyServer.js"; import { SSEServer, startHTTPServer } from "../startHTTPServer.js"; import { StdioClientTransport } from "../StdioClientTransport.js"; util.inspect.defaultOptions.depth = 8; if (!("EventSource" in global)) { // @ts-expect-error - figure out how to use --experimental-eventsource with vitest global.EventSource = EventSource; } const argv = await yargs(hideBin(process.argv)) .scriptName("mcp-proxy") .version(packageJson.version) .command("$0 [command] [args...]", "Proxy an MCP stdio server over HTTP") .positional("command", { describe: "The command to run", type: "string", }) .positional("args", { array: true, describe: "The arguments to pass to the command", type: "string", }) .usage("$0 [options] -- <command> [args...]\n $0 <command> [args...]") .env("MCP_PROXY") .parserConfiguration({ "populate--": true, }) .options({ apiKey: { describe: "API key for authenticating requests (uses X-API-Key header)", type: "string", }, connectionTimeout: { default: 60000, describe: "The timeout (in milliseconds) for initial connection to the MCP server (default: 60 seconds)", type: "number", }, corsAddAllowedHeader: { array: true, describe: "Add a header name to Access-Control-Allow-Headers (defaults preserved). Repeat to add multiple, e.g. `--corsAddAllowedHeader X-API-Key`.", type: "string", }, debug: { default: false, describe: "Enable debug logging", type: "boolean", }, endpoint: { describe: "The endpoint to listen on", type: "string", }, eventStore: { default: true, describe: "Enable the streamable HTTP transport's resumability event store, which lets clients replay missed messages after a reconnect. Use --no-eventStore to disable it entirely for request/response-only deployments that don't need this and would rather avoid the memory overhead", type: "boolean", }, eventStoreMaxEvents: { default: 1000, describe: "Maximum number of buffered events the resumability event store retains (per session) before it evicts the oldest; bounds memory use. Ignored when --no-eventStore is set", type: "number", }, gracefulShutdownTimeout: { default: 5000, describe: "The timeout (in milliseconds) for graceful shutdown", type: "number", }, host: { default: "::", describe: "The host to listen on", type: "string", }, keepAliveTimeout: { default: 300000, describe: "The HTTP keep-alive timeout in milliseconds for stateful stream sessions (default: 5 minutes)", type: "number", }, maxBodySize: { default: 10485760, describe: "Maximum request body size in bytes accepted by the stream endpoint; larger requests are answered with 413 Payload Too Large. Bounds the memory a single request can consume (default: 10 MiB). Set to 0 to disable the limit", type: "number", }, port: { default: 8080, describe: "The port to listen on", type: "number", }, requestTimeout: { default: 300000, describe: "The timeout (in milliseconds) for requests to the MCP server (default: 5 minutes)", type: "number", }, server: { choices: ["sse", "stream"], describe: "The server type to use (sse or stream). By default, both are enabled", type: "string", }, shell: { default: false, describe: "Spawn the server via the user's shell", type: "boolean", }, sseEndpoint: { default: "/sse", describe: "The SSE endpoint to listen on", type: "string", }, sslCa: { describe: "Filename to override the trusted CA certificates", type: "string", }, sslCert: { describe: "Cert chains filename in PEM format", type: "string", }, sslKey: { describe: "Private keys filename in PEM format", type: "string", }, stateless: { default: false, describe: "Enable stateless mode for HTTP streamable transport (no session management)", type: "boolean", }, streamEndpoint: { default: "/mcp", describe: "The stream endpoint to listen on", type: "string", }, tunnel: { default: false, describe: "Expose the proxy via a public tunnel using tunnel.gla.ma", type: "boolean", }, tunnelSubdomain: { describe: "Request a specific subdomain for the tunnel (availability not guaranteed)", type: "string", }, }) .help() .parseAsync(); if (!(argv.eventStoreMaxEvents >= 1)) { console.error( `Error: --eventStoreMaxEvents must be a number >= 1 (got ${String(argv.eventStoreMaxEvents)}). Use --no-eventStore to disable the event store instead.`, ); process.exit(1); } if (!(argv.maxBodySize >= 0)) { console.error( `Error: --maxBodySize must be a number >= 0 (got ${String(argv.maxBodySize)}). Use --maxBodySize 0 to disable the limit instead.`, ); process.exit(1); } // Default Access-Control-Allow-Headers list — must stay in sync with // `defaultCorsOptions.allowedHeaders` in src/startHTTPServer.ts. const DEFAULT_ALLOWED_HEADERS = [ "Content-Type", "Authorization", "Accept", "Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-Id", ]; const corsOption = argv.corsAddAllowedHeader && argv.corsAddAllowedHeader.length > 0 ? { allowedHeaders: [...DEFAULT_ALLOWED_HEADERS, ...argv.corsAddAllowedHeader] } : undefined; // If -- separator was used, everything after -- is the command and its args const dashDashArgs = argv["--"] as string[] | undefined; let finalCommand: string; let finalArgs: string[]; if (dashDashArgs && dashDashArgs.length > 0) { // -- was used: first item after -- is command, rest are args [finalCommand, ...finalArgs] = dashDashArgs; } else if (argv.command) { // No -- used: use positional command and args finalCommand = argv.command as string; finalArgs = (argv.args as string[]) || []; } else { console.error("Error: No command specified."); console.error("Usage: mcp-proxy [options] -- <command> [args...]"); console.error(" or: mcp-proxy <command> [args...]"); console.error(""); console.error("Examples:"); console.error(" mcp-proxy --port 8080 -- node server.js --port 3000"); console.error(" mcp-proxy node server.js"); process.exit(1); } const connect = async (client: Client, connectionTimeout: number) => { const transport = new StdioClientTransport({ args: finalArgs, command: finalCommand, env: process.env as Record<string, string>, onEvent: (event) => { if (argv.debug) { console.debug("transport event", event); } }, shell: argv.shell, // We want to passthrough stderr from the MCP server to enable better debugging stderr: "inherit", }); await client.connect(transport, { timeout: connectionTimeout }); }; const proxy = async () => { const client = new Client( { name: "mcp-proxy", version: "1.0.0", }, { capabilities: {}, }, ); await connect(client, argv.connectionTimeout); const serverVersion = client.getServerVersion() as { name: string; version: string; }; const serverCapabilities = client.getServerCapabilities() as ServerCapabilities; console.info("starting server on port %d", argv.port); const createServer = async () => { const server = new Server(serverVersion, { capabilities: serverCapabilities, }); proxyServer({ client, requestTimeout: argv.requestTimeout, server, serverCapabilities, }); return server; }; const server = await startHTTPServer({ apiKey: argv.apiKey, cors: corsOption, createServer, eventStore: argv.eventStore ? undefined : false, eventStoreMaxEvents: argv.eventStoreMaxEvents, host: argv.host, keepAliveTimeout: argv.keepAliveTimeout, maxBodySize: argv.maxBodySize === 0 ? false : argv.maxBodySize, port: argv.port, sseEndpoint: argv.server && argv.server !== "sse" ? null : (argv.sseEndpoint ?? argv.endpoint), sslCa: argv.sslCa, sslCert: argv.sslCert, sslKey: argv.sslKey, stateless: argv.stateless, streamEndpoint: argv.server && argv.server !== "stream" ? null : (argv.streamEndpoint ?? argv.endpoint), }); let tunnel: Awaited<ReturnType<typeof pipenet>> | undefined; if (argv.tunnel) { console.info("establishing tunnel via tunnel.gla.ma"); tunnel = await pipenet({ host: "https://tunnel.gla.ma", port: argv.port, subdomain: argv.tunnelSubdomain, }); console.info("tunnel established at %s", tunnel.url); } return { close: async () => { await server.close(); if (tunnel) { await tunnel.close(); } }, }; }; const createGracefulShutdown = ({ server, timeout, }: { server: SSEServer; timeout: number; }) => { const gracefulShutdown = () => { console.info("received shutdown signal; shutting down"); server.close(); setTimeout(() => { // Exit with non-zero code to indicate failure to shutdown gracefully process.exit(1); }, timeout).unref(); }; process.once("SIGTERM", gracefulShutdown); process.once("SIGINT", gracefulShutdown); return () => { server.close(); }; }; const main = async () => { try { const server = await proxy(); createGracefulShutdown({ server, timeout: argv.gracefulShutdownTimeout, }); } catch (error) { console.error("could not start the proxy", error); // We give an extra second for logs to flush setTimeout(() => { process.exit(1); }, 1000); } }; await main();