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.
Summary
On a self-hosted Docker install, every request to
/api/mcpis rejected with-32001 Invalid or expired OAuth token, including requests carrying a validOAuth token minted seconds earlier by
/api/oauth/token. TheX-WorldMonitor-Keyfallback 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 --buildfrom a clean clone at 2051db4Root cause
docker/nginx.conf.templatesets the header unconditionally for the whole/api/location:
LOCAL_API_TOKENis the sidecar's per-session transport credential, generated inentrypoint.shat boot. So the caller's ownAuthorizationheader is discardedbefore 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 theroute handler:
api/mcp/auth.tsresolvesAuthorization: Bearerfirst and only falls back toX-WorldMonitor-Keywhen no bearer is present. Because nginx guarantees a beareris always present, and that bearer is the sidecar's transport token rather than an
OAuth token,
resolveBearerToContext()returnsnulland the request 401s. TheAPI-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
Evidence the token itself is valid
Ruling out the obvious causes first:
/api/oauth/tokenreturns 200 and writesoauth:token:<uuid>to Redis.keyFingerprint(key).WORLDMONITOR_VALID_KEYSis present in the sidecar process's environment(checked via
/proc/<pid>/environ), and the bundle reads it at runtime ratherthan inlining it at build time.
returns
{ kind: 'env_key', apiKey: 'wm_…' }— it resolves correctly.And invoking the bundled handler directly with
X-WorldMonitor-Keyset and noAuthorizationheader returns 200 with 41 tools. The feature works; only theheader plumbing is wrong.
Suggested fix
Strip the transport credential after the gate has validated it, before dispatch:
The sidecar has finished with that header by then, and route handlers read
Authorizationas caller identity, so forwarding it conflates transport auth withcaller auth. With the header stripped,
X-WorldMonitor-Keyworks end to end andthe 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.tstocheck
X-WorldMonitor-Keybefore the bearer, though that leaves OAuth broken forself-hosters.