Skip to content

/api/mcp is unauthenticatable in the Docker self-host: the sidecar forwards its own LOCAL_API_TOKEN as the caller's Authorization header #5471

Description

@petrelwright

Summary

On a self-hosted Docker install, every request to /api/mcp is rejected with
-32001 Invalid or expired OAuth token, including requests carrying a valid
OAuth token minted seconds earlier by /api/oauth/token. The X-WorldMonitor-Key
fallback cannot be reached either. As far as I can tell the MCP endpoint has no
working authentication path in this deployment mode.

The MCP implementation itself is fine. The token never reaches it.

Environment

  • docker compose up -d --build from a clean clone at 2051db4
  • Docker 29.6.2, buildx 0.35.0
  • Dashboard published on host port 3100 → container 8080

Root cause

docker/nginx.conf.template sets the header unconditionally for the whole /api/
location:

proxy_set_header Authorization "Bearer ${LOCAL_API_TOKEN}";

LOCAL_API_TOKEN is the sidecar's per-session transport credential, generated in
entrypoint.sh at boot. So the caller's own Authorization header is discarded
before it reaches the sidecar.

The sidecar then validates that token in its global auth gate
(src-tauri/sidecar/local-api-server.mjs) and forwards the same header on to the
route handler:

const hdrs = toHeaders(req.headers, { stripOrigin: true });
hdrs.set('Origin', `http://127.0.0.1:${context.port}`);
const request = new Request(requestUrl.toString(), { method: req.method, headers: hdrs, body });

api/mcp/auth.ts resolves Authorization: Bearer first and only falls back to
X-WorldMonitor-Key when no bearer is present. Because nginx guarantees a bearer
is always present, and that bearer is the sidecar's transport token rather than an
OAuth token, resolveBearerToContext() returns null and the request 401s. The
API-key branch is unreachable by construction.

Going around nginx does not help: the sidecar's own gate rejects a request that
lacks its token, and supplying it puts the same value back in the same header.

Reproduction

KEY="wm_$(openssl rand -hex 24)"
# add WORLDMONITOR_VALID_KEYS=$KEY to the worldmonitor service environment, restart

TOK=$(curl -s -X POST http://localhost:3100/api/oauth/token \
  -d "grant_type=client_credentials&client_secret=$KEY" | jq -r .access_token)

curl -s -X POST http://localhost:3100/api/mcp \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → {"code":-32001,"message":"Invalid or expired OAuth token. Re-authenticate via /oauth/token."}

curl -s -X POST http://localhost:3100/api/mcp \
  -H "X-WorldMonitor-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → same OAuth error, because nginx supplied a bearer the client never sent

Evidence the token itself is valid

Ruling out the obvious causes first:

  • /api/oauth/token returns 200 and writes oauth:token:<uuid> to Redis.
  • The stored value is correct JSON and the fingerprint matches keyFingerprint(key).
  • The REST proxy round-trips colon-containing keys with and without percent-encoding.
  • WORLDMONITOR_VALID_KEYS is present in the sidecar process's environment
    (checked via /proc/<pid>/environ), and the bundle reads it at runtime rather
    than inlining it at build time.
  • Running the resolver by hand inside the container against a freshly minted token
    returns { kind: 'env_key', apiKey: 'wm_…' } — it resolves correctly.

And invoking the bundled handler directly with X-WorldMonitor-Key set and no
Authorization header returns 200 with 41 tools. The feature works; only the
header plumbing is wrong.

Suggested fix

Strip the transport credential after the gate has validated it, before dispatch:

hdrs.set('Origin', `http://127.0.0.1:${context.port}`);
hdrs.delete('Authorization');

The sidecar has finished with that header by then, and route handlers read
Authorization as caller identity, so forwarding it conflates transport auth with
caller auth. With the header stripped, X-WorldMonitor-Key works end to end and
the OAuth path is reachable for clients that pass a real bearer through a
non-clobbering front end.

I have this patch running locally and am happy to open a PR if the approach looks
right to you. There may be a reason handlers are expected to see the token that I
have not spotted — if so, the alternative would be reordering api/mcp/auth.ts to
check X-WorldMonitor-Key before the bearer, though that leaves OAuth broken for
self-hosters.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions