Describe the bug
When a CopilotSession is created with mcpServers containing one or more stdio entries, the Copilot CLI spawns a child process for each server. When session.disconnect() is called, those child processes are not killed. They only die when CopilotClient.stop() is eventually called.
In workloads that create many short-lived sessions sharing a single CopilotClient (e.g. eval/testing pipelines that run N prompts sequentially), this causes one orphaned MCP server process per session, leading to monotonically increasing memory consumption for the lifetime of the client.
Process hierarchy
node (host process)
└── Copilot CLI subprocess ← spawned by CopilotClient
└── node mcp-server.js ← spawned by CLI when session sends first prompt
└── node mcp-server.js ← spawned for next session, previous one still alive
└── ... ← accumulates until client.stop()
Affected version
GitHub Copilot CLI 1.0.49.
Steps to reproduce the behavior
The following two files are all that is needed.
mcp-server.js — a trivial stdio MCP server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "greeting-server", version: "0.1.0" });
server.registerTool(
"demo_get_greeting",
{
description: "Returns a greeting for the given name.",
inputSchema: { name: z.string() },
},
async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }),
);
const transport = new StdioServerTransport();
server.connect(transport);
repro-sdk-only.mjs — the repro script
import { CopilotClient, approveAll } from "@github/copilot-sdk";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dir = dirname(fileURLToPath(import.meta.url));
const MCP_SERVER = join(__dir, "mcp-server.js");
function snapshotProcesses(label) {
let lines = "";
try {
lines = execFileSync(
"powershell",
[
"-NoProfile",
"-Command",
"Get-CimInstance Win32_Process -EA SilentlyContinue" +
" | Where-Object { $_.Name -eq 'node.exe' -and $_.CommandLine -match 'mcp-server' }" +
" | ForEach-Object {" +
" $m = [math]::Round((Get-Process -Id $_.ProcessId -EA 0).WorkingSet64/1MB,1);" +
' Write-Output " PID=$($_.ProcessId) Mem=${m}MB Cmd=$($_.CommandLine)"' +
" }",
],
{ encoding: "utf8", timeout: 5_000 },
).trim();
} catch { /* no processes or powershell unavailable */ }
const count = lines ? lines.split("\n").length : 0;
const status = count > 0 ? `${count} process(es) still alive ← BUG` : "0 process(es) alive ✓";
console.log(`\n[${label}] ${status}`);
if (lines) console.log(lines);
}
const client = new CopilotClient();
// Step 1 — create session
console.log("[1] Creating session with stdio MCP server...");
const session = await client.createSession({
mcpServers: {
"greeting-server": {
type: "stdio",
command: "node",
args: [MCP_SERVER],
tools: ["*"],
},
},
onPermissionRequest: approveAll,
streaming: false,
workingDirectory: process.cwd(),
});
console.log(` Session created: ${session.sessionId}`);
snapshotProcesses("after createSession (before prompt)");
// Step 2 — send a prompt (this is when the CLI spawns the MCP process)
console.log("\n[2] Sending prompt...");
await session.sendAndWait(
{ prompt: "Use the demo_get_greeting tool to greet Alice.", mode: "immediate" },
60_000,
);
console.log(" sendAndWait returned.");
snapshotProcesses("after sendAndWait — MCP process spawned");
// Step 3 — disconnect: MCP process should die here, but doesn't
console.log("\n[3] Calling session.disconnect()...");
await session.disconnect();
console.log(" session.disconnect() returned.");
snapshotProcesses("after session.disconnect() — should be 0, is NOT ← BUG");
// Step 4 — stop the client: only now does the process die
console.log("\n[4] Calling client.stop()...");
await client.stop();
console.log(" client.stop() returned.");
snapshotProcesses("after client.stop() — finally clean");
Run it
Observed results
Starting minimal repro for session.disconnect() MCP leak...
MCP server: C:\Evals\evaluate\tests\evals\mcp-process-leak\mcp-server.js
[1] Creating session with stdio MCP server...
[CLI subprocess] (node:67688) ExperimentalWarning: SQLite is an experimental feature and might change at any time
[CLI subprocess] (Use `node --trace-warnings ...` to show where the warning was created)
Session created: c9939bf6-b45f-41c0-aad9-607aa6ca489f
[after createSession (before any prompt)] 0 process(es) alive ✓
[2] Sending prompt (triggers MCP server spawn)...
sendAndWait returned.
[after sendAndWait — MCP process now alive] 1 process(es) still alive ← BUG
PID=51096 Mem=78.4MB Cmd=node C:\Evals\evaluate\tests\evals\mcp-process-leak\mcp-server.js
[3] Calling session.disconnect()...
session.disconnect() returned.
[after session.disconnect()] 1 process(es) still alive ← BUG
PID=51096 Mem=67MB Cmd=node C:\Evals\evaluate\tests\evals\mcp-process-leak\mcp-server.js
[4] Calling client.stop()...
client.stop() returned.
[after client.stop() — processes finally gone] 0 process(es) alive ✓
Key observation: the same PID survives session.disconnect() and only disappears after client.stop(). The process is not a timing artifact — it persists indefinitely until the client is stopped.
Note: sendAndWait fully resolves (the Promise settles and "sendAndWait returned." is printed) before session.disconnect() is ever called. The leak is not caused by a pending or unresolved sendAndWait — the process survives even after all session work is entirely complete.
Expected behavior
session.disconnect() should kill any stdio MCP server processes that were spawned for that session. Callers should not need to know whether a session used MCP servers in order to ensure clean process teardown.
Additional context
@github/copilot-sdk: latest
- OS: Windows 11
- Node.js: v22.22.2
Describe the bug
When a
CopilotSessionis created withmcpServerscontaining one or morestdioentries, the Copilot CLI spawns a child process for each server. Whensession.disconnect()is called, those child processes are not killed. They only die whenCopilotClient.stop()is eventually called.In workloads that create many short-lived sessions sharing a single
CopilotClient(e.g. eval/testing pipelines that run N prompts sequentially), this causes one orphaned MCP server process per session, leading to monotonically increasing memory consumption for the lifetime of the client.Process hierarchy
Affected version
GitHub Copilot CLI 1.0.49.
Steps to reproduce the behavior
The following two files are all that is needed.
mcp-server.js— a trivial stdio MCP serverrepro-sdk-only.mjs— the repro scriptRun it
Observed results
Key observation: the same PID survives
session.disconnect()and only disappears afterclient.stop(). The process is not a timing artifact — it persists indefinitely until the client is stopped.Expected behavior
session.disconnect()should kill any stdio MCP server processes that were spawned for that session. Callers should not need to know whether a session used MCP servers in order to ensure clean process teardown.Additional context
@github/copilot-sdk: latest