Gateway
Gatewaybeta6 min

Serve through ngrok

Give a gateway a public HTTPS URL with the ngrok transport plugin — no inbound port, no public IP — while identity, OAuth discovery, rate limits and SSE still run through the gateway's own HTTP router.

The ngrok transport plugin (dev.mcpg.transport.ngrok) serves the gateway at an ngrok endpoint. The plugin dials out to ngrok, so the machine needs no inbound port, no public IP and no firewall rule. A gateway on a laptop, behind NAT or in a private network gets a public HTTPS URL that claude.ai, ChatGPT or any remote MCP client can reach.

The plugin does not parse MCP. It hands every HTTP request to the gateway's own HTTP router, so a request through ngrok gets the same treatment as one on the gateway's HTTP listener:

  • Identity and OAuth discovery. The Host header is the ngrok domain, so protected-resource metadata names the URL the client used.
  • Per-IP rate limits, keyed on the client address that ngrok reports.
  • SSE streams, frame by frame.
  • Both MCP revisions, 2025-11-25 and 2026-07-28.

Quick start

  1. Get an authtoken from the ngrok dashboard and export it:

    bash
    export NGROK_AUTHTOKEN=<your token>
    
  2. Load the plugin and add it to gateway.server.transports:

    yaml
    plugins:
      - id: dev.mcpg.transport.ngrok
        kind: native
        class: transport
        source:
          oci: ghcr.io/mcpg-dev/plugins/transport-ngrok
        granted_capabilities:
          - network_outbound
          - transport_listen
    
    gateway:
      server:
        transports:
          - kind: dev.mcpg.transport.ngrok
            config:
              authtoken: ${env.NGROK_AUTHTOKEN}
    
  3. Start the gateway. The log names the endpoint:

    text
    extra transport started kind=dev.mcpg.transport.ngrok wire=http listen=https://example-1234.ngrok-free.app
    

    Give the client https://example-1234.ngrok-free.app/mcp.

The gateway's own HTTP listener stays on. Bind it to 127.0.0.1 when you want ngrok to be the only way in.

A stable domain

Without domain, ngrok assigns the account's dev domain. To keep the URL that clients store, reserve a domain in the ngrok dashboard and name it:

yaml
config:
  authtoken: ${env.NGROK_AUTHTOKEN}
  domain: tools.example.ngrok.app

Filter at ngrok's edge

An ngrok Traffic Policy runs at ngrok's edge, before a request reaches the gateway. It is an additional layer, not a replacement for the gateway's own identity and policy. This policy lets through only Anthropic's API network:

yaml
config:
  authtoken: ${env.NGROK_AUTHTOKEN}
  domain: tools.example.ngrok.app
  traffic_policy: |
    on_http_request:
      - expressions: ["!('com.anthropic.api' in conn.client_ip.categories)"]
        actions: [{ type: deny, config: { status_code: 403 } }]

allow_cidrs and deny_cidrs set source-address lists without a policy.

Keep the endpoint private

With binding: internal and a domain that ends in .internal, the endpoint has no public address. Only an ngrok cloud endpoint whose policy uses forward-internal reaches it, so you can put the public URL, the policy and the rate limits on the cloud endpoint and keep the gateway itself unlisted.

yaml
config:
  authtoken: ${env.NGROK_AUTHTOKEN}
  domain: tools.internal
  binding: internal

Several gateways on one domain

With pooling: true, several gateways serve the same domain and ngrok balances the requests between them. A client's requests can then reach any gateway in the pool. On the 2025-11-25 wire an MCP session lives on one gateway, so run the gateways in cluster mode with a shared session store, or use clients that speak the stateless 2026-07-28 revision.

Configuration

FieldDefaultDescription
authtoken(required)The ngrok authtoken, from ${env.NGROK_AUTHTOKEN}.
domainthe dev domainA reserved domain, or a .internal name with binding: internal.
traffic_policynoneAn ngrok Traffic Policy: a YAML or JSON string, or the policy as a map.
bindingngrok's defaultpublic, internal or kubernetes.
poolingfalseLet several gateways serve the same domain.
metadatanoneEndpoint metadata for the ngrok dashboard and API.
forwards_tomcpg gatewayThe label in the ngrok dashboard.
allow_cidrs, deny_cidrs[]Source CIDRs that ngrok lets through or refuses.
server_addrngrok's defaultThe ngrok session server, host:port.
connect_timeout_secs30How long boot waits for the session and the endpoint.
max_request_body_bytes4194304The largest request body; a larger one gets HTTP 413.

When the transports entry has no config:, the plugin uses the config: of its plugins[] entry.

Behaviour to know

  • Boot fails on a bad endpoint. If the session or the endpoint does not open within connect_timeout_secs, the gateway does not start, and the error names the cause (a refused authtoken, a domain that the account has not reserved).
  • Reconnects are automatic. After a network failure the ngrok session reconnects and binds the endpoint again.
  • The URL is the public address. The endpoint URL is the transport's listen address. Unless gateway.server.canonical_url is set, the gateway also uses it as the base URL for webhook callbacks from upstreams.
  • ngrok sees plaintext. ngrok terminates TLS at its edge. For a relay that sees only ciphertext, run your own reverse tunnel relay.
  • legacy_wire: false works. The plugin goes through the HTTP router, so it follows gateway.server.legacy_wire like the HTTP listener.

For callers inside a private network, the Tailscale transport also authenticates every caller by their tailnet identity, with no token.

Other tunnels as plugins

ngrok is one http-wire transport. Any transport plugin that brings HTTP requests from somewhere the gateway does not listen — another tunnel service, a relay, a socket the plugin owns — can declare the http wire and hand each request to the gateway the same way. The plugin SDK's http_ingress module and SyncTransport::start_http are the plugin side of that contract.