Summary
When server.py is started with --tailscale, it binds only to the node's Tailscale IP and drops the loopback binding. Every place the MCP endpoint is advertised uses http://localhost:<port>/mcp, so an MCP client configured against localhost can never establish the connection — the server shows as perpetually "connecting" and none of its tools surface to the agent. The web viewer and MCP endpoint are effectively unreachable from the same machine over loopback.
Environment
server.py run as a service with --port 8765 --tailscale
- MCP client (Claude Code) configured with the advertised endpoint
http://localhost:8765/mcp
Symptom
- The MCP server is listed but stays in a "connecting" state;
tools/list never returns, so tool discovery yields nothing.
curl -s -X POST http://localhost:8765/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' returns nothing (connection not served on loopback).
ss -tlnp shows the listener bound to the tailnet address only, e.g. 100.x.y.z:8765, with no 127.0.0.1:8765.
Root cause
resolve_bind_host() returns the tailnet IP instead of the loopback address when --tailscale is set, and HTTPServer binds to that single address:
def resolve_bind_host(host, use_tailscale, ip_lookup=tailscale_ip):
if not use_tailscale:
return host
ip = ip_lookup()
if not ip:
raise ValueError("Could not determine Tailscale IP. ...")
return ip # <-- single interface; loopback is dropped
server = HTTPServer((bind_host, port), Handler) # binds only to bind_host
Meanwhile the endpoint is advertised as loopback in both the UI and docs:
html += 'MCP endpoint: <code ...>http://localhost:' + location.port + '/mcp</code>';
So --tailscale silently relocates the MCP endpoint (and web UI) to the tailnet interface, but nothing that consumes it is told — a same-host MCP client on localhost is left unable to connect.
Reproduction
python3 server.py --port 8765 --tailscale
- Point an MCP client at
http://localhost:8765/mcp (as advertised).
curl -s -m 3 -X POST http://localhost:8765/mcp -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' → no response.
ss -tlnp | grep 8765 → bound to 100.x.y.z:8765, not 127.0.0.1.
Suggested fixes (any one)
- Bind loopback in addition to the tailnet IP so
--tailscale is additive rather than a move. Simplest: when --tailscale is set, bind 0.0.0.0 (all interfaces, which includes both loopback and the tailnet address) rather than the single tailnet IP — or run two listeners (127.0.0.1 + tailnet IP).
- If a single non-loopback bind is intended, print the actual reachable MCP URL (
http://100.x.y.z:8765/mcp) at startup and make the web UI advertise location.host (which it already partially does via location.port) so copy-paste reflects the real bind — and document that MCP clients must use that address, not localhost.
- Consider a startup self-check: after binding, attempt a loopback
tools/list and warn if the advertised localhost endpoint isn't actually reachable.
Why it matters
The MCP integration is a headline feature ("any MCP-capable agent can query your entire development history"). With --tailscale, that integration is silently disabled for a local agent, and the failure mode (a server stuck "connecting" with zero tools) gives no hint that the cause is the bind address. In practice an agent falls back to reading ~/.claude/breadcrumbs.db and the raw JSONL directly — which works, but defeats the purpose of the MCP endpoint.
Summary
When
server.pyis started with--tailscale, it binds only to the node's Tailscale IP and drops the loopback binding. Every place the MCP endpoint is advertised useshttp://localhost:<port>/mcp, so an MCP client configured againstlocalhostcan never establish the connection — the server shows as perpetually "connecting" and none of its tools surface to the agent. The web viewer and MCP endpoint are effectively unreachable from the same machine over loopback.Environment
server.pyrun as a service with--port 8765 --tailscalehttp://localhost:8765/mcpSymptom
tools/listnever returns, so tool discovery yields nothing.curl -s -X POST http://localhost:8765/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'returns nothing (connection not served on loopback).ss -tlnpshows the listener bound to the tailnet address only, e.g.100.x.y.z:8765, with no127.0.0.1:8765.Root cause
resolve_bind_host()returns the tailnet IP instead of the loopback address when--tailscaleis set, andHTTPServerbinds to that single address:Meanwhile the endpoint is advertised as loopback in both the UI and docs:
So
--tailscalesilently relocates the MCP endpoint (and web UI) to the tailnet interface, but nothing that consumes it is told — a same-host MCP client onlocalhostis left unable to connect.Reproduction
python3 server.py --port 8765 --tailscalehttp://localhost:8765/mcp(as advertised).curl -s -m 3 -X POST http://localhost:8765/mcp -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'→ no response.ss -tlnp | grep 8765→ bound to100.x.y.z:8765, not127.0.0.1.Suggested fixes (any one)
--tailscaleis additive rather than a move. Simplest: when--tailscaleis set, bind0.0.0.0(all interfaces, which includes both loopback and the tailnet address) rather than the single tailnet IP — or run two listeners (127.0.0.1 + tailnet IP).http://100.x.y.z:8765/mcp) at startup and make the web UI advertiselocation.host(which it already partially does vialocation.port) so copy-paste reflects the real bind — and document that MCP clients must use that address, notlocalhost.tools/listand warn if the advertisedlocalhostendpoint isn't actually reachable.Why it matters
The MCP integration is a headline feature ("any MCP-capable agent can query your entire development history"). With
--tailscale, that integration is silently disabled for a local agent, and the failure mode (a server stuck "connecting" with zero tools) gives no hint that the cause is the bind address. In practice an agent falls back to reading~/.claude/breadcrumbs.dband the raw JSONL directly — which works, but defeats the purpose of the MCP endpoint.