Tsmcp
MCP Tailnet Bridge β reverse proxy exposing private MCP servers on Tailscale to Claude.ai
Ask AI about Tsmcp
Powered by Claude Β· Grounded in docs
I know everything about Tsmcp. Ask me about installation, configuration, usage, or troubleshooting.
0/500
Reviews
Documentation
tsmcp β MCP Tailnet Bridge
A Go reverse proxy that exposes private MCP servers on your Tailscale network to Claude.ai via a single public FQDN β with OAuth authentication powered by Tailscale's identity provider (tsidp).
How It Works
βββTailnetβββΆ MCP Server A
Claude.ai ββHTTPSβββΆ Caddy ββHTTPβββΆ tsmcp βββββββββ€
β β βββTailnetβββΆ MCP Server B
β β
β βββTailnetβββΆ tsidp (/token, /register, /introspect)
β β²
βββββββββββHTTPS (Funnel)ββββββββββββββββββββββββββ
(browser: /authorize)
tsmcp connects to tsidp in two ways:
- tsmcp β tsidp (over Tailnet via tsnet): OAuth token exchange (
/token), client registration (/register), and token introspection (/introspect). The MCP spec requires these endpoints at the resource server's origin, so tsmcp proxies them to tsidp over the tailnet. - Browser β tsidp (over HTTPS/Funnel): User authorization only (
/authorize). tsmcp sends a 302 redirect β the browser must reach tsidp directly so Tailscale can verify the user's identity.
Each path in the config maps to a separate Claude.ai custom connector. One deployment serves multiple MCP servers.
Prerequisites
- Tailscale account with at least one MCP server on the tailnet
- tsidp running on your tailnet with Funnel enabled β the user's browser must reach it over the public internet for the
/authorizestep - VPS or server with a public IP, Docker installed, and joined to your Tailscale tailnet
- Domain name pointed at the host (e.g.,
mcp.example.com) - Caddy (or another reverse proxy) for TLS termination β must support SSE flush
- Tailscale ACLs allowing the bridge node to reach tsidp and your MCP servers (see example ACL below)
Setup Guide
1. Register a client with tsidp
From a machine on your tailnet, register an OAuth client for Claude.ai:
curl -s -X POST https://idp.YOUR-TAILNET.ts.net/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"client_name": "Claude.ai MCP",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic"
}' | python3 -m json.tool
This must be done from a tailnet node β tsidp rejects dynamic client registration over Funnel. Save the returned client_id and client_secret.
Why
client_secret_basic? tsidp does not yet support PKCE (Proof Key for Code Exchange), so it cannot act as a public OAuth client. The client must authenticate with a secret using HTTP Basic Auth (client_secret_basic). This means Claude.ai needs both theclient_idandclient_secretwhen configured as a connector.
2. Generate a Tailscale auth key
Go to the Tailscale admin console and generate an auth key. This allows tsmcp's embedded tsnet node to join your tailnet.
- Reusable: Optional β tsnet state is persisted to a Docker volume, so the node re-authenticates automatically after the first start
- Ephemeral: No β the bridge node must remain registered so ACL rules and tsidp access continue to work
- Tags: e.g.,
tag:tsmcp(for ACL rules)
3. Create the config file
server:
listen: "0.0.0.0:8900"
allowed_origins:
- "https://claude.ai"
- "https://claude.com"
tailnet:
hostname: "tsmcp"
state_dir: "/var/lib/tsmcp/tsnet"
authkey_env: "TS_AUTHKEY"
auth:
issuer: "https://idp.your-tailnet.ts.net"
audience: "https://mcp.example.com"
introspection_url: "https://idp.your-tailnet.ts.net/introspect"
resource_metadata_url: "https://mcp.example.com/.well-known/oauth-protected-resource"
endpoints:
- path: "/mcp/my-server"
target: "https://my-mcp-server.your-tailnet.ts.net/mcp"
description: "My MCP Server"
The target is the Tailscale FQDN (or MagicDNS name) of the MCP server on your tailnet. tsmcp dials it over Tailscale β the MCP server does not need to be publicly accessible.
The auth section is optional β omit it entirely to run without authentication.
4. Docker Compose
services:
tsmcp:
image: meltforce/tsmcp:edge
container_name: tsmcp
restart: unless-stopped
environment:
- TS_AUTHKEY=tskey-auth-...
volumes:
- ./config.yaml:/etc/tsmcp/config.yaml:ro
- tsnet-state:/var/lib/tsmcp/tsnet
networks:
- proxy-net
cap_drop:
- ALL
cap_add:
- NET_RAW
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=64m
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8900/healthz"]
interval: 10s
timeout: 3s
start_period: 30s
retries: 3
volumes:
tsnet-state:
networks:
proxy-net:
external: true
tsnet-statevolume persists the Tailscale node state across restartsproxy-netis an external Docker network shared with Caddy so it can reach tsmcp by container nameTS_AUTHKEYis only needed on first start; after tsnet saves state, the node re-authenticates automaticallyNET_RAWcapability is required by tsnet's userspace networking
docker network create proxy-net # if it doesn't exist yet
docker compose up -d
5. Configure Caddy
Add a route for your domain. The flush_interval -1 is critical for SSE streaming:
mcp.example.com {
reverse_proxy tsmcp:8900 {
flush_interval -1
}
log {
output file /var/log/caddy/mcp-access.log {
roll_size 10MiB
roll_keep 3
roll_keep_for 168h
}
format json
}
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
-Server
}
}
Caddy handles TLS automatically via Let's Encrypt. The header block adds HSTS, prevents MIME-type sniffing, and strips the Server header.
6. Add the Claude.ai connector
In Claude.ai settings β Integrations β Add custom integration:
- URL:
https://mcp.example.com/mcp/my-server - Client ID: the
client_idfrom Step 1 - Client Secret: the
client_secretfrom Step 1
Claude.ai will:
- Hit the MCP endpoint, get a 401
- Discover tsidp via
/.well-known/oauth-protected-resource - Fetch authorization server metadata from tsmcp (proxied from tsidp, with
/tokenand/registerrewritten to tsmcp's origin) - Redirect your browser to tsidp to authorize (authenticated by your Tailscale identity)
- Exchange the code for an access token via tsmcp's
/tokenendpoint (proxied to tsidp over the tailnet) - Call the MCP endpoint with the token
7. Verify
# Health check
curl https://mcp.example.com/healthz
# β {"status":"ok","tsnet_connected":true}
# Resource metadata
curl https://mcp.example.com/.well-known/oauth-protected-resource
# β {"resource":"https://mcp.example.com","authorization_servers":["https://idp.your-tailnet.ts.net"],...}
# Unauthenticated MCP request (should get 401)
curl -X POST https://mcp.example.com/mcp/my-server \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
-w "\nHTTP %{http_code}\n"
# β HTTP 401
# Container logs
docker logs tsmcp --tail 20
Auth Flow in Detail
tsmcp implements the MCP Authorization specification using Tailscale's identity provider (tsidp) as the OAuth authorization server.
The MCP spec requires that OAuth endpoints (/token, /register, /.well-known/oauth-authorization-server) are served at the resource server's origin β not at the authorization server. tsmcp handles this by proxying these endpoints to tsidp over the tailnet via tsnet. The only endpoint the browser reaches directly on tsidp is /authorize (via 302 redirect), because tsidp needs to verify the user's Tailscale identity.
Discovery
Claude.ai ββPOSTβββΆ https://mcp.example.com/mcp/my-server
βββ 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
Claude.ai ββGETβββΆ https://mcp.example.com/.well-known/oauth-protected-resource
βββ 200
{
"resource": "https://mcp.example.com",
"authorization_servers": ["https://idp.your-tailnet.ts.net"]
}
Claude.ai ββGETβββΆ https://mcp.example.com/.well-known/oauth-authorization-server
βββ 200 (proxied from tsidp, with endpoints rewritten)
{
"authorization_endpoint": "https://idp.your-tailnet.ts.net/authorize",
"token_endpoint": "https://mcp.example.com/token",
"registration_endpoint": "https://mcp.example.com/register",
...
}
- Claude.ai hits the MCP endpoint without a token, gets a
401with the resource metadata URL - Claude.ai fetches the RFC 9728 Protected Resource Metadata, which points to tsidp as the authorization server
- Claude.ai fetches RFC 8414 Authorization Server Metadata β tsmcp proxies from tsidp and rewrites
token_endpointandregistration_endpointto point at itself, keepingauthorization_endpointat tsidp
Authorization
Claude.ai ββGETβββΆ https://mcp.example.com/authorize?client_id=...&...
βββ 302 β https://idp.your-tailnet.ts.net/authorize?client_id=...&...
[User's browser follows redirect to tsidp, authenticates via Tailscale identity]
tsidp ββredirectβββΆ https://claude.ai/api/mcp/auth_callback?code=...&state=...
Claude.ai ββPOSTβββΆ https://mcp.example.com/token (proxied to tsidp via tsnet)
Authorization: Basic base64(client_id:client_secret)
grant_type=authorization_code&code=...
βββ { "access_token": "<opaque>", "token_type": "Bearer", "expires_in": 300 }
- Claude.ai hits
/authorizeon tsmcp, gets a 302 redirect to tsidp's authorize endpoint - tsidp authenticates the user via their Tailscale identity β the browser connects to tsidp over Tailscale Funnel, and tsidp identifies the user by their tailnet node. This is the key security boundary: only users on your tailnet can authorize.
- tsidp redirects back to Claude.ai with an authorization code
- Claude.ai exchanges the code for an opaque access token (5-minute TTL) via
POST /tokenon tsmcp, which proxies to tsidp over the tailnet. Authentication usesclient_secret_basic(HTTP Basic Auth with client credentials) β tsidp does not support PKCE.
Authenticated Request
Claude.ai ββPOSTβββΆ https://mcp.example.com/mcp/my-server
Authorization: Bearer <opaque-token>
tsmcp ββPOSTβββΆ https://idp.your-tailnet.ts.net/introspect (via tsnet)
token=<opaque-token>
βββ { "active": true, "sub": "user@github", "uid": "...", ... }
tsmcp ββproxyβββΆ https://my-mcp-server.your-tailnet.ts.net/mcp (via tsnet)
βββ MCP response (JSON-RPC / SSE)
- Claude.ai sends the MCP request with the Bearer token
- tsmcp validates the token by calling tsidp's RFC 7662 introspection endpoint over Tailscale (via tsnet) β this is critical because tsidp resolves to a Tailscale IP that isn't reachable from Docker's network otherwise
- If the token is active, tsmcp proxies the request to the upstream MCP server over Tailscale
- Introspection results are cached (60s or until token expiry, whichever is shorter)
Security Model
- tsidp rejects dynamic client registration (DCR) over Funnel β clients must be pre-registered from a tailnet node
- The authorize endpoint requires Tailscale identity β the user's browser must connect to tsidp over the tailnet. tsidp identifies users by their Tailscale node identity, not by a login form.
- Tokens are opaque β tsidp issues opaque access tokens (not JWTs), so they can only be validated via introspection
- Introspection goes through tsnet β tsmcp dials tsidp over the tailnet, so ACLs control which nodes can validate tokens
- A stranger cannot complete the OAuth flow: they can discover tsidp (via resource metadata), but they cannot register a client or authorize because those operations require tailnet access
Example Tailscale ACL
The bridge node needs to reach both tsidp (for token introspection) and the MCP servers (for proxying). tsidp also needs an app grant so that clients can register and users can authorize:
// Tag definitions
"tagOwners": {
"tag:tsmcp": ["autogroup:admin"], // MCP bridge
"tag:idp": ["autogroup:admin"], // Tailscale identity provider
"tag:mcp": ["autogroup:admin"], // MCP servers
},
"acls": [
// Allow the bridge to reach MCP servers
{
"src": ["tag:tsmcp"],
"dst": ["tag:mcp"],
"ip": ["tcp:443"],
},
// tsidp β admin access (admin UI + DCR)
{
"src": ["autogroup:admin"],
"dst": ["tag:idp"],
"app": {
"tailscale.com/cap/tsidp": [{
"allow_admin_ui": true,
"allow_dcr": true,
"resources": ["*"],
"scopes": ["openid", "email", "profile"],
"users": ["*"],
}],
},
},
// tsidp β all tailnet users (DCR only, no admin UI)
{
"src": ["*"],
"dst": ["tag:idp"],
"app": {
"tailscale.com/cap/tsidp": [{
"allow_admin_ui": false,
"allow_dcr": true,
"resources": ["*"],
"scopes": ["openid", "email", "profile"],
"users": ["*"],
}],
},
},
]
Features
- Path-based routing β single domain, multiple MCP servers
- SSE streaming β automatic flush for Server-Sent Events
- OAuth auth β validate opaque tokens via RFC 7662 introspection against tsidp
- OAuth endpoint proxying β
/token,/register, and AS metadata proxied to tsidp via tsnet;/authorizeredirected to tsidp for Tailscale identity - RFC 9728 metadata β
/.well-known/oauth-protected-resourcefor MCP auth discovery - Tailscale identity β authentication is backed by Tailscale node identity, not passwords
- Origin validation β restrict to
claude.ai/claude.com - Health check β
/healthzwith tsnet readiness - Structured logging β JSON via
slog - Hardened Docker β read-only root, cap_drop ALL, unprivileged user
Configuration Reference
server
| Field | Required | Description |
|---|---|---|
listen | Yes | Bind address. Must be loopback (127.0.0.1) or unspecified (0.0.0.0). |
allowed_origins | No | List of allowed Origin headers. Empty = allow all (dev mode). |
tailnet
| Field | Required | Description |
|---|---|---|
hostname | Yes | Tailscale node name for the bridge. |
state_dir | Yes | Directory for tsnet persistent state. |
authkey_env | Yes | Environment variable containing the Tailscale auth key. |
auth (optional β omit for authless mode)
| Field | Required | Description |
|---|---|---|
issuer | Yes | tsidp issuer URL (your tailnet's IDP). |
audience | Yes | Your bridge's public URL. |
introspection_url | Yes | tsidp introspection endpoint. Must be http or https. |
client_id | Yes | Client ID from DCR registration (Step 1). Used to authenticate introspection requests. |
client_secret | Yes | Client secret from DCR registration. Used to authenticate introspection requests. |
resource_metadata_url | Yes | Public URL of the RFC 9728 metadata endpoint. |
Notes:
client_idandclient_secretare the credentials obtained from registering a client with tsidp (Step 1). They are used both by Claude.ai for the OAuth flow and by tsmcp to authenticate token introspection requests via HTTP Basic Auth.- The
introspection_urlmust be reachable from inside the container. Since tsidp resolves to a Tailscale IP, tsmcp routes introspection requests through its embedded tsnet node automatically. - Active introspection results are cached for 60 seconds (or until token expiry, whichever is shorter) to reduce round-trips.
endpoints
| Field | Required | Description |
|---|---|---|
path | Yes | URL path for this endpoint (e.g., /mcp/my-server). Must start with /. |
target | Yes | Upstream MCP server URL (Tailscale FQDN). Must be http or https. |
description | No | Human-readable description for logging. |
enabled | No | Set to false to disable. Default: true. |
upstream_token_env | No | Environment variable holding a Bearer token to set on upstream requests. |
Health Check
curl http://127.0.0.1:8900/healthz
{"status":"ok","tsnet_connected":true}
Returns 200 when tsnet is connected, 503 when degraded.
Development
# Run tests
go test ./...
# Run with debug logging
go run . -config config.yaml -debug
# Build
go build -o tsmcp .
Project Structure
βββ main.go # Entry point, lifecycle management
βββ internal/
β βββ config/ # YAML config loading + validation
β βββ auth/ # Token introspection + RFC 9728 metadata + OAuth endpoint proxying
β βββ proxy/ # Reverse proxy handler + transport
β βββ tsbridge/ # Tailscale network bridge (tsnet)
β βββ health/ # Health check endpoint
β βββ server/ # HTTP server assembly + middleware
βββ Dockerfile # Multi-stage build (48MB image)
βββ docker-compose.yml # Production deployment
βββ .github/workflows/ # CI/CD (deploy + release)
