# Turnscale [![tangled.sh](https://img.shields.io/badge/tangled.sh-scottlanoue.com%2Fturnscale-blue)](https://tangled.sh/scottlanoue.com/turnscale) MCP gateway for Tailscale. Proxies [Model Context Protocol](https://modelcontextprotocol.io) requests to multiple backends, using Tailscale identity for access control. Single Go binary, no credentials to manage. - Identity from Tailscale `WhoIs` — no API keys or OAuth - Policy via tailnet ACL grants (or YAML fallback) - Per-tool deny globs (e.g. allow Gitea but deny `delete_*`) - Audit log and optional session recording - Dashboard with server health, request chart, tool discovery - Single binary, no CGO ![Turnscale dashboard](demo.png) ## Quick Start ```bash go build ./cmd/turnscale cp gateway.example.yaml gateway.yaml # edit with your servers + policies ./turnscale -config gateway.yaml ``` The gateway joins your tailnet as a node. Point MCP clients at `https://{hostname}.{tailnet}.ts.net/{server}/mcp`. ## Configuration ```yaml hostname: "mcp" tailnet: "your-tailnet.ts.net" state_dir: "~/.local/share/turnscale" servers: github: url: "http://localhost:8091/mcp" transport: "streamable-http" slack: url: "http://localhost:8092/mcp" transport: "streamable-http" policies: - name: "admin" match: identity: ["you@github"] allow: ["*"] - name: "ai-agents" match: tags: ["tag:ai-agent"] allow: ["github", "slack"] deny_tools: ["mcp__github__delete_*"] - name: "default-deny" match: identity: ["*"] deny: ["*"] ``` Servers can also be added, edited, and removed from the dashboard UI. ## Connecting Clients ### Claude Code Add to `~/.claude/settings.local.json`: ```json { "mcpServers": { "github": { "type": "http", "url": "https://mcp.your-tailnet.ts.net/github/mcp" }, "slack": { "type": "http", "url": "https://mcp.your-tailnet.ts.net/slack/mcp" } } } ``` ### Any MCP Client (Streamable HTTP) ``` POST https://mcp.your-tailnet.ts.net/{server}/mcp # JSON-RPC GET https://mcp.your-tailnet.ts.net/{server}/mcp # SSE stream DELETE https://mcp.your-tailnet.ts.net/{server}/mcp # Session termination ``` No auth headers — identity comes from Tailscale. ## Tailscale ACL Grants The recommended way to manage access. Add grants to your tailnet policy — the gateway reads them from `WhoIs().CapMap` at request time: ```json { "grants": [ { "src": ["autogroup:member"], "dst": ["tag:server"], "app": { "your-tailnet.ts.net/cap/mcp": [{ "servers": ["*"], "admin": true }] } }, { "src": ["tag:ai-agent"], "dst": ["tag:server"], "app": { "your-tailnet.ts.net/cap/mcp": [{ "servers": ["github", "slack"], "denyTools": ["mcp__github__delete_*"], "record": true }] } } ] } ``` | Field | Description | |-------|-------------| | `servers` | Server names or `["*"]` for all | | `denyTools` | Glob patterns for denied tools | | `admin` | Access to audit logs, server management, session recordings | | `record` | Capture full request/response bodies for this caller | Grants take priority over YAML policies. Multiple grants per caller are unioned. ## Web UI All pages live under `/ui/`: - `/ui/` — dashboard: request chart, server health, policies, recent activity - `/ui/audit` — full audit log with filtering by caller/server/tool/status - `/ui/session/{id}` — session recording viewer ## Architecture ``` ┌──────────────┐ ┌──────────────────┐ ┌─────────────┐ │ Claude Code │────>│ Turnscale │────>│ github-mcp │ │ │ │ (tsnet node) │────>│ slack-mcp │ ├──────────────┤ │ │────>│ jira-mcp │ │ AI Agents │────>│ - WhoIs identity│────>│ ... │ │ (tag:agent) │ │ - ACL grants │ └─────────────┘ └──────────────┘ │ - Audit + Record│ │ - Tool discovery│ │ - Dashboard │ └──────────────────┘ ``` ## Development ```bash go build ./... # Build go test -race ./... # Test (85 tests, race detector) go test -cover ./... # Coverage go vet ./... # Static analysis ``` ## Dependencies - [`tailscale.com/tsnet`](https://pkg.go.dev/tailscale.com/tsnet) — embedded Tailscale node - [`modernc.org/sqlite`](https://pkg.go.dev/modernc.org/sqlite) — pure-Go SQLite (no CGO) - [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) — config parsing ## License [MIT](LICENSE)