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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions