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
Hostheader 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-25and2026-07-28.
Quick start
-
Get an authtoken from the ngrok dashboard and export it:
bashexport NGROK_AUTHTOKEN=<your token> -
Load the plugin and add it to
gateway.server.transports:yamlplugins: - 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} -
Start the gateway. The log names the endpoint:
textextra transport started kind=dev.mcpg.transport.ngrok wire=http listen=https://example-1234.ngrok-free.appGive 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:
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:
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.
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
| Field | Default | Description |
|---|---|---|
authtoken | (required) | The ngrok authtoken, from ${env.NGROK_AUTHTOKEN}. |
domain | the dev domain | A reserved domain, or a .internal name with binding: internal. |
traffic_policy | none | An ngrok Traffic Policy: a YAML or JSON string, or the policy as a map. |
binding | ngrok's default | public, internal or kubernetes. |
pooling | false | Let several gateways serve the same domain. |
metadata | none | Endpoint metadata for the ngrok dashboard and API. |
forwards_to | mcpg gateway | The label in the ngrok dashboard. |
allow_cidrs, deny_cidrs | [] | Source CIDRs that ngrok lets through or refuses. |
server_addr | ngrok's default | The ngrok session server, host:port. |
connect_timeout_secs | 30 | How long boot waits for the session and the endpoint. |
max_request_body_bytes | 4194304 | The 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_urlis 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: falseworks. The plugin goes through the HTTP router, so it followsgateway.server.legacy_wirelike 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.