Gateway
Gatewaybeta9 min

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:

PeerThe caller is
A user's devicethe 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:

yaml
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:

json
{
  "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"] }] }
    }
  ]
}
yaml
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

  1. Create an auth key in the Tailscale admin console (a tagged one, e.g. tag:mcp, suits a server) and export it:

    bash
    export TS_AUTHKEY=<your key>
    
  2. Turn on MagicDNS and HTTPS certificates for the tailnet in the admin console (for listen.mode: https, the default).

  3. Load the plugin and add it to gateway.server.transports:

    yaml
    plugins:
      - 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}
    
  4. Start the gateway. The log names the node:

    text
    tailscale node up node=mcp.example.ts.net listen=tailnet:https://mcp.example.ts.net
    

    Give 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

MethodConfig
Auth keyauth_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 federationclient_id with id_token, or with audience for a well-known identity provider
Headscale or another control servercontrol_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:

yaml
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

FieldDefaultDescription
hostnamemcpgThe 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, audiencenoneHow the node authenticates; set one method.
advertise_tags[]Tags the node asks for.
control_urlTailscaleAnother coordination server.
ephemeralfalseLeave the tailnet when the gateway stops.
upload_logsfalseUpload the node's logs to Tailscale.
wireguard_portanyThe UDP port for WireGuard.
listen.mode, listen.porthttps, 443The tailnet listener (http defaults to 80).
funnel.enabled, funnel.portfalse, 443The public listener.
identity.trust_levelverifiedverified, header_asserted, or none to attest nothing.
identity.capabilitynoneThe app capability that carries roles, groups and scopes.
identity.require_capabilityfalseRefuse devices without the capability.
identity.tagged_nodesallowallow or refuse tagged devices.
connect_timeout_secs60How long boot waits for the node.
max_request_body_bytes4194304The 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_dir and hostname. A 2025-11-25 client keeps its session on one node.
  • legacy_wire: false works: the plugin goes through the HTTP router.
  • The local listener stays on. Bind it to 127.0.0.1 so the tailnet is the only way in from other machines.

For a public URL without a tailnet, see Serve through ngrok.