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
jittered backoff 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: "identity.has_group('acme')"
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 | (relay allocates) | Stable tunnel name → stable public sub-domain. |
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 — existing tunnels are never killed. Relayed bytes are metered as fair-use overage, not a primary meter.
See editions and pricing for the managed relay, quota enforcement, and billing.
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). |
MCPG_RELAY_IP_RATE / _IP_BURST / _TUNNEL_RATE / _TUNNEL_BURST | 50 / 100 / 200 / 400 | Edge rate limits (per-IP and per-tunnel token buckets). |
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). - 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. |
Private tunnel refused (tunnels_private_not_licensed) | --private needs Team or above. |
e2ee tunnel refused (tunnels_e2ee_not_licensed) | --tunnel-mode e2ee needs Enterprise. |
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.