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_liveness → wire_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
- MCP startup pid-walk.
resolve_session_key → claude_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.
wire_status / any liveness-touching call → ensure_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
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.rsspawns a subprocess, and each cold-start is ~0.5–2s (PowerShell especially). The Unix equivalents are a/procread orkill -0(microseconds). There is no Win32 API dependency — it's all shell-outs:session::parent_pidpowershell.exe Get-CimInstance Win32_Processper hopplatform::process_alive(pid_is_alive)tasklist.exe /FIdaemon_livenessplatform::find_processes_by_cmdlinepowershell.exe Get-CimInstance(enumerates ALL procs)daemon_liveness→wire_status, doctorplatform::pid_cmdlinepowershell.exe Get-CimInstanceplatform::machine_id_rawreg.exe queryplatform::os_user_id_byteswhoami.exeTwo dominant costs
resolve_session_key→claude_code_session_from_pidfilewalks the parent-PID chain (≤16 hops), and on Windows each hop is a PowerShellGet-CimInstancecold-start. The code comment notes the MCP server doesn't inheritCLAUDE_CODE_SESSION_IDon some platforms and Windows Claude Code passes the literal${CLAUDE_CODE_SESSION_ID}(rejected byvalid_session_key), so the env checks miss → the PowerShell walk always runs at startup. If the~/.claude/sessions/<pid>.jsonpidfile isn't found quickly, it walks the full 16 hops = up to ~16–25s. Felt on every MCP (re)connect.wire_status/ any liveness-touching call →ensure_up::daemon_liveness→ one PowerShell CIM (find_processes_by_cmdline) plus atasklist.exeper 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 mcpserver'senvblock in the Windows Claude Code MCP config — this makesresolve_session_keyhit the cheap env path and skip the PowerShell parent-pid walk entirely:(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_statusliveness cost is separate.Proper fix
Replace the Windows subprocess shell-outs with native Win32 calls (no spawn) via the
windowscrate under[target.'cfg(windows)'.dependencies]:parent_pid+find_processes_by_cmdline: oneCreateToolhelp32Snapshot(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/RegQueryValueExforMachineGuidinstead ofreg.exe.os_user_id_bytes:GetTokenInformation(TokenUser)instead ofwhoami.exe.pid_cmdline(rare): keep PowerShell or PEB-read viaNtQueryInformationProcess.Lighter interim if avoiding the dep: collapse the per-hop
parent_pidPowerShell into a singleGet-CimInstancecall returning all(ProcessId, ParentProcessId)pairs, walk in-memory (16 spawns → 1); and short-TTL-cachedaemon_liveness.Note
Diagnosed from macOS — cannot compile/run Windows code here (no windows target). Needs a Windows build + verify (Willard). A
WIRE_DIAG=1trace on Willard's box would confirm startup-vs-per-call split.🤖 Generated with Claude Code