Connecting Claude Desktop (Windows) to a remote MCP server over Tailscale — what actually works and why SSH fails
[Original Reddit post](https://www.reddit.com/r/ClaudeCode/comments/1tgmfgy/connecting_claude_desktop_windows_to_a_remote_mcp/)
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": ["[email protected]", "/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
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": ["[email protected]", "/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