Gateway
Gatewaybeta13 min

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

  1. Dev preview. Run mcpg --tunnel and 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.
  2. Private deploy, public reach. Run a gateway deep inside a network and expose exactly one MCP endpoint through the tunnel — outbound-443 only, nothing listening on the box.
  3. 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.

text
  ┌──────────────── 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?

ExposurePublic URLReachable bySet with
public (default)https://<id>.tunnels.mcpg.cloud/mcpany MCP client on the internet(default)
privatenone — no public hostname, no public TLS listeneronly same-org gateways, as a tunnel://<name> federation upstream--private

Trust — what can the relay see?

ModeRelay seesRequiresSet with
relay_terminated (default)plaintext (it terminates client TLS, like ngrok / a CDN)—(default)
e2eeintended: ciphertext only, relay blindprivate 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_terminated at 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 on e2ee mode. 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:

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

bash
mcpg --config gw.yaml --tunnel wss://relay.example.com:7845

The gateway dials the relay and logs its public URL:

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

bash
mcpg --config gw.yaml --tunnel --tunnel-name demo
# → https://demo.tunnels.mcpg.cloud/mcp

Equivalently, in config instead of flags:

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

text
  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):

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

yaml
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 the X-MCPG-Tunnel-Token header. The relay consumes it and never forwards it down the tunnel.
  • The end user's Authorization bearer flows through untouched to the tunnelled gateway, where it's the MCP caller identity that minimum_trust and allow_if evaluate. 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.

FieldTypeDefaultMeaning
enabledboolfalseTurn the tunnel on. --tunnel sets this.
relay_urlstringwss://relay.tunnels.mcpg.cloudRelay agent-accept endpoint (wss://).
namestring(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.
exposurepublic | privatepublicprivate = no public URL; federation-only.
moderelay_terminated | e2eerelay_terminatedTrust 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.

FieldTypeDefaultMeaning
relay_ingress_urlstring(required)Relay federation-ingress base URL (http(s)://).
tokenstringMCPG_TUNNEL_TOKEN envOrg 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

FlagEffect
--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).
--privatePrivate 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 varPurpose
MCPG_TUNNEL_TOKENOrg token presented to the relay on dial-in / federation.
MCPG_TUNNEL_CAPath 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.

PlanConcurrent tunnelsPrivate (reverse federation)e2ee
Community (free)1——
Pro3——
Team10✓—
Enterpriseunlimited✓✓
  • 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 --private tunnel 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 varDefaultPurpose
MCPG_RELAY_AGENT_ADDR0.0.0.0:7845Agent-accept listener (gateways dial in).
MCPG_RELAY_PUBLIC_ADDR0.0.0.0:8080Public MCP listener (host-routed).
MCPG_RELAY_FEDERATION_ADDR0.0.0.0:8081Federation ingress (/federate/<name>/…).
MCPG_RELAY_OPS_ADDR0.0.0.0:9090/healthz, /readyz, /metrics.
MCPG_RELAY_ZONEtunnels.mcpg.cloudDNS 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_SECS30Wait 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_BURST50 / 100 / 200 / 400Edge rate limits (per-IP and per-tunnel token buckets).
MCPG_RELAY_THROTTLE_BYTES_PER_SEC65536Bytes 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 private tunnel 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 Authorization flows 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_terminated mode 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

SymptomLikely cause
Gateway logs "no relay authentication" / refuses to establishMCPG_TUNNEL_TOKEN unset or not accepted by the relay.
tunnel_quota_exceeded on dial-inat your plan's concurrent-tunnel cap — close one or upgrade.
Tunnel traffic slows down sharply, but the tunnel stays upyour 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 closesyour 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-inanother org holds that public name — pick another --tunnel-name.
tunnel_name_in_use on dial-inanother 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 unauthorizedthe org token it dialed in with was revoked or expired — issue a new one.
tunnel_name_invalid on dial-ina 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 closethe org is suspended — settle billing to reconnect.
server.tunnel: e2ee mode requires private exposure at bootpair --tunnel-mode e2ee with --private (it forces it, but explicit config must match).
tunnel:// upstream but … tunnel_federation … is not configuredadd gateway.server.tunnel_federation.relay_ingress_url on the federating gateway.
a tunnel:// upstream needs a namewrite tunnel://<name>/<path>, not tunnel:///….
Federated tunnel:// tools missing / 404 on reachthe 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 relayset MCPG_TUNNEL_CA to the relay's CA PEM.

See also