Skip to content

session.disconnect() does not kill stdio MCP server processes spawned for that session #3440

@AlitzelMendez

Description

@AlitzelMendez

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

node repro-sdk-only.mjs

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions