diff --git a/src/proxy.ts b/src/proxy.ts index 4b35e97..38ff8d0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,6 +19,8 @@ export interface RunProxyOptions { export interface ProxyHandle { close: () => Promise; + /** Set when inbound HTTP is enabled; actual bound port (config port 0 → OS-assigned). */ + httpPort?: number; } export async function runProxy(opts: RunProxyOptions): Promise { @@ -65,6 +67,7 @@ export async function runProxy(opts: RunProxyOptions): Promise { const handle = await startStdioServer(aggregator.createServer()); closers.push(handle.close); } + let httpPort: number | undefined; if (cfg.inbound.http.enabled) { const handle = await startHttpServer({ cfg, @@ -72,6 +75,7 @@ export async function runProxy(opts: RunProxyOptions): Promise { upstream, audit, }); + httpPort = handle.port; closers.push(handle.close); } @@ -85,6 +89,7 @@ export async function runProxy(opts: RunProxyOptions): Promise { ); return { + httpPort, close: async () => { for (const c of [...closers].reverse()) { try { diff --git a/src/server/http.ts b/src/server/http.ts index b18bbcc..37a5c4e 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -9,6 +9,8 @@ import { child as childLogger } from "../logger.js"; export interface InboundHttpHandle { close: () => Promise; + /** Actual bound port (useful when config port is 0). */ + port: number; } /** @@ -29,7 +31,9 @@ export async function startHttpServer(args: { }): Promise { const { cfg, createServer: createMcp, upstream } = args; const log = childLogger({ component: "inbound-http" }); - const { host, port, path: mcpPath, sessions } = cfg.inbound.http; + const { host, path: mcpPath, sessions } = cfg.inbound.http; + const requestedPort = cfg.inbound.http.port; + let boundPort = requestedPort; if (sessions !== "stateless") { log.warn( @@ -39,7 +43,7 @@ export async function startHttpServer(args: { } const handler = async (req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url ?? "/", `http://${host}:${port}`); + const url = new URL(req.url ?? "/", `http://${host}:${boundPort}`); if (url.pathname === "/healthz") { const upstreams = [...upstream.connections.values()].map((c) => ({ @@ -101,14 +105,19 @@ export async function startHttpServer(args: { await new Promise((resolve, reject) => { httpServer.once("error", reject); - httpServer.listen(port, host, () => { + httpServer.listen(requestedPort, host, () => { httpServer.off("error", reject); + const addr = httpServer.address(); + if (addr && typeof addr === "object") { + boundPort = addr.port; + } resolve(); }); }); - log.info({ host, port, path: mcpPath, sessions }, "inbound HTTP transport listening"); + log.info({ host, port: boundPort, path: mcpPath, sessions }, "inbound HTTP transport listening"); return { + port: boundPort, close: () => new Promise((resolve) => { httpServer.close(() => resolve()); diff --git a/tests/integration/proxy.test.ts b/tests/integration/proxy.test.ts index a079ffa..9fb4d97 100644 --- a/tests/integration/proxy.test.ts +++ b/tests/integration/proxy.test.ts @@ -32,18 +32,19 @@ async function startHarness(opts: { servers: Record>; filters?: { allow?: string[]; deny?: string[] }; shrink?: { mode?: "off" | "rules" | "llm" }; - port?: number; }): Promise { - const port = opts.port ?? randomPort(); const cfg = buildTestConfig({ servers: opts.servers, filters: opts.filters, shrink: opts.shrink, inboundHttp: true, }); - cfg.inbound.http.port = port; + // Port 0 → OS picks a free port. Random high ports flake on Windows CI (EACCES). + cfg.inbound.http.port = 0; cfg.inbound.http.host = "127.0.0.1"; const handle = await runProxy({ cfg, disableInboundStdio: true }); + const port = handle.httpPort; + if (!port) throw new Error("proxy HTTP port not available"); const url = `http://127.0.0.1:${port}/mcp`; const client = new Client({ name: "test-client", version: "0.0.1" }, { capabilities: {} }); @@ -55,11 +56,7 @@ async function startHarness(opts: { return harness; } -function randomPort(): number { - return 30_000 + Math.floor(Math.random() * 20_000); -} - -describe("end-to-end proxy", () => { +describe.sequential("end-to-end proxy", () => { it( "merges tool lists from two upstream stdio servers under namespaced names", async () => {