Connect Claude Code, Cursor, and Codex
The MCP server (mcp/server.py) supports two transports, selected by the
COMPASS_MCP_TRANSPORT environment variable on the server process (default
stdio). Which one you use changes where the key comes from — get this
part wrong and you'll either get a confusing 401 or, with the old
behaviour this replaced, silently see another tenant's data.
stdio — Claude Code and Cursor, run locally
The client starts mcp/server.py itself as a subprocess. The key lives in
the process environment you give it — there's no separate caller to
read a header from, so COMPASS_KEY_RO/COMPASS_KEY_RW are the only
source in this mode.
Claude Code (.mcp.json) and Cursor (.cursor/mcp.json) take the same
shape:
{
"mcpServers": {
"compass": {
"command": "/path/to/mcp/.venv/bin/python",
"args": ["/path/to/mcp/server.py"],
"env": {
"COMPASS_CORE_URL": "http://localhost:18080",
"COMPASS_KEY_RO": "<read-only key>",
"COMPASS_KEY_RW": "<read-write key>"
}
}
}
}
COMPASS_CORE_URL— the Core's base URL, without/api/v1(defaulthttp://localhost:18080).COMPASS_KEY_RO— used for read tools; if unset, read tools fall back toCOMPASS_KEY_RW.COMPASS_KEY_RW— used for write tools; if unset, a write tool call is refused client-side rather than sent without a key.
Codex
Codex CLI reads MCP servers from ~/.codex/config.toml, as a
[mcp_servers.<name>] table with the same command/args/env shape:
[mcp_servers.compass]
command = "/path/to/mcp/.venv/bin/python"
args = ["/path/to/mcp/server.py"]
[mcp_servers.compass.env]
COMPASS_CORE_URL = "http://localhost:18080"
COMPASS_KEY_RO = "<read-only key>"
COMPASS_KEY_RW = "<read-write key>"
streamable-http — a running container
Point at https://<host>/mcp (or http://localhost:18082/mcp locally).
This is the mode the deployed MCP runs under, and it accepts two kinds of
credential — exactly one per request, never both at once.
The first path: OAuth, no key
A client that speaks OAuth needs no key in its configuration. With Claude Code it is two steps:
claude mcp add --transport http compass https://<host>/mcp
Then, in the chat, /mcp → "Authenticate": the browser opens the consent
page, you sign in and grant access. Until then claude mcp list says "Needs
authentication" — that is not a failure, it is the state before consent.
The session then belongs to the person who consented, with their role and
tenant. If the server has no signing key set for the door
(COMPASS_MCP_OAUTH_SIGNING_KEY), the OAuth paths answer 503 and name the
missing variable — they do not quietly fall back to open access. The key path
is unaffected by this.
The second path: key as a header
Use a key instead of OAuth when the agent is meant to work as a specific persona (its briefing, its limits) or runs without a browser — on a server, say. A key can be bound to a persona; an OAuth session never is, it belongs to the signed-in person.
{
"mcpServers": {
"compass": {
"url": "http://localhost:18082/mcp",
"transport": "http",
"headers": { "X-Compass-Key": "<key for this tenant>" }
}
}
}
On this path the headers block is required, not optional — a request
with neither a key nor an OAuth token does not get through, and one carrying
both is refused as an ambiguous credential.
When a real incoming HTTP request is present, the server reads the key
exclusively from that request's own X-Compass-Key header — it does not
fall back to any key baked into the process's own environment, even if one
happens to be set. Omit the header and the call is refused before the Core
is ever contacted:
{"status": 401, "detail": "X-Compass-Key fehlt in der Anfrage — kein Ruecksprung auf den Prozess-Schluessel."}
This matters because the same key determines both which tenant you see
and what you're allowed to do (ro/rw/bruecke — see
Concepts) — the same header works for every tool, read
or write; the Core decides server-side whether the presented key is enough.
Connecting without a key — what you will see
Clients that speak OAuth need no key in their configuration. They send you to the browser once instead, and you decide there.
What the page tells you:
- Who is asking. The name the client registered under. If you do not recognise it, decline.
- Who you are signed in as. Your address, as COMPASS knows it.
- Which project. If you belong to several, the choice is required and nothing is preselected — otherwise the wrong answer gets clicked through. If you belong to exactly one, you are not asked.
- What the client may do, in full sentences: read, record, and what is explicitly out of reach — seeing keys, changing members, deleting anything. If your access is read-only, recording is listed under "Not", with the reason.
Then two buttons: Allow or Decline. If you decline, nothing is created and the client stays unconnected.
If something goes wrong, the page says which of these four it was instead of just "error":
| What it says | What it means |
|---|---|
| Sign-in expired | The window stood open too long. Nothing happened — click "Connect" again in your client. |
| Wrong project | The account you are signed in with has no access to this project. Sign in with another account, or pick a different project above. |
| Unknown assistant | The requesting program could not identify itself. COMPASS will not connect it. If you set it up yourself, use the key route. |
| COMPASS is not responding | Nothing was saved and nothing was connected. Try again. |
The page speaks German or English, whichever your browser asks for; German when in doubt.
After connecting, choose the persona in the chat, with
persona_waehlen. Only then is it settled who the assistant writes as and
what it may see. Without a choice, everything stays as it was.
Which transport for which client
| Client | Typical setup | Transport | Key source |
|---|---|---|---|
| Claude Code, local | runs server.py itself |
stdio |
process env |
| Cursor, local | runs server.py itself |
stdio |
process env |
| Codex, local | runs server.py itself |
stdio |
process env |
| Claude Code, against the deployed server | claude mcp add --transport http |
streamable-http |
OAuth, no key |
| Any client, against a shared/deployed server | connects over HTTP | streamable-http |
per-request header or OAuth |
Verify the connection
Once connected, a read tool that doesn't depend on any existing data is the cheapest check:
persona_list()
or, from the shell, the equivalent REST call:
curl -sS "$COMPASS_URL/personas" -H "X-Compass-Key: $COMPASS_KEY"
A 401 here means the key or header is wrong for the transport you're
using — recheck the table above before assuming the server itself is down.