Original Reddit post

I spent a full debugging session getting Claude Desktop on Windows to talk to an MCP server running on a Raspberry Pi via Tailscale. Sharing this Claude report as it can be useful to others attempting similar setups. Six attempts, six different failure modes. Writing this up because I couldn’t find a single clear resource on why this is hard, and the real blocker is non-obvious. TL;DR : Claude Desktop is an MSIX Windows Store app. Its child processes cannot access ~/.ssh/ . SSH silently fails with no error. The fix is a 25-line Node.js proxy that bridges Claude Desktop’s stdio transport to an HTTP server on the Pi. What I was building A personal knowledge graph on a Pi 4. I wanted Claude Desktop on my Windows machine to query it via MCP while I’m in a conversation. Attempt 1 — url field in config Tried: json “mcpServers”: { “self-graph”: { “url”: “http://100.x.x.x:8090/mcp” } } } Result : “not valid configuration” — immediate rejection. Claude Desktop only supports command / args (stdio transport). The url field works in Claude Code CLI and the SDK but not in Claude Desktop’s config parser. Dead end. Attempt 2 — SSH stdio The obvious approach: use SSH as the subprocess so Claude Desktop can talk to the remote Python script through the tunnel. json { “command”: “ssh”, “args”: [“pi@your-pi.your-tailnet.ts.net”, “/usr/bin/python3 /home/pi/self-graph/mcp_server.py”] } Result : Connected, received initialize , crash ~350ms later. Two bugs hit simultaneously: Bug A — SSH host key not in Windows known_hosts . With no TTY, SSH can’t prompt “Are you sure?”. It fails silently. Fixed by running the SSH command once manually in PowerShell. Bug B — mcp Python SDK 1.27.1 breaking change. The list_tools handler was returning plain dict objects. SDK 1.27.1 requires typed Tool(…) objects. The crash happened exactly one network roundtrip after initialize — enough time for Claude Desktop to send tools/list and get back ‘dict’ object has no attribute ‘name’ . Fixed both. Still broken. Attempt 3 — After SDK fix: asyncio/anyio transport dies through SSH pipes Result : Now crashes 42ms after initialize . That’s exactly one Tailscale roundtrip — Claude Desktop receives the response and the Python process exits before notifications/initialized arrives. The mcp SDK uses anyio 's stdio_server() which wraps stdin in an async memory stream. Something in the interaction between Windows SSH’s pipe handling and anyio’s task group lifecycle causes the session to terminate after the first response. Works perfectly locally ( printf python3 ), fails every time through SSH. Fix : Replaced the entire SDK transport layer with a plain for line in sys.stdin loop. Raw JSON-RPC, no framework. Simple and reliable. Attempt 4 — Module-level file open crashes Python before it starts I added debug logging at module level: python dbg = open(os.path.expanduser(“~/self-graph/logs/debug.log”), “a”) Result : Python crashes in 2ms. EPIPE on Claude Desktop side. No output at all. os.path.expanduser(“~”) reads the HOME env variable. In SSH subprocess context (non-login, non-interactive shell), HOME wasn’t set. expanduser(“~”) returned the literal string ~/self-graph/… . open() raised FileNotFoundError at module load time, before main() was called, before any output. Fix : Use os.path.dirname(os.path.abspath(file)) instead. Always resolves to the script’s directory regardless of environment. Wrap everything in try/except — a log failure should never kill the server. Attempt 5 — The real blocker: Windows Store app subprocess isolation After all fixes, Python still never ran from Claude Desktop. I added a sentinel file at line 5 of the script (before any imports): python open(“/tmp/mcp_ssh_started”, “w”).write(os.environ.get(“HOME”, “?”)) Running the exact same SSH command from PowerShell: sentinel created instantly, server responds correctly. From Claude Desktop subprocess: sentinel never created. Zero debug log entries from any Claude Desktop session despite dozens of attempts. Root cause : Claude Desktop is distributed as an MSIX Windows Store app ( C:\Program Files\WindowsApps\Claude_1.x.x_x64_…
). MSIX apps run child processes in a restricted security context. The child ssh.exe process cannot access its SSH private key file ( C:\Users\USER.ssh\id_ed25519 or similar). With BatchMode=yes , SSH fails silently — no error to stderr, just exits. Claude Desktop never captures the failure. Key evidence : - C:\Windows\System32\OpenSSH\ssh.exe -o BatchMode=yes pi@host “python3 script.py” → works from PowerShell (same binary, same args) - Same command → never works from Claude Desktop subprocess There’s no way to fix this from the server side. You can’t make SSH find keys it can’t access. Solution — Node.js proxy + HTTP server Claude Desktop ←stdio→ mcp_proxy.js (node.exe, Windows) ↕ HTTP POST mcp_http_server.py (Pi, port 8090) node.exe is a regular user process, not sandboxed by the Store app context. It can make HTTP requests to the Pi over Tailscale without any SSH key issues. ** mcp_proxy.js ** (save on Windows, ~25 lines): ```javascript const http = require(‘http’); const readline = require(‘readline’); const rl = readline.createInterface({ input: process.stdin, terminal: false
); function post(body) { return new Promise((resolve, reject) => { const data = JSON.stringify(body); const req = http.request({ hostname: ‘100.x.x.x’, port: 8090, path: ‘/mcp’, method: ‘POST’, headers: { ‘Content-Type’: ‘application/json’, ‘Content-Length’: Buffer.byteLength(data) } }, res => { let out = ‘’; res.on(‘data’, c => out += c); res.on(‘end’, () => resolve(out.trim())); }); req.on(‘error’, reject); req.write(data); req.end(); }); } rl.on(‘line’, async line => { line = line.trim(); if (!line) return; try { const msg = JSON.parse(line); // Notifications are fire-and-forget — never send a response if (msg.method && msg.method.startsWith(‘notifications/’)) return; const resp = await post(msg); if (resp) process.stdout.write(resp + ‘\n’); } catch (_) {} }); ``` Claude Desktop config : json { “mcpServers”: { “self-graph”: { “command”: “node”, “args”: [“C:\Users\USER\mcp_proxy.js”] } } } One gotcha: do not send notification responses back to Claude Desktop . The HTTP server returns {“id”: null, “result”: {}} for notifications/initialized . Claude Desktop’s Zod validator rejects any message with id: null (expects string or number) and also rejects result as an unrecognized key in its request schema. Skip notifications entirely in the proxy. The 8 things I wish I’d known Claude Desktop (MSIX) can’t use SSH keys from child processes — skip SSH, use HTTP mcp SDK 1.27.1 requires Tool / TextContent typed objects — plain dicts crash silently then hard-fail anyio’s stdio_server fails through SSH pipes — raw for line in stdin is more robust ** os.path.expanduser(“~”) is unreliable in SSH subprocess context** — use file Module-level open() with no guard crashes Python before anything runs — always use try/except MCP notifications must not generate responses in a proxy — id: null fails Zod SSH stderr is not captured by Claude Desktop — use file-based logging for diagnosis Test with C:\Windows\System32\OpenSSH\ssh.exe specifically , not just ssh from terminal — they may use different key stores submitted by /u/pcx_wave

Originally posted by u/pcx_wave on r/ClaudeCode