Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 28 additions & 20 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
});

Expand Down
89 changes: 89 additions & 0 deletions server/test/stdio-sse-close.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolveWait) => setTimeout(resolveWait, ms));

async function getAvailablePort(): Promise<number> {
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();
}
});