Reverse tunnels
Expose a gateway that binds no public port — it dials out to an MCPG relay and is answered through its own full request path. Get a public MCP URL for dev preview, reach a gateway behind NAT, or federate a customer's gateway so their secrets never leave their infrastructure.
A reverse tunnel lets a gateway serve MCP without binding a public port.
The gateway dials out to an MCPG relay over a single outbound 443
connection; the relay answers inbound MCP requests back down that connection into
the gateway's own request path. Nothing listens; no inbound firewall rule; the
full gateway pipeline — identity, policy, audit, rate limits — still runs on
every request.
This guide is operator-facing: the model, the exposure and trust modes, a public-tunnel quick start, and the keystone use case — reverse federation, where an MCPG-Cloud gateway federates a customer's tunnelled gateway so the customer's secrets never leave their own infrastructure. It pairs directly with MCP federation; read that first if you haven't.
Why tunnels
Three concrete jobs, none served well by a generic byte tunnel (ngrok, cloudflared, the OpenAI MCP tunnel):
- Dev preview. Run
mcpg --tunneland get a public URL to paste into ChatGPT, claude.ai, or an agent — no account with a third party, no inbound firewall change, and your gateway's governance applies to the exposed surface. - Private deploy, public reach. Run a gateway deep inside a network and
expose exactly one MCP endpoint through the tunnel — outbound-
443only, nothing listening on the box. - Secret-sovereign federation (the strategic one). A customer runs a
gateway in their own infrastructure, holding their own secrets and
network access, and tunnels it privately. An MCPG-Cloud gateway federates it
over
tunnel://and re-exports its tools to the customer's users — without ever holding those secrets. With a private tunnel this happens with no public URL at all: the customer exposes nothing to the internet, only an org-scoped federation endpoint.
Unlike a generic tunnel, an MCPG tunnel carries MCP semantics end to end: the caller's identity propagates into the policy chain, every call is audited, and the relay is MCP-session-aware. And it runs the inverse direction no byte-pipe offers — a gateway reaching an MCP server behind NAT, with central policy and audit.
How it works
Tunneling reuses the gateway's normal request path. The gateway's HTTP router is an in-process service; the tunnel replays framed MCP requests from the relay straight into it. Identity resolution, per-request metadata, SSE streaming, and rate limiting all run exactly as they would for a locally-bound listener — there is simply no TCP bind.
┌──────────────── your network / laptop ─────────────────┐
│ mcpg --tunnel ──dials out (wss, 443)──▶ │
└────────────────────────────────────────────│──────────┘
▼
┌────────────────────┐
MCP client ──────https────────▶ │ MCPG relay │
(ChatGPT, agent, cloud gateway) │ host-routes onto │
│ the owning tunnel │
└─────────│──────────┘
▼ (back down the same connection)
gateway router.oneshot(req)
→ identity · policy · audit · SSE
The gateway authenticates itself to the relay on dial-in with an org token
(MCPG_TUNNEL_TOKEN). If the connection drops, the gateway reconnects with a
doubling backoff (at most 30 seconds) and re-registers automatically.
Exposure and trust
A tunnel has two independent properties.
Exposure — is there a public URL?
| Exposure | Public URL | Reachable by | Set with |
|---|---|---|---|
public (default) | https://<id>.tunnels.mcpg.cloud/mcp | any MCP client on the internet | (default) |
private | none — no public hostname, no public TLS listener | only same-org gateways, as a tunnel://<name> federation upstream | --private |
Trust — what can the relay see?
| Mode | Relay sees | Requires | Set with |
|---|---|---|---|
relay_terminated (default) | plaintext (it terminates client TLS, like ngrok / a CDN) | — | (default) |
e2ee | intended: ciphertext only, relay blind | private exposure (mcpg-to-mcpg only) | --tunnel-mode e2ee |
e2ee status. End-to-end encryption is specified and license-gated but not yet cryptographically enforced in the data plane. Today every tunnel is
relay_terminatedat the transport: a relay you don't operate can see payloads. If you need the relay to be truly blind right now, self-host the relay inside your own trust boundary (below) rather than relying one2eemode. This note will be removed when the ciphertext-splice data plane ships.
The two combine into three practical postures:
public+relay_terminated— dev preview / third-party clients. The relay terminates TLS, same trust model as any hosted tunnel.private+relay_terminated— reverse federation. No public surface; the relay routes org-internally. If you run the relay yourself, it's fully inside your boundary.private+e2ee— the target posture for zero-trust-in-the-relay federation (see the status note above).
e2ee always implies private — the gateway forces it, and the relay rejects
a public + e2ee tunnel.
Quick start — a public dev tunnel
Expose a local gateway's MCP surface at a public URL:
export MCPG_TUNNEL_TOKEN=<your org token> # from mcpg.cloud → Settings → Tunnels
mcpg --config gw.yaml --tunnel
Bare --tunnel dials the MCPG Cloud relay (wss://relay.tunnels.mcpg.cloud),
or a relay_url set in the config file's tunnel: block. To dial another
relay — e.g. a self-hosted one — give the URL
directly to the flag:
mcpg --config gw.yaml --tunnel wss://relay.example.com:7845
The gateway dials the relay and logs its public URL:
tunnel established url=https://a1b2c3.tunnels.mcpg.cloud/mcp
Paste that /mcp URL into any MCP client. Requests arrive through your gateway's
full pipeline — a caller's Authorization bearer is resolved by your identity
chain, your policies gate every tool, and every call is audited. Give the tunnel
a stable name so the URL doesn't rotate:
mcpg --config gw.yaml --tunnel --tunnel-name demo
# → https://demo.tunnels.mcpg.cloud/mcp
Equivalently, in config instead of flags:
gateway:
server:
tunnel:
enabled: true
name: demo
# exposure: public # default
# mode: relay_terminated # default
# relay_url: wss://relay.tunnels.mcpg.cloud # default
Flags win per field over config: each --tunnel* flag overrides only its own
field, so --tunnel-name replaces tunnel.name while a file-set relay_url
or mode is kept.
Reverse federation — the keystone
This is the pairing with federation that tunnels exist for. Two gateways, two orgs' worth of trust, secrets that never move:
end user ──▶ cloud gateway ──tunnel://acme-crm──▶ relay ──▶ customer gateway
(MCPG-Cloud, (in ACME's VPC,
no secrets) re-exports ACME's tools holds the CRM creds)
On the customer side — run the real gateway inside your infrastructure, holding your data-source credentials, and open a private tunnel (no public URL):
export MCPG_TUNNEL_TOKEN=<acme org token>
mcpg --config acme-gw.yaml --tunnel --private --tunnel-name acme-crm
Your CRM backend, its credentials, and its network access stay entirely inside ACME's boundary. The tunnel exposes nothing to the internet — it's reachable only as an org-scoped federation upstream.
On the cloud side — the MCPG-Cloud gateway federates that tunnel by name and re-exports its tools under a prefix, applying its own governance:
gateway:
server:
tunnel_federation:
relay_ingress_url: "https://relay.tunnels.mcpg.cloud"
token: "${env.MCPG_TUNNEL_TOKEN}" # same org token; authenticates the org to the relay
mcp:
federations:
- name: acme-crm
governance:
minimum_trust: verified
allow_if: '"acme" in identity.groups'
upstream:
transport: streamable_http
url: "tunnel://acme-crm/mcp" # resolves through the relay to the private tunnel
import:
tools: true
naming:
tool_prefix: "acme."
The cloud gateway now serves acme.* tools to ACME's users. When a user calls
one, the request travels cloud gateway → relay → the private tunnel → ACME's
gateway, which executes it against the CRM with credentials the cloud never
saw. The cloud gateway is a governed aggregator; the secrets are sovereign.
Everything in the federation guide applies to a
tunnel:// upstream — prefixing, filtering, minimum_trust, allow_if,
per-user impersonation, list_changed refresh. A tunnel:// upstream is just a
federation upstream whose transport happens to be a private tunnel.
How the two tokens differ
Reverse federation involves two distinct credentials — don't conflate them:
MCPG_TUNNEL_TOKEN(the org token) authenticates the org to the relay. On the customer side it's the dial-in bearer; on the cloud side it's sent in theX-MCPG-Tunnel-Tokenheader. The relay consumes it and never forwards it down the tunnel.- The end user's
Authorizationbearer flows through untouched to the tunnelled gateway, where it's the MCP caller identity thatminimum_trustandallow_ifevaluate. The tunnel token and the caller token never collide.
Configuration reference
Egress — gateway.server.tunnel
Makes this gateway dial a relay and serve through it.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Turn the tunnel on. --tunnel sets this. |
relay_url | string | wss://relay.tunnels.mcpg.cloud | Relay agent-accept endpoint (wss://). |
name | string | (the gateway's instance uid) | Stable tunnel name → stable public sub-domain. A public name must be one DNS label (a-z, 0-9, -, compared lowercase) and stays with the org that first registers it. One gateway holds a name at a time, so give each gateway its own. |
exposure | public | private | public | private = no public URL; federation-only. |
mode | relay_terminated | e2ee | relay_terminated | Trust mode (see e2ee status note). e2ee requires private. |
Ingress — gateway.server.tunnel_federation
Lets this gateway resolve tunnel:// federation upstreams through a relay.
Required whenever any federation upstream uses a tunnel:// URL.
| Field | Type | Default | Meaning |
|---|---|---|---|
relay_ingress_url | string | (required) | Relay federation-ingress base URL (http(s)://). |
token | string | MCPG_TUNNEL_TOKEN env | Org token sent in X-MCPG-Tunnel-Token. Supports ${env.X}. |
The tunnel:// upstream
A federation upstream of the form tunnel://<name>/<path> — the <name> must
match the private tunnel's name. It resolves at connect time to
<relay_ingress_url>/federate/<name>/<path>. A tunnel:// upstream without a
name is rejected at validation, and a tunnel:// upstream with no
tunnel_federation block configured fails fast at boot.
CLI flags and env
| Flag | Effect |
|---|---|
--tunnel [wss-url] | Enable the tunnel (dial the relay, no public bind). The optional value sets the relay endpoint; bare --tunnel uses the config file's relay_url (or the MCPG Cloud relay). |
--private | Private exposure — no public URL, federation-only. |
--tunnel-name <name> | Stable tunnel name. |
--tunnel-mode <relay_terminated|e2ee> | Trust mode (e2ee forces --private). |
--tunnel-relay <wss-url> | Override the relay endpoint — equivalent to passing the URL to --tunnel (give it to only one of the two). |
The --private / --tunnel-name / --tunnel-mode / --tunnel-relay flags all
require --tunnel.
| Env var | Purpose |
|---|---|
MCPG_TUNNEL_TOKEN | Org token presented to the relay on dial-in / federation. |
MCPG_TUNNEL_CA | Path to a private CA PEM to trust a self-hosted relay's TLS. |
Validate any config with mcpg config check before shipping.
Plans and quota
The relay is a governed resource: your plan sets how many tunnels you can run concurrently, and which exposure/trust modes you may use. The scarce thing — a live relay registry slot — is what the tier gates.
| Plan | Concurrent tunnels | Private (reverse federation) | e2ee |
|---|---|---|---|
| Community (free) | 1 | — | — |
| Pro | 3 | — | — |
| Team | 10 | ✓ | — |
| Enterprise | unlimited | ✓ | ✓ |
- Public tunnels are governed by the quota alone — no feature gate. A free account runs one public dev tunnel.
- Private tunnels (reverse federation) start at Team. Opening a
--privatetunnel on a lower plan is refused with an upgrade message. - e2ee mode is Enterprise (and see the e2ee status note above).
- Opening one tunnel past your quota is refused — the quota never closes an existing tunnel. Relayed bytes are metered as fair-use overage, not a primary meter: on a paid plan, bytes past the included traffic are billed from the same monthly spend cap as pay-as-you-go tool calls and MCP Events deliveries, and tunnels close when it is reached. On the free plan, traffic past the included amount is slowed down — never cut off and never billed — until the next calendar month (UTC) or an upgrade.
- When your org loses a plan feature an open tunnel uses (a private or e2ee tunnel after a downgrade or cancellation), or is suspended, the tunnel keeps running for a 7-day grace, and reconnects during it. It keeps the included traffic of the plan it ran under too: after a cancellation the free plan slows it down only past that traffic, and after a move to a lower paid plan the lower plan's spend cap closes it only past that traffic plus what the cap leaves. When the grace ends it is closed within a few minutes, and the gateway is refused until the org is entitled again. A new tunnel is judged by the org's plan at once. A deleted org, or an immediate suspension, closes its tunnels at the next heartbeat.
- A gateway that goes away without closing its connection (a host that lost power, a dropped network) gives its slot back within about a minute: the relay closes a tunnel it has heard nothing from for 60 seconds.
See editions and pricing for the managed relay, quota enforcement, and billing.
Public names
A public tunnel's name is its address, so it stays bound to your org after
the tunnel closes: no other org can take the URL while your gateway is
offline. An org keeps at most three public names per concurrent-tunnel slot
(at least three; no cap with unlimited tunnels). A tunnel without
--tunnel-name is named after the gateway's instance id, which changes on
every restart, so each restart binds a new name. When your org binds one name
past its cap, the name whose tunnel closed longest ago is released: it stays
yours for 7 more days, and your org can bind it again meanwhile; after that
any org can register it. A deleted org's names are released 90 days after
the deletion.
A released name can reach someone else's gateway, together with whatever a
client still configured with the old URL sends it. For a URL that clients
keep using, set a fixed --tunnel-name and keep that gateway connected.
Self-hosting the relay
The relay ships as mcpg-cloud-relay. Run it inside your own trust boundary
when you want the relay under your control (the honest way to get "relay sees
nothing it shouldn't" today, given the e2ee note). It's configured entirely by
MCPG_RELAY_* env vars:
| Env var | Default | Purpose |
|---|---|---|
MCPG_RELAY_AGENT_ADDR | 0.0.0.0:7845 | Agent-accept listener (gateways dial in). |
MCPG_RELAY_PUBLIC_ADDR | 0.0.0.0:8080 | Public MCP listener (host-routed). |
MCPG_RELAY_FEDERATION_ADDR | 0.0.0.0:8081 | Federation ingress (/federate/<name>/…). |
MCPG_RELAY_OPS_ADDR | 0.0.0.0:9090 | /healthz, /readyz, /metrics. |
MCPG_RELAY_ZONE | tunnels.mcpg.cloud | DNS zone for public URLs (<id>.<zone>). |
MCPG_RELAY_AUTH_TOKENS | — | Static grants token:org[,token:org…]. |
MCPG_RELAY_CP_URL / MCPG_RELAY_CP_TOKEN | — | Control-plane URL + bearer for entitlement-aware auth (plan/quota/suspension). |
MCPG_RELAY_TLS_CERT / MCPG_RELAY_TLS_KEY | — | Agent-accept TLS (wildcard cert for *.<zone>). |
MCPG_RELAY_FORWARD_TIMEOUT_SECS | 30 | Wait for a response head before 504 (the SSE body is unbounded; a throttled request body's wait for the byte rate does not count). |
MCPG_RELAY_IP_RATE / _IP_BURST / _TUNNEL_RATE / _TUNNEL_BURST | 50 / 100 / 200 / 400 | Edge rate limits (per-IP and per-tunnel token buckets). |
MCPG_RELAY_THROTTLE_BYTES_PER_SEC | 65536 | Bytes a second one throttled org's tunnels share (a free-plan org past its monthly allowance, as the control plane says). At least 1. |
The relay refuses to boot without an authenticator — configure
MCPG_RELAY_AUTH_TOKENS (or the control-plane pair for managed entitlements)
before exposing it. Point tunnelling gateways at it with --tunnel-relay wss://<your-relay-host>:7845, and if it uses a private CA, set MCPG_TUNNEL_CA
on the gateway.
Security model
- No inbound surface. A tunnelled gateway binds nothing; a
privatetunnel additionally has no public hostname or TLS listener anywhere. The only reach is org-scoped federation. - Full pipeline on every request. Tunnelled requests run the same identity, policy, audit, and rate-limit path as a bound listener — a tunnel is not a governance bypass.
- Token separation. The org tunnel token authenticates to the relay and is
stripped before forwarding; the end user's
Authorizationflows through as the MCP caller identity (see above). - Revocation reaches open tunnels. Revoking the org token a gateway dialed in with, or its expiry, closes that gateway's tunnel within about 30 seconds; it cannot reconnect with that token.
- Cross-org isolation. A federation reach for a tunnel your org doesn't own
— or a name that doesn't exist — both return
404, so tunnel names can't be probed across orgs. - SSRF guard.
tunnel://is the sanctioned private federation path; it's exempt from the gateway's private-address SSRF block precisely because the relay, not a raw private IP, is the reachable endpoint. All other private or loopback federation upstreams stay blocked unless explicitly allowed. - Relay trust. In
relay_terminatedmode the relay sees plaintext. For the hosted relay that is the same trust you extend any managed tunnel provider; self-host the relay to keep it inside your boundary.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Gateway logs "no relay authentication" / refuses to establish | MCPG_TUNNEL_TOKEN unset or not accepted by the relay. |
tunnel_quota_exceeded on dial-in | at your plan's concurrent-tunnel cap — close one or upgrade. |
| Tunnel traffic slows down sharply, but the tunnel stays up | your org is on the free plan and has relayed its included tunnel traffic for this calendar month (UTC). It is never billed for it; full speed returns on the 1st, or at the next heartbeat after an upgrade. |
tunnel_spend_cap_reached on dial-in, or an open tunnel closes | your org is on a paid plan, and its tunnel traffic past the included traffic, with its pay-as-you-go tool calls and MCP Events deliveries, has reached the monthly spend cap — an owner raises the cap (Settings → Plan), or wait for the next month. |
Private tunnel refused (tunnels_private_not_licensed) | --private needs Team or above. |
e2ee tunnel refused (tunnels_e2ee_not_licensed) | --tunnel-mode e2ee needs Enterprise. |
tunnel_hostname_taken on dial-in | another org holds that public name — pick another --tunnel-name. |
tunnel_name_in_use on dial-in | another gateway of your org is connected with that name — give each gateway its own --tunnel-name. The gateway keeps retrying, at least every 5 seconds, so a gateway that replaces another (a rolling update) connects within about 5 seconds of the old one disconnecting. |
An open tunnel closes and the gateway then logs unauthorized | the org token it dialed in with was revoked or expired — issue a new one. |
tunnel_name_invalid on dial-in | a public tunnel's name must be one DNS label; without --tunnel-name the gateway's instance uid is used, so set a name. |
org_suspended on dial-in, or open tunnels close | the org is suspended — settle billing to reconnect. |
server.tunnel: e2ee mode requires private exposure at boot | pair --tunnel-mode e2ee with --private (it forces it, but explicit config must match). |
tunnel:// upstream but … tunnel_federation … is not configured | add gateway.server.tunnel_federation.relay_ingress_url on the federating gateway. |
a tunnel:// upstream needs a name | write tunnel://<name>/<path>, not tunnel:///…. |
Federated tunnel:// tools missing / 404 on reach | the private tunnel isn't connected, the name doesn't match, or it belongs to another org. |
| Dial-in fails with a TLS error against a self-hosted relay | set MCPG_TUNNEL_CA to the relay's CA PEM. |
See also
- MCP federation — the consuming side of
tunnel://; all of it applies. - Editions and pricing — the managed relay, quota, and billing.
- Configuration reference — the full gateway config surface.
- Identity setup — the inbound auth chain a tunnelled gateway still enforces.