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 jittered backoff 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: "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 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(relay allocates)Stable tunnel name → stable public sub-domain.
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 — 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 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_TOKENSStatic grants token:org[,token:org…].
MCPG_RELAY_CP_URL / MCPG_RELAY_CP_TOKENControl-plane URL + bearer for entitlement-aware auth (plan/quota/suspension).
MCPG_RELAY_TLS_CERT / MCPG_RELAY_TLS_KEYAgent-accept TLS (wildcard cert for *.<zone>).
MCPG_RELAY_FORWARD_TIMEOUT_SECS30Wait for a response head before 504 (the SSE body is unbounded).
MCPG_RELAY_IP_RATE / _IP_BURST / _TUNNEL_RATE / _TUNNEL_BURST50 / 100 / 200 / 400Edge 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 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).
  • 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.
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 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