# Gateway tunnels

Put MCP servers you run yourself behind approvals without exposing them or sharing their credentials.

Many MCP servers have no hosted address: you run them yourself, as a command such as `npx` or `docker run`, or on your private network. A **tunnel** lets the [MCP Gateway](/docs/web-app/mcp-gateway) use them. It runs inside your network, connects out to withHuman, and serves the servers you list in its configuration. Every call still goes through your [approval pipeline](/docs/web-app/approval-pipelines) first.

Nothing on your side is exposed. The tunnel opens one outbound HTTPS connection to withHuman and keeps it open; withHuman sends approved calls back down it. The servers' credentials stay in the tunnel's environment and are never sent to withHuman.

```mermaid
flowchart LR
    agent["Agent"] -->|Tool call| gateway["withHuman gateway<br/>Approval pipeline"]
    tunnel["Tunnel<br/>in your network"] -->|Connects out| gateway
    gateway -.->|Approved calls| tunnel
    tunnel --> server["Your MCP server"]
```

Tunnels are part of the hosted edition.

## Set up a tunnel

1. On the **Gateway** page, choose **Tunnels**, then **Set up a tunnel**.
2. Name it after where it runs, such as a cluster. withHuman shows its token once. Copy it.
3. Write a `tunnel.yaml` listing the servers it serves.
4. Run the tunnel where those servers can run. The page shows when it connects.

```yaml
servers:
  terraform_read:
    command: ["terraform-mcp-server", "stdio"]
    env:
      TFE_TOKEN: ${TFE_READ_TOKEN}
  terraform_write:
    command: ["terraform-mcp-server", "stdio"]
    env:
      TFE_TOKEN: ${TFE_WRITE_TOKEN}
  billing:
    url: http://billing-mcp.internal:8080/mcp
    headers:
      Authorization: Bearer ${BILLING_MCP_TOKEN}
```

Each server has a name, then either a `command` the tunnel starts or a `url` it reaches on your network. `${NAME}` is read from the tunnel's environment, so keep secrets in your usual secret store. The tunnel refuses to start if a variable it needs is not set.

A command server gets only its own `env` plus basics such as `PATH`, `HOME` and proxy settings. It never sees the tunnel's token or another server's secrets.

Run it with Docker, giving it the token, the servers' secrets and the file:

```bash
docker run -d --name withhuman-tunnel --restart unless-stopped -e WITHHUMAN_TUNNEL_TOKEN -e TFE_READ_TOKEN -e TFE_WRITE_TOKEN -v "$PWD/tunnel.yaml:/etc/withhuman/tunnel.yaml:ro" quay.io/withhuman/tunnel:latest
```

The tunnel's page shows a Kubernetes manifest too. Run two copies to keep serving while one restarts.

The image includes Node and `uv`, so `npx` and `uvx` commands work as documented. A server that ships its own program, such as `terraform-mcp-server` above, needs adding to the image:

```dockerfile
FROM quay.io/withhuman/tunnel:latest
COPY terraform-mcp-server /usr/local/bin/
```

Or run the server in its own container on the same network and list it by `url`.

Check that every server starts and lists its tools before you connect:

```bash
docker run --rm -e TFE_READ_TOKEN -e TFE_WRITE_TOKEN -v "$PWD/tunnel.yaml:/etc/withhuman/tunnel.yaml:ro" quay.io/withhuman/tunnel:latest check
```

## Register a server through it

Register a server on the **Gateway** page as usual, and choose **Through a tunnel**. Pick the tunnel, then give each scope the name of the tunnel server it calls. Scopes through a tunnel store no credential in withHuman: the tunnel holds it.

To let reads through and send writes to a person, give two scopes servers with different tokens. For example, `read` calls `terraform_read` and `write` calls `terraform_write`. The service then enforces the difference, and your pipeline can match `withhuman_scope` as on any other server. See [Scopes](/docs/web-app/mcp-gateway#scopes).

Everything else about the server works as on a server reached at a URL: tool access, activation and revisions.

## What to expect

- **While the tunnel is offline**, calls through it fail straight away with a message that the tunnel is offline. No approval is requested. Its tools leave agents' tool lists until it reconnects.
- **When withHuman restarts**, tunnels reconnect on their own within seconds.
- **Each call** records which copy of the tunnel carried it, in the [Audit log](/docs/web-app/audit-log).
- **The tunnel only runs what its configuration lists.** withHuman can ask for a server by name, and nothing else.

## Status and tokens

A tunnel's page shows whether it is online, what its live copies serve, the gateway servers that go through it, and recent connections with their host and version.

To rotate a token, create a new one, update the tunnel, then revoke the old one. Revoking a token disconnects copies using it within a minute. **Archive tunnel** revokes every token for good. A tunnel an active server still uses can't be archived: deactivate the server or point it elsewhere first.

## Troubleshooting

| What you see | What to do |
| --- | --- |
| The tunnel stops with "the gateway refused the tunnel token" | The token was revoked, mistyped, or its tunnel archived. Create a new token on the tunnel's page. |
| The tunnel stops asking you to upgrade | Pull the latest `quay.io/withhuman/tunnel` image. |
| A scope shows **Not served** | The tunnel's configuration has no server by that name. Add it, or fix the name on the scope. |
| A server's tools don't list | Run `check` where the tunnel runs, and read the tunnel's logs for that server. |
| The tunnel can't connect | It needs outbound HTTPS to `app.withhuman.ai`. If your network uses a proxy, set `HTTPS_PROXY`. |
