Skip to content

Windows: painfully slow MCP (and CLI) — every process/identity op shells out to PowerShell/tasklist/reg/whoami #350

Description

@laulpogan

Reporter: Willard (Windows, raven-kettle). MCP usage on Windows is painfully slow.

Root cause

On Windows, every process/identity primitive in src/platform.rs + src/session.rs spawns a subprocess, and each cold-start is ~0.5–2s (PowerShell especially). The Unix equivalents are a /proc read or kill -0 (microseconds). There is no Win32 API dependency — it's all shell-outs:

fn Windows impl hot path
session::parent_pid powershell.exe Get-CimInstance Win32_Process per hop session resolution at MCP/CLI startup
platform::process_alive (pid_is_alive) tasklist.exe /FI per-pid liveness in daemon_liveness
platform::find_processes_by_cmdline powershell.exe Get-CimInstance (enumerates ALL procs) daemon_livenesswire_status, doctor
platform::pid_cmdline powershell.exe Get-CimInstance status/supervisor
platform::machine_id_raw reg.exe query identity enroll
platform::os_user_id_bytes whoami.exe identity enroll

Two dominant costs

  1. MCP startup pid-walk. resolve_session_keyclaude_code_session_from_pidfile walks the parent-PID chain (≤16 hops), and on Windows each hop is a PowerShell Get-CimInstance cold-start. The code comment notes the MCP server doesn't inherit CLAUDE_CODE_SESSION_ID on some platforms and Windows Claude Code passes the literal ${CLAUDE_CODE_SESSION_ID} (rejected by valid_session_key), so the env checks miss → the PowerShell walk always runs at startup. If the ~/.claude/sessions/<pid>.json pidfile isn't found quickly, it walks the full 16 hops = up to ~16–25s. Felt on every MCP (re)connect.
  2. wire_status / any liveness-touching callensure_up::daemon_liveness → one PowerShell CIM (find_processes_by_cmdline) plus a tasklist.exe per session daemon pid (pid_is_alive × N sessions). Multi-second per call on a multi-session box.

Immediate workaround (no code change)

Set a static session key in the wire mcp server's env block in the Windows Claude Code MCP config — this makes resolve_session_key hit the cheap env path and skip the PowerShell parent-pid walk entirely:

"wire": { "command": "wire", "args": ["mcp"], "env": { "WIRE_SESSION_ID": "willard-win" } }

(Any stable non-empty literal. Trade-off: pins this host to one wire identity instead of per-session — fine for a single user.) This kills the startup tax; the wire_status liveness cost is separate.

Proper fix

Replace the Windows subprocess shell-outs with native Win32 calls (no spawn) via the windows crate under [target.'cfg(windows)'.dependencies]:

  • parent_pid + find_processes_by_cmdline: one CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) + Process32FirstW/NextW (gives ppid + exe name), walk in-memory — zero spawns.
  • process_alive: OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess (STILL_ACTIVE).
  • machine_id_raw: RegOpenKeyEx/RegQueryValueEx for MachineGuid instead of reg.exe.
  • os_user_id_bytes: GetTokenInformation(TokenUser) instead of whoami.exe.
  • pid_cmdline (rare): keep PowerShell or PEB-read via NtQueryInformationProcess.

Lighter interim if avoiding the dep: collapse the per-hop parent_pid PowerShell into a single Get-CimInstance call returning all (ProcessId, ParentProcessId) pairs, walk in-memory (16 spawns → 1); and short-TTL-cache daemon_liveness.

Note

Diagnosed from macOS — cannot compile/run Windows code here (no windows target). Needs a Windows build + verify (Willard). A WIRE_DIAG=1 trace on Willard's box would confirm startup-vs-per-call split.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions