# Configuration

All configuration can be done via environment variables. Copy `.env.example` to `.env` for local development.

## Environment Variables

### OpenClaw Connection

| Variable                 | Description                             | Default                  |
| ------------------------ | --------------------------------------- | ------------------------ |
| `OPENCLAW_URL`           | OpenClaw gateway URL                    | `http://127.0.0.1:18789` |
| `OPENCLAW_GATEWAY_TOKEN` | Bearer token for gateway authentication | (none)                   |
| `OPENCLAW_AGENT_ID`      | Named agent to route requests to        | (none)                   |
| `OPENCLAW_TIMEOUT_MS`    | Request timeout in milliseconds         | `120000` (2 min)         |
| `OPENCLAW_MODEL`         | Model name for chat completions         | `openclaw`               |

`OPENCLAW_AGENT_ID` is sent as the `x-openclaw-agent-id` header. When unset, the gateway uses its default agent.

> **Note:** An unrecognized `OPENCLAW_AGENT_ID` is rejected by the gateway with `400 Bad Request` (`"Unknown agent '<id>'."`) — it does **not** silently fall back to the default agent. A typo will surface as a loud error, not a silent misroute.

### Multi-Instance Mode

Orchestrate multiple OpenClaw gateways from a single MCP server. Set `OPENCLAW_INSTANCES` as a JSON array — when present, it takes precedence over `OPENCLAW_URL` / `OPENCLAW_GATEWAY_TOKEN`.

```
                         ┌─────────────────┐
                         │  Claude Client   │
                         └────────┬────────┘
                                  │
                    ┌─────────────▼─────────────┐
                    │   OpenClaw MCP Bridge      │
                    │                            │
                    │   instance="prod"  ──────────────► OpenClaw GW (prod)
                    │   instance="staging" ────────────► OpenClaw GW (staging)
                    │   instance="dev"  ───────────────► OpenClaw GW (dev)
                    │   (no instance)   ──► default ──► OpenClaw GW (prod)
                    └────────────────────────────┘
```

| Variable             | Description                    | Default                       |
| -------------------- | ------------------------------ | ----------------------------- |
| `OPENCLAW_INSTANCES` | JSON array of instance configs | (none — single-instance mode) |

**Example:**

```bash
OPENCLAW_INSTANCES='[
  {"name": "prod", "url": "http://prod:18789", "token": "tok1", "default": true},
  {"name": "staging", "url": "http://staging:18789", "token": "tok2"},
  {"name": "dev", "url": "http://dev:18789", "token": "tok3"}
]'
```

Each instance object supports:

| Field     | Type    | Required | Description                                                              |
| --------- | ------- | -------- | ------------------------------------------------------------------------ |
| `name`    | string  | Yes      | Unique instance name (1-64 chars, alphanumeric/dashes/underscores)       |
| `url`     | string  | Yes      | OpenClaw gateway URL (http or https only)                                |
| `token`   | string  | No       | Bearer token for gateway authentication                                  |
| `agentId` | string  | No       | Named agent to route requests to on this instance's gateway              |
| `timeout` | number  | No       | Request timeout in ms (inherits global `OPENCLAW_TIMEOUT_MS` if omitted) |
| `default` | boolean | No       | Mark as the default instance (first instance is default if none marked)  |

`agentId` is sent as the `x-openclaw-agent-id` header. When omitted, the gateway uses its default agent. An unrecognized value is rejected with `400 Bad Request`, not silently routed to the default.

**Using instances in tools:**

All gateway-facing tools (`openclaw_chat`, `openclaw_status`, `openclaw_chat_async`) accept an optional `instance` parameter. When omitted, the default instance is used.

```
# Target a specific instance
openclaw_chat message="Hello" instance="staging"

# Check health of a specific gateway
openclaw_status instance="prod"

# List all available instances (names, URLs, default — tokens are never exposed)
openclaw_instances

# Async tasks also support instance targeting
openclaw_chat_async message="Run migration" instance="dev"

# Filter task list by instance
openclaw_task_list instance="staging"
```

**How instance resolution works:**

1. If `instance` parameter is provided → use that instance
2. If `instance` is omitted → use the instance marked as `default`
3. If no instance is marked as default → the first instance in the array is used

Each instance gets its own isolated HTTP client with independent auth token, timeout, and base URL. Async tasks store the target instance ID so results are always routed correctly.

**Docker Compose with multi-instance:**

```yaml
services:
  mcp-bridge:
    image: ghcr.io/freema/openclaw-mcp:latest
    environment:
      - OPENCLAW_INSTANCES=[{"name":"prod","url":"http://prod-gw:18789","token":"tok1","default":true},{"name":"staging","url":"http://staging-gw:18789","token":"tok2"}]
      - MCP_CLIENT_ID=openclaw
      - MCP_CLIENT_SECRET=${MCP_CLIENT_SECRET}
```

**Backward compatibility:** When `OPENCLAW_INSTANCES` is not set, the server creates a single `"default"` instance from `OPENCLAW_URL` + `OPENCLAW_GATEWAY_TOKEN`. Existing deployments work without any configuration change — zero migration required.

### Server Settings (HTTP transport)

| Variable      | Description                                                       | Default                  |
| ------------- | ----------------------------------------------------------------- | ------------------------ |
| `PORT`        | HTTP server port                                                  | `3000`                   |
| `HOST`        | HTTP server host                                                  | `0.0.0.0`                |
| `ALLOWED_HOSTS` | Extra hostnames accepted by DNS-rebinding protection            | (unset)                  |
| `DEBUG`       | Enable debug logging                                              | `false`                  |
| `TRUST_PROXY` | Express `trust proxy` setting (required behind a reverse proxy)   | (unset — trust nothing)  |

**`TRUST_PROXY` values:**

| Value          | Meaning                                                                        |
| -------------- | ------------------------------------------------------------------------------ |
| (unset)        | Express default — do not trust any proxy                                       |
| `1`            | Trust one hop — typical when there is a single reverse proxy in front          |
| `true`         | Trust every proxy — only safe on a fully private network                       |
| `loopback`     | Trust `127.0.0.1` and `::1`                                                    |
| `10.0.0.0/8`   | Trust a specific IP or CIDR range                                              |

When `TRUST_PROXY` is unset and a reverse proxy injects an `X-Forwarded-For` header, the MCP SDK's `/token` handler crashes with `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` and OAuth fails. Set this whenever the server is behind Caddy, nginx, Traefik, Cloudflare Tunnel, ngrok, or any other proxy.

**`ALLOWED_HOSTS`:**

When the server binds to a loopback address (`127.0.0.1`, `localhost`, `::1`), DNS-rebinding protection rejects any request whose `Host` header is not loopback with `403 Invalid Host`. That breaks the bare-metal pattern of a loopback bind fronted by a reverse proxy that preserves the public `Host` header (Tailscale Serve, nginx with `proxy_set_header Host $host`) — and the alternative, binding to `0.0.0.0`, would expose the port on every interface.

Set `ALLOWED_HOSTS` to a comma-separated list of extra hostnames to accept (hostnames only — no scheme or port, IPv6 in brackets, case-insensitive). The loopback set stays accepted and protection stays enabled; on a non-loopback bind, setting `ALLOWED_HOSTS` turns Host validation on where it would otherwise be off.

```bash
HOST=127.0.0.1 ALLOWED_HOSTS=machine.tail-net.ts.net openclaw-mcp --transport http
```

### CORS Configuration

| Variable       | Description                       | Default            |
| -------------- | --------------------------------- | ------------------ |
| `CORS_ORIGINS` | Allowed origins (comma-separated) | _(CORS disabled)_  |

CORS is **off unless you opt in**. With it off the server sends no
`Access-Control-Allow-Origin` header, so browsers refuse cross-origin calls to
it. Non-browser clients — Claude.ai, Claude Desktop, and anything speaking
stdio — are unaffected; set this only when a web page has to reach the server
directly.

**CORS_ORIGINS examples:**

- _(unset)_ or `none` — CORS disabled (default)
- `https://claude.ai` — Single origin
- `https://claude.ai,https://your-app.com` — Multiple origins
- `*.example.com` — Wildcard subdomain
- `*` — Allow all origins. Lets any web page drive this server through a
  visitor's browser; do not use in production.

### Authentication (OAuth 2.1)

The server uses the MCP SDK's built-in OAuth 2.1 server with authorization code + PKCE flow. This is what Claude.ai requires for custom MCP connectors.

OAuth is **mandatory** for the HTTP transport since 1.8.0: without `MCP_CLIENT_ID` and `MCP_CLIENT_SECRET` the server refuses to start. `AUTH_ENABLED=true` is still accepted but no longer needed; `AUTH_ENABLED=false` refuses to start. The only way to run without authentication is `MCP_DANGEROUSLY_DISABLE_AUTH=true` (`--dangerously-disable-auth`), e.g. when a proxy in front already authenticates every request. stdio is unaffected.

**Async task ownership:** tasks created with `openclaw_chat_async` belong to the authenticated OAuth client, so a client that opens a new MCP session between tool calls can still poll them. Each dynamically registered client is a separate owner. Without auth (stdio, or `MCP_DANGEROUSLY_DISABLE_AUTH`) tasks are scoped to the MCP connection.

| Variable            | Description                                                 | Required                   |
| ------------------- | ----------------------------------------------------------- | -------------------------- |
| `MCP_CLIENT_ID`     | OAuth client ID (e.g., `openclaw`)                          | Yes (HTTP transport)       |
| `MCP_CLIENT_SECRET` | OAuth client secret                                         | Yes (HTTP transport)       |
| `MCP_DANGEROUSLY_DISABLE_AUTH` | Run the HTTP transport without OAuth (`true`/`false`) | Never, unless a proxy in front authenticates |
| `MCP_ISSUER_URL`    | OAuth issuer URL override (e.g., `https://mcp.example.com`) | When behind HTTPS proxy    |
| `MCP_REDIRECT_URIS` | Allowed redirect URIs (comma-separated)                     | Recommended for production |
| `MCP_DANGEROUSLY_ALLOW_DCR`        | Enable Dynamic Client Registration (`true`/`false`)         | Dev only (see below)       |
| `MCP_DANGEROUSLY_ALLOW_DCR_PUBLIC` | Escape hatch to allow DCR on non-loopback binds             | Never, unless you mean it  |

**Client ID validation rules:**

- 3-64 characters
- Alphanumeric, dashes, underscores only
- Must start with a letter or digit

**Client Secret requirements:**

- Minimum 32 characters
- Generate a secure one: `openssl rand -hex 32`

When auth is enabled, the server exposes these OAuth 2.1 endpoints:

- `GET /.well-known/oauth-authorization-server` — OAuth server metadata
- `GET /.well-known/oauth-protected-resource/mcp` — Protected resource metadata
- `GET /authorize` — Authorization endpoint (auto-approves for pre-configured client)
- `POST /token` — Token exchange (requires client_secret)
- `POST /revoke` — Token revocation

Dynamic client registration is **disabled by default** — only the pre-configured client (from `MCP_CLIENT_ID` + `MCP_CLIENT_SECRET`) can authenticate. This prevents anyone who knows the server URL from self-registering and bypassing auth.

#### Redirect URI allow-list

`MCP_REDIRECT_URIS` restricts which `redirect_uri` values the `/authorize` endpoint accepts for the pre-configured client. When unset, any redirect URI is accepted (the client secret verified during token exchange remains the actual auth gate) — set it in production so authorization codes can only be delivered to callbacks you trust.

Claude.ai uses exactly this callback (note the `/api/mcp/auth_callback` path):

```bash
MCP_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,https://claude.com/api/mcp/auth_callback
```

Matching is exact (scheme, host, path). A common mistake is registering `https://claude.ai/oauth/callback`, which does **not** match and makes Claude.ai fail with `Unregistered redirect_uri`. If you also connect other clients (e.g. MCP Inspector), append their callbacks to the comma-separated list — loopback callbacks (`http://localhost/...`, `http://127.0.0.1/...`) match on any port per RFC 8252.

#### Cursor / Windsurf compatibility (dev only)

Cursor and Windsurf only support MCP servers that expose OAuth 2.0 Dynamic Client Registration (RFC 7591). To let them connect, set `MCP_DANGEROUSLY_ALLOW_DCR=true`. The server will then advertise a `/register` endpoint and accept ad-hoc client registrations (kept in an in-memory FIFO store, capped at 100 entries).

`MCP_CLIENT_ID` and `MCP_CLIENT_SECRET` are still required — DCR augments the pre-configured client, it does not replace it. If you are only running Cursor locally you can use any valid values for them; they simply remain unused.

**This is dev-only.** With DCR enabled alongside the server's auto-approve authorization flow, any client that can reach the server can register itself and obtain a token. To prevent accidental exposure the server refuses to start when `MCP_DANGEROUSLY_ALLOW_DCR=true` and `HOST` is not loopback (`127.0.0.1`, `localhost`, or `::1`). If you genuinely need DCR on a non-loopback bind (e.g., inside a trusted private network), also set `MCP_DANGEROUSLY_ALLOW_DCR_PUBLIC=true`.

For production with Claude.ai, keep DCR disabled and use the pre-configured `MCP_CLIENT_ID` / `MCP_CLIENT_SECRET`.
