Skip to content

--tailscale binds only to the tailnet IP, breaking MCP clients on localhost #2

Description

@davidg238

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

  1. python3 server.py --port 8765 --tailscale
  2. Point an MCP client at http://localhost:8765/mcp (as advertised).
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions