Skip to main content
Glama
README.md
# Camoufox Canary MCP

An experimental, single-container MCP service for creating isolated Camoufox
browser sessions and connecting Playwright to them through one published port.
It is intended for a trusted HomeLAN with fewer than ten users.

The service exposes:

- Streamable HTTP MCP at `http://HOST:8000/mcp`
- proxied browser WebSockets at `ws://HOST:8000/browser/SESSION_ID`
- an unauthenticated health check at `http://HOST:8000/health`

## Quickstart

Docker Compose is the shortest supported path. Port 8000 is intentionally fixed
in Compose so the published mapping, healthcheck, and service listener agree.

Copy the fully commented example configuration, then edit at least the public
URL for access from another machine. Setting a bearer token is recommended:

```sh
cp .env.example .env
${EDITOR:-vi} .env
```

```sh
docker compose up --build --detach --wait
curl --fail http://127.0.0.1:8000/health
```

The expected health response is `{"status":"ok"}`. Stop and remove the service
with:

```sh
docker compose down
```

To build the image used by the end-to-end test:

```sh
docker build -t camoufox-canary-mcp:test .
```

For another machine on the LAN, set the public URL to the address its clients
can reach before starting Compose:

```sh
CAMOUFOX_MCP_PUBLIC_URL=ws://192.168.1.20:8000 docker compose up --build --detach --wait
```

The image runs as the unprivileged `app` user (UID/GID `10001`) and publishes
only the MCP service port. Browser ports 9100–9109 remain private inside the
container.

## Manual Docker deployment

Build the image and create a local environment file from the documented
template:

```sh
docker build -t camoufox-canary-mcp:local .
cp .env.example .env
${EDITOR:-vi} .env
```

In `.env`, replace `localhost` in `CAMOUFOX_MCP_PUBLIC_URL` with the Docker
host address reachable by MCP clients, for example `192.168.1.20`.
Start the container with an init process and 2 GiB of shared memory:

```sh
docker run --detach \
  --name camoufox-canary-mcp \
  --init \
  --shm-size=2g \
  --publish 8000:8000 \
  --env-file .env \
  --health-cmd="python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)\"" \
  --health-interval=30s \
  --health-timeout=5s \
  --health-retries=3 \
  camoufox-canary-mcp:local
```

Check startup, health, and logs:

```sh
docker ps --filter name=camoufox-canary-mcp
curl --fail http://127.0.0.1:8000/health
docker logs --follow camoufox-canary-mcp
```

Stop and remove the manual deployment with:

```sh
docker stop camoufox-canary-mcp
docker rm camoufox-canary-mcp
```

To publish a different host port, change only the left side of the mapping,
for example `--publish 18000:8000`, and set the public URL to
`ws://HOST:18000`. Keep `CAMOUFOX_MCP_LISTEN_PORT=8000` inside the container.
Do not publish the private browser port range.

## Configuration

All variables are optional. Compose deliberately does not pass through
`CAMOUFOX_MCP_LISTEN_PORT`; its container-side value must remain 8000.
See [`.env.example`](.env.example) for a copy-ready configuration containing
every variable, valid values, examples, and operational notes.

| Variable | Default | Valid values and meaning |
| --- | --- | --- |
| `CAMOUFOX_MCP_LISTEN_HOST` | `0.0.0.0` | Non-empty Uvicorn bind address. |
| `CAMOUFOX_MCP_LISTEN_PORT` | `8000` | Integer `1–65535`. Keep `8000` inside this image and with Compose. |
| `CAMOUFOX_MCP_PUBLIC_URL` | `ws://localhost:8000` | Client-reachable `ws://` or `wss://` base placed in session results. A trailing slash is stripped. |
| `CAMOUFOX_MCP_AUTH_TOKEN` | unset | Non-blank optional bearer token protecting MCP and browser WebSocket requests. `/health` stays open. |
| `CAMOUFOX_MCP_MAX_SESSIONS` | `4` | Positive integer maximum for tracked browser sessions. Effective capacity cannot exceed the private port count. |
| `CAMOUFOX_MCP_IDLE_TIMEOUT_SECONDS` | `900` | Positive integer seconds since the last proxied WebSocket traffic before cleanup. |
| `CAMOUFOX_MCP_STARTUP_TIMEOUT_SECONDS` | `30` | Positive integer seconds allowed for the private browser server to bind. |
| `CAMOUFOX_MCP_CLEANUP_INTERVAL_SECONDS` | `30` | Positive integer seconds between idle-session cleanup passes. |
| `CAMOUFOX_MCP_PORT_START` | `9100` | Integer `1–65535`; first private browser port. |
| `CAMOUFOX_MCP_PORT_END` | `9109` | Integer `1–65535`; last private browser port, greater than or equal to `PORT_START`. |
| `CAMOUFOX_MCP_HEADLESS` | `virtual` | Exactly `virtual` (per-session Xvfb) or `native` (Firefox native headless). |
| `CAMOUFOX_MCP_ALLOWED_HOSTS` | `*` | Comma-separated MCP `Host` values. `*` disables SDK DNS-rebinding protection; use explicit client-visible hosts in less-trusted networks. |
| `CAMOUFOX_MCP_ALLOWED_ORIGINS` | empty | Comma-separated MCP `Origin` values allowed when DNS-rebinding protection is enabled. |

The private port range is inclusive. Configure at least as many ports as the
desired session count; otherwise the port range becomes the effective limit.
All integer settings reject zero, negative, and non-integer values. Port values
outside `1–65535` are also rejected. Unknown environment variables are ignored.

Example authenticated Compose start:

```sh
CAMOUFOX_MCP_AUTH_TOKEN='replace-this-token' \
CAMOUFOX_MCP_PUBLIC_URL='ws://192.168.1.20:8000' \
docker compose up --build --detach --wait
```

Send `Authorization: Bearer replace-this-token` on both MCP HTTP requests and
the Playwright WebSocket connection. The token and any proxy credentials are
never returned in session metadata.

## MCP tools

`create_session` accepts this input shape; every field is optional:

```json
{
  "proxy_server": "http://proxy.example:8080",
  "proxy_username": "user",
  "proxy_password": "password",
  "os": "linux",
  "locale": "en-AU",
  "timezone": "Australia/Sydney",
  "screen": {
    "min_width": 1280,
    "max_width": 1920,
    "min_height": 720,
    "max_height": 1080
  }
}
```

- `proxy_server` accepts `http`, `https`, or `socks5`. Username and password
  must be supplied together and separately from the URL.
- `os` is `windows`, `macos`, or `linux`.
- `locale` must be a registered language tag such as `en-AU`, `en-US`, or
  `de-DE`.
- `timezone` must be an IANA timezone available in the image, such as
  `Australia/Sydney`, `America/New_York`, or `Etc/UTC`.
- Every screen bound is a positive integer; each minimum must not exceed its
  maximum.
- Unknown fields are rejected. Supplying a proxy username without a password,
  or vice versa, is rejected. Proxy credentials embedded in the URL are also
  rejected; use the separate credential fields.

It returns `{id, websocket_url, created_at, expires_at, request}`. The other
tool schemas are:

| Tool | Input | Result |
| --- | --- | --- |
| `list_sessions` | `{}` | `{"result": [session, ...]}` |
| `get_session` | `{"session_id": "..."}` | one session object |
| `close_session` | `{"session_id": "..."}` | `{"id": "...", "closed": true}` |

## Connect MCP clients

Replace `192.168.1.20` with the Docker host address reachable from the client.
When `CAMOUFOX_MCP_AUTH_TOKEN` is set on the server, export the same value in
the shell that starts your MCP client:

```sh
export CAMOUFOX_MCP_AUTH_TOKEN='replace-this-token'
```

Do not commit the real token. Omit the authorization setting entirely when
server authentication is disabled. Use an `https://` MCP URL when TLS is
terminated by a reverse proxy.

### Claude Code

Add the server for the current project without authentication:

```sh
claude mcp add --transport http --scope project camoufox \
  http://192.168.1.20:8000/mcp
```

For bearer authentication, create `.mcp.json` in the project root. Claude Code
expands `${CAMOUFOX_MCP_AUTH_TOKEN}` from its environment:

```json
{
  "mcpServers": {
    "camoufox": {
      "type": "http",
      "url": "http://192.168.1.20:8000/mcp",
      "headers": {
        "Authorization": "Bearer ${CAMOUFOX_MCP_AUTH_TOKEN}"
      }
    }
  }
}
```

Run `claude mcp list`, or use `/mcp` inside Claude Code, to check the
connection. See the official [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp).

### Codex CLI

Add an unauthenticated server:

```sh
codex mcp add camoufox --url http://192.168.1.20:8000/mcp
```

For bearer authentication, tell Codex which environment variable contains the
token:

```sh
codex mcp add camoufox \
  --url http://192.168.1.20:8000/mcp \
  --bearer-token-env-var CAMOUFOX_MCP_AUTH_TOKEN
```

The equivalent `~/.codex/config.toml` entry is:

```toml
[mcp_servers.camoufox]
url = "http://192.168.1.20:8000/mcp"
bearer_token_env_var = "CAMOUFOX_MCP_AUTH_TOKEN"
```

Remove `bearer_token_env_var` when authentication is disabled. Run
`codex mcp list`, or use `/mcp` inside Codex, to check the connection. See the
official [Codex MCP documentation](https://developers.openai.com/codex/mcp/).

### OpenCode

Add this entry to `opencode.json` for an authenticated connection. OpenCode
uses `{env:NAME}` for environment-variable substitution:

```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "camoufox": {
      "type": "remote",
      "url": "http://192.168.1.20:8000/mcp",
      "enabled": true,
      "oauth": false,
      "headers": {
        "Authorization": "Bearer {env:CAMOUFOX_MCP_AUTH_TOKEN}"
      }
    }
  }
}
```

Remove the `headers` object and `oauth` when authentication is disabled. Run
`opencode mcp list` to check the connection. See the official
[OpenCode MCP documentation](https://opencode.ai/docs/mcp-servers/).

These integrations expose the session-management tools listed above. After
`create_session` returns a `websocket_url`, browser automation connects to that
URL with Playwright as shown in the following examples.

## Python client

Install the client packages at the same versions as the service, then initialize
MCP, create a session, and connect Playwright Firefox to the returned URL:

```sh
python -m pip install 'mcp==2.0.0' 'playwright==1.60.0'
```

```python
import asyncio

import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from playwright.async_api import async_playwright


async def main() -> None:
    token = "replace-this-token"  # Use None when authentication is disabled.
    headers = {"Authorization": f"Bearer {token}"} if token else {}

    async with httpx2.AsyncClient(headers=headers) as http:
        async with streamable_http_client(
            "http://127.0.0.1:8000/mcp", http_client=http
        ) as (read, write):
            async with ClientSession(read, write) as mcp:
                await mcp.initialize()
                result = await mcp.call_tool("create_session", {})
                session = result.structured_content
                assert session is not None
                try:
                    async with async_playwright() as playwright:
                        browser = await playwright.firefox.connect(
                            session["websocket_url"], headers=headers
                        )
                        page = await browser.new_page()
                        await page.goto("data:text/html,<title>Camoufox</title>")
                        print(await page.title())
                        await browser.close()
                finally:
                    await mcp.call_tool(
                        "close_session", {"session_id": session["id"]}
                    )


asyncio.run(main())
```

Connecting does not require downloading a local Firefox binary; the browser
runs in the container.

## Node Playwright connection

Use the same Playwright version as the image:

```sh
npm install 'playwright@1.60.0'
```

After an MCP `create_session` call has supplied `CAMOUFOX_WS_URL`:

```js
const { firefox } = require("playwright");

(async () => {
  const token = process.env.CAMOUFOX_MCP_AUTH_TOKEN;
  const headers = token ? { Authorization: `Bearer ${token}` } : {};
  const browser = await firefox.connect(process.env.CAMOUFOX_WS_URL, { headers });
  const page = await browser.newPage();
  await page.goto("data:text/html,<title>Camoufox</title>");
  console.log(await page.title());
  await browser.close();
})();
```

Closing the Playwright client is not a substitute for `close_session`; call the
MCP tool to release the managed session and private port.

## Session lifecycle and errors

Each successful `create_session` starts one private Camoufox process and
reserves one private port. A session remains tracked until `close_session`, idle
cleanup, application shutdown, or container termination closes that process.
WebSocket traffic refreshes its idle timer; MCP metadata reads do not.

- Creation fails when `MAX_SESSIONS` is reached or no configured private port
  remains. Failed startups release their reservation.
- Unknown session IDs fail `get_session`, `close_session`, and WebSocket
  connection attempts.
- If browser termination fails, the session remains tracked so cleanup can
  retry and its port cannot be assigned unsafely.
- Session state is memory-only. Restarting the container closes and forgets all
  sessions.
- Invalid session options are rejected before browser startup. Proxy-related
  errors are intentionally generic so submitted credentials cannot leak.
- HTTP `401` means a bearer credential is absent or malformed; `403` means the
  supplied token does not match. WebSocket upgrades are rejected under the
  equivalent conditions.

## TLS reverse proxy

The service does not terminate TLS. A reverse proxy must forward normal HTTP,
Streamable HTTP responses, and WebSocket upgrades to the same port. For example,
with Caddy:

```caddyfile
camoufox.example.lan {
    tls internal
    reverse_proxy 127.0.0.1:8000
}
```

Run the container with:

```sh
CAMOUFOX_MCP_PUBLIC_URL=wss://camoufox.example.lan \
CAMOUFOX_MCP_ALLOWED_HOSTS=camoufox.example.lan \
CAMOUFOX_MCP_ALLOWED_ORIGINS=https://camoufox.example.lan \
docker compose up --build --detach --wait
```

Clients then use `https://camoufox.example.lan/mcp` for MCP and receive
`wss://camoufox.example.lan/browser/SESSION_ID` from `create_session`. Keep
bearer authentication enabled unless the proxy and network independently
restrict every client. Certificate trust for Caddy's internal CA must be
installed on each client.

## Virtual-headless behavior

The default `virtual` mode gives each session its own Xvfb display while Firefox
runs in headful mode against that display. This preserves the virtual-headless
behavior of the pinned canary without exposing an X11 port. Camoufox generates
a coherent fingerprint automatically, enables humanized input, uses its bundled
uBlock Origin only, and enables GeoIP alignment when a proxy is supplied.

Set `CAMOUFOX_MCP_HEADLESS=native` only when Firefox's native headless mode is
preferred over the stealth-oriented default.

## Verification

Install the locked test environment and run the unit suite (the Docker E2E is
collected but skipped by default):

```sh
uv sync --group test
.venv/bin/python -m pytest -q
docker compose config
```

Build the named image and opt in to the real non-root, virtual-headless MCP and
Playwright WebSocket test:

```sh
docker build -t camoufox-canary-mcp:test .
CAMOUFOX_MCP_RUN_E2E=1 \
CAMOUFOX_MCP_E2E_IMAGE=camoufox-canary-mcp:test \
.venv/bin/python -m pytest tests/e2e/test_container.py -v
```

The E2E always removes its test container, including after failures.

## Upstream pins and updates

The build checks out Camoufox PR [#707](https://github.com/daijro/camoufox/pull/707)
at `d83cbb373b63ffc063e068d89efcf78620ba1a53`, then cherry-picks the integrity
verification change from PR [#716](https://github.com/daijro/camoufox/pull/716)
at `65cda21b4fc10d8eeaec46d8f82c1b2a9fbb0791`.

To update, first verify the candidate revisions are compatible. Then change the
two constants in `scripts/build-camoufox.sh`, the matching OCI labels in the
`Dockerfile`, and the assertions in `tests/test_build_pins.py`. Rebuild the test
image and run the unit suite, `docker compose config`, and the opt-in E2E command
above before using the new image.

## Limitations and troubleshooting

- This is a canary build from unmerged upstream revisions. State is in memory;
  restarting the single process closes all sessions. There is no built-in TLS,
  persistence, multi-host coordination, or browser-action MCP tool set.
- A healthy service with a failing `create_session` usually needs more startup
  time or memory, or the requested fingerprint constraints may be incompatible.
  Keep the 2 GiB shared-memory setting and inspect the child-process diagnostic
  with `docker compose logs --tail=200 camoufox-canary-mcp`.
- HTTP 401 means the bearer header is missing or malformed; 403 means the token
  is wrong. The same header is required in Playwright `connect` options.
- A Playwright protocol/version error usually means the client differs from
  `1.60.0`. Match the pinned version shown above.
- If session URLs point at `localhost` from another computer, set
  `CAMOUFOX_MCP_PUBLIC_URL` to the LAN-reachable `ws://` or reverse-proxied
  `wss://` address.
- For non-local MCP deployments, set explicit comma-separated
  `CAMOUFOX_MCP_ALLOWED_HOSTS` and `CAMOUFOX_MCP_ALLOWED_ORIGINS` values that
  match the client-visible host and origin.