From 9354e6ca6b1c4cde56c51a8b06ba913a4e87ce5f Mon Sep 17 00:00:00 2001 From: Akhil Trivedi <118957648+AkhilTrivediX@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:18:18 +0530 Subject: [PATCH] fix(server): keep stdio SSE proxy alive after disconnect --- package.json | 3 +- server/package.json | 3 +- server/src/index.ts | 48 +++++++++------- server/test/stdio-sse-close.test.ts | 89 +++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 server/test/stdio-sse-close.test.ts diff --git a/package.json b/package.json index 8330ebd8f..79240799d 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "start": "node client/bin/start.js", "start-server": "cd server && npm run start", "start-client": "cd client && npm run preview", - "test": "npm run prettier-check && cd client && npm test", + "test": "npm run prettier-check && npm run test-server && cd client && npm test", + "test-server": "npm run build-server && cd server && npm test", "test-cli": "cd cli && npm run test", "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", "prettier-fix": "prettier --write .", diff --git a/server/package.json b/server/package.json index 5b398d64c..8ae9989b0 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,8 @@ "build": "tsc && shx cp -R static build", "start": "node build/index.js", "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" + "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL", + "test": "tsx --test test/**/*.test.ts" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/server/src/index.ts b/server/src/index.ts index bdfe49019..3dcce2af7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -85,6 +85,10 @@ const sendErrorResponse = ( res.status(status).json({ error: message }); }; +const logClientTransportError = (error: unknown) => { + console.error("Error sending message to inspector client:", error); +}; + /** * Prefer forwarding the upstream MCP 401 (WWW-Authenticate + body) so the browser * matches direct-mode OAuth behavior. Falls back to a generic 401 JSON response @@ -672,17 +676,19 @@ app.get( if (chunk.toString().includes("MODULE_NOT_FOUND")) { // Server command not found, remove transports const message = "Command not found, transports removed"; - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level: "emergency", - logger: "proxy", - data: { - message, + void webAppTransport + .send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { + level: "emergency", + logger: "proxy", + data: { + message, + }, }, - }, - }); + }) + .catch(logClientTransportError); webAppTransport.close(); serverTransport.close(); webAppTransports.delete(webAppTransport.sessionId); @@ -722,17 +728,19 @@ app.get( } else { level = "info"; } - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/message", - params: { - level, - logger: "stdio", - data: { - message, + void webAppTransport + .send({ + jsonrpc: "2.0", + method: "notifications/message", + params: { + level, + logger: "stdio", + data: { + message, + }, }, - }, - }); + }) + .catch(logClientTransportError); } }); diff --git a/server/test/stdio-sse-close.test.ts b/server/test/stdio-sse-close.test.ts new file mode 100644 index 000000000..3e4c89961 --- /dev/null +++ b/server/test/stdio-sse-close.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createServer } from "node:net"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, "../.."); + +const wait = (ms: number) => + new Promise((resolveWait) => setTimeout(resolveWait, ms)); + +async function getAvailablePort(): Promise { + return new Promise((resolvePort, rejectPort) => { + const server = createServer(); + server.once("error", rejectPort); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (typeof address !== "object" || address === null) { + rejectPort(new Error("Could not allocate a local test port")); + return; + } + server.close((error) => { + if (error) rejectPort(error); + else resolvePort(address.port); + }); + }); + }); +} + +test("keeps the proxy alive when a closed SSE client receives stdio stderr", async () => { + const port = await getAvailablePort(); + const proxy = spawn(process.execPath, ["server/build/index.js"], { + cwd: rootDir, + env: { + ...process.env, + DANGEROUSLY_OMIT_AUTH: "true", + HOST: "127.0.0.1", + SERVER_PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + proxy.stdout?.on("data", (chunk) => { + output += chunk.toString(); + }); + proxy.stderr?.on("data", (chunk) => { + output += chunk.toString(); + }); + + try { + for ( + let attempt = 0; + attempt < 50 && !output.includes("Proxy server listening"); + attempt += 1 + ) { + await wait(50); + } + assert.match(output, /Proxy server listening/); + + const mcpServer = [ + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';", + "import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';", + "const server = new McpServer({ name: 'stderr-repro', version: '1.0.0' });", + "await server.connect(new StdioServerTransport());", + "setInterval(() => console.error('stderr after SSE close'), 20);", + ].join(" "); + const args = `--input-type=module -e ${JSON.stringify(mcpServer)}`; + const url = new URL(`http://127.0.0.1:${port}/stdio`); + url.searchParams.set("transportType", "stdio"); + url.searchParams.set("command", process.execPath); + url.searchParams.set("args", args); + + const controller = new AbortController(); + const request = fetch(url, { + headers: { Accept: "text/event-stream" }, + signal: controller.signal, + }); + await wait(250); + controller.abort(); + await request.catch(() => undefined); + await wait(500); + + assert.equal(proxy.exitCode, null, output); + } finally { + if (proxy.exitCode === null) proxy.kill(); + } +});