mcp-proxy
Version:
A TypeScript SSE proxy for MCP servers that use stdio transport.
369 lines (332 loc) • 10.5 kB
text/typescript
#!/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();