Serve on a tailnet
Join the gateway to your tailnet as its own Tailscale node with the Tailscale transport plugin. Callers are authenticated by the tailnet with no token, and roles come from your tailnet policy.
The Tailscale transport plugin (dev.mcpg.transport.tailscale) joins the
gateway to your tailnet as its own node. The node
is embedded in the plugin, so the machine runs no tailscaled, opens no
inbound port and needs no public IP. Every device on the tailnet that your
policy allows can reach the gateway at https://<hostname>.<tailnet>.ts.net.
The plugin hands every request to the gateway's own HTTP router, so identity, policy, audit, rate limits, SSE and both MCP revisions work as on the HTTP listener. It adds one thing the HTTP listener cannot: it knows who is calling.
Tailnet identity, with no token
For each tailnet connection the plugin asks its node who owns the peer (Tailscale's whois), and attests that identity on every request of the connection:
| Peer | The caller is |
|---|---|
| A user's device | the owner's login, e.g. alice@example.com |
| A tagged device (a server) | the node name, e.g. ci.example.ts.net |
The identity is verified: WireGuard authenticated the peer node, and your
tailnet's control server names its owner. Trust floors, policies and the
audit log see a verified principal with the issuer tailscale:<tailnet> and
the source transport:dev.mcpg.transport.tailscale. A request that presents
its own verified credential (a bearer token) keeps that identity instead.
To admit only tailnet members, require authentication:
governance:
access:
require_authentication: true
A Funnel caller (see below) and any caller on the gateway's own HTTP listener then need a credential of their own.
Roles from your tailnet policy
Tailscale app capabilities carry application data in a grant. Name one,
and the roles, groups and scopes arrays of every grant that matches the
caller become the caller's:
{
"grants": [
{
"src": ["group:eng"],
"dst": ["tag:mcp"],
"app": {
"example.com/cap/mcpg": [{ "roles": ["developer"], "scopes": ["tools:call"] }]
}
},
{
"src": ["group:sre"],
"dst": ["tag:mcp"],
"app": { "example.com/cap/mcpg": [{ "roles": ["admin"] }] }
}
]
}
config:
identity:
capability: example.com/cap/mcpg
require_capability: true
With require_capability: true, a tailnet device that the policy grants no
such capability gets HTTP 403 from the plugin; its requests never reach the
gateway. Access to a tool then has two layers: the tailnet policy decides who
holds which roles, and the gateway's policies
decide what each role may call.
Quick start
-
Create an auth key in the Tailscale admin console (a tagged one, e.g.
tag:mcp, suits a server) and export it:bashexport TS_AUTHKEY=<your key> -
Turn on MagicDNS and HTTPS certificates for the tailnet in the admin console (for
listen.mode: https, the default). -
Load the plugin and add it to
gateway.server.transports:yamlplugins: - id: dev.mcpg.transport.tailscale kind: native class: transport source: oci: ghcr.io/mcpg-dev/plugins/transport-tailscale granted_capabilities: - network_outbound - transport_listen gateway: server: bind_address: "127.0.0.1:8787" transports: - kind: dev.mcpg.transport.tailscale config: hostname: mcp state_dir: /var/lib/mcpg/tailscale auth_key: ${env.TS_AUTHKEY} -
Start the gateway. The log names the node:
texttailscale node up node=mcp.example.ts.net listen=tailnet:https://mcp.example.ts.netGive tailnet clients
https://mcp.example.ts.net/mcp.
The auth key is used once, to create the node; after that the node's keys live
in state_dir. Without an auth key, the log shows a login URL at WARN; open it
to add the node.
Other ways to authenticate the node
| Method | Config |
|---|---|
| Auth key | auth_key: ${env.TS_AUTHKEY} |
| OAuth client secret (mints a tagged node's key) | client_secret: ${env.TS_CLIENT_SECRET} and advertise_tags: [tag:mcp] |
| Workload identity federation | client_id with id_token, or with audience for a well-known identity provider |
| Headscale or another control server | control_url: https://headscale.example.com |
Set ephemeral: true for a node that leaves the tailnet when the gateway
stops; it then needs no state_dir.
Serve the public internet too
Funnel publishes the node on the internet as well:
config:
funnel:
enabled: true
port: 443 # 443, 8443 or 10000
Funnel callers come from the internet: they carry no tailnet identity, and the gateway's own authentication applies to them. With Funnel on, the node's URL is the transport's listen address and the gateway's public address for webhook callbacks. Your tailnet policy must allow Funnel for the node.
Configuration
| Field | Default | Description |
|---|---|---|
hostname | mcpg | The node's name on the tailnet. |
state_dir | (required unless ephemeral) | The node's keys and state; one per gateway replica. |
auth_key, client_secret, client_id, id_token, audience | none | How the node authenticates; set one method. |
advertise_tags | [] | Tags the node asks for. |
control_url | Tailscale | Another coordination server. |
ephemeral | false | Leave the tailnet when the gateway stops. |
upload_logs | false | Upload the node's logs to Tailscale. |
wireguard_port | any | The UDP port for WireGuard. |
listen.mode, listen.port | https, 443 | The tailnet listener (http defaults to 80). |
funnel.enabled, funnel.port | false, 443 | The public listener. |
identity.trust_level | verified | verified, header_asserted, or none to attest nothing. |
identity.capability | none | The app capability that carries roles, groups and scopes. |
identity.require_capability | false | Refuse devices without the capability. |
identity.tagged_nodes | allow | allow or refuse tagged devices. |
connect_timeout_secs | 60 | How long boot waits for the node. |
max_request_body_bytes | 4194304 | The largest request body. |
Behaviour to know
- Platforms. The plugin ships for Linux (glibc) amd64 and arm64, and macOS arm64. The node is Go code inside the plugin: musl cannot load a Go runtime into a plugin, and the plugin passes connections as Unix socket pairs, which Windows does not have.
- Privacy. The node does not upload logs to Tailscale unless you set
upload_logs: true. - Boot fails on a node that does not come up within
connect_timeout_secs; the error names the cause and never shows a key. - Replicas. Each replica is its own node, with its own
state_dirandhostname. A2025-11-25client keeps its session on one node. legacy_wire: falseworks: the plugin goes through the HTTP router.- The local listener stays on. Bind it to
127.0.0.1so the tailnet is the only way in from other machines.
For a public URL without a tailnet, see Serve through ngrok.