Skip to content

Provision agent API keys

WP5 points five AI coding agents (Claude Code, Copilot, Cursor, Codex, Gemini) plus the engram CLI bridge at one always-on ENGRAM server over MCP_TRANSPORT=streamable-http. That shared surface changes the threat model.

When AUTH_REQUIRED=false, the server reads the tenant userId from the request body, which is fully spoofable: any local process that can reach the port can send any userId and thereby read, write, or delete any tenant’s memories (WP5 R1 / GAPS G1). There is no identity — only an unverified string.

The mitigation is non-negotiable for a multi-agent host:

  • AUTH_REQUIRED=true — every protected tools/call must carry a valid key.
  • One least-privilege API key per agent (below).
  • Loopback bind (127.0.0.1) so the port is not exposed off-host.

The server enforces this posture at boot: a multi-tenant streamable-http server without AUTH_REQUIRED=true refuses to start in every NODE_ENV unless the operator explicitly sets ALLOW_UNAUTHENTICATED_HTTP=true — the trusted-network acknowledgement for a deliberately open deployment (e.g. a loopback-bound single-operator host).

With auth on, the server injects the key’s userId over any client-supplied userId for identity tools — the tenant is the token, not the body — and enforces per-key scopes (memories:read, memories:write, memories:delete; admin satisfies any). That enforcement is unit-tested in apps/mcp-server/src/**/dispatch-auth.spec.ts. See the agent memory server runbook for turning auth on and binding loopback, and the Agent Memory Contract for the userId convention and scope grammar.

Every agent gets its own key. All keys share the single tenant userId: "qp" (per WP5 R1 and the standing convention) — the key, not the userId, is what distinguishes agents for provenance and revocation. Give each key only the scopes that agent actually uses.

Agent Key name userId Scopes
Claude Code claude-code qp memories:read, memories:write
GitHub Copilot copilot qp memories:read, memories:write
Cursor cursor qp memories:read, memories:write
Codex codex qp memories:read, memories:write
Gemini gemini qp memories:read, memories:write
CLI bridge (engram) cli-bridge qp memories:read, memories:write

Delete is opt-in, default-deny. The table above deliberately omits memories:delete. Grant memories:delete only for an agent that is actually wired to call forget / delete_memory; omit it for every other agent. A read+write key cannot delete, so an agent that never deletes cannot be tricked (or bugged) into destroying memories. Never grant the admin scope to an agent key — admin satisfies every scope check and is meant for operators only.

ENGRAM_AGENT is attribution-only — it is not a substitute for distinct keys. The CLI bridge’s ENGRAM_AGENT label (packages/agent-bridge/src/config.ts) is an unauthenticated, self-declared provenance string: any process can claim any label, and it never enters an authorization decision. Verified per-agent attribution comes from the key — the server stamps the authenticated key’s id into MemoryAudit.actorId on every audited mutation. With one shared key, every agent’s operations collapse into a single actor: you can neither tell agents apart in the audit trail nor revoke one agent without cutting off all of them. Distinct keys per agent are required for per-agent authz, attribution, and revocation.

Keys are minted with the admin MCP tool create_api_key. There is no REST endpointcreate_api_key is invoked as an MCP tools/call, and the call must present the admin credential (adminToken, see below). Two practical ways to make the call:

  • MCP Inspector — run pnpm inspector and point it at the server (http://127.0.0.1:3000/mcp), then invoke create_api_key from the tool UI.
  • An authenticated admin MCP client — any client that speaks MCP Streamable-HTTP and can send the tools/call below.

The tool input (validated by the Zod schema in apps/mcp-server/src/api-keys/dto/create-api-key.dto.ts) takes userId, adminToken (≥16 chars, equals MCP_ADMIN_TOKEN), name (1–100 chars), scopes (1–10 of memories:read | memories:write | memories:delete | admin), and optional expiresInDays (1–3650). The JSON-RPC envelope:

{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_api_key",
"arguments": {
"userId": "qp",
"adminToken": "<MCP_ADMIN_TOKEN>",
"name": "claude-code",
"scopes": ["memories:read", "memories:write"],
"expiresInDays": 90
}
}
}

The response returns the plaintext key exactly once (format eng_…) alongside { id, prefix, name, scopes, expiresAt, createdAt, warning }. The server stores only a hash — it can never show the plaintext again. Copy the eng_… value immediately into the target agent’s config (next section); if you lose it, revoke and re-mint. Repeat the call once per agent, changing only name (and scopes).

Instead of repeating the manual create_api_key call per agent, the provision-agent-keys CLI mints the whole fleet in one pass. It uses ApiKeysService directly against the database — run it on the server host (with the server’s .env); it needs neither a running server nor MCP_ADMIN_TOKEN:

Terminal window
pnpm --filter mcp-server provision-agent-keys -- \
--agents claude-code,copilot,cursor,codex,gemini,cli-bridge --user qp

For each agent name it mints one distinct key labelled agent:<name> under the single shared tenant (--user, normally qp — distinct keys, one memory pool), prints the eng_ plaintext once with ready-to-paste ENGRAM_API_KEY / Authorization: Bearer snippets (next section), and refuses admin outright. Options:

  • --scopes — default memories:read,memories:write,memories:delete. To follow the delete-is-opt-in table above, pass --scopes memories:read,memories:write for agents that never delete.
  • --expires-in <duration> — key lifetime, e.g. 90d, 12w, 1y (default: no expiry).
  • --rotate — revoke-and-replace. Without it, an agent whose agent:<name> key is still active is reported as already provisioned and skipped — the plaintext is not recoverable, and the CLI never silently re-mints — so re-running the command is safe.

Each agent authenticates by sending its own plaintext key on every request as an HTTP header:

Authorization: Bearer eng_<the-key-for-this-agent>

Wire it in one of two equivalent ways, per agent:

  • MCP config header — set the Authorization: Bearer <key> header on the engram server entry in that agent’s MCP client configuration.
  • Environment variable — set ENGRAM_API_KEY=eng_…; the engram CLI bridge and hook wrappers read it and attach the Bearer header for you.

The exact config file and header syntax for each of the five agents is in Agent memory client wiring. Never commit a key to git — no eng_… value belongs in a tracked file, a checked-in .mcp.json, or a commit message. Keep keys in per-agent local config or an untracked env file.

MCP_ADMIN_TOKEN is not an agent key and must never be handed to an agent. It guards the destructive/admin tools (reindex_*, consolidate, create_api_key, revoke_api_key) via a constant-time comparison. A leaked admin token lets any holder mint keys for any tenant and run admin operations — it is the most sensitive secret in this system.

  • Give agents only their scoped eng_… key. Give no agent the admin token.
  • Store MCP_ADMIN_TOKEN only where an operator mints/rotates keys (the server env / operator shell), never in an agent config.

Rotation is revoke, then re-mint — there is no in-place update. Use it on a schedule, on suspected exposure, or when an agent is retired.

  1. Find the key id: call list_api_keys with { "userId": "qp" } and read the id of the key whose name/prefix you are rotating.
  2. Revoke it: call revoke_api_key with { "userId": "qp", "keyId": "<id>" }. The old eng_… value stops working immediately.
  3. Re-mint: run the create_api_key tools/call above with the same name and scopes to get a fresh eng_… (shown once).
  4. Update that agent’s config (the Authorization header or ENGRAM_API_KEY) with the new value, then confirm the agent can still recall.

list_api_keys and revoke_api_key are admin-guarded like create_api_key; they require the admin credential and act by userId (and keyId for revoke).

The automated base checks — health 200, the MCP initialize handshake, tools/list, and that an unauthenticated protected tools/call returns 401 when AUTH_REQUIRED=true — are in scripts/verify-engram-server.sh. Run that first.

The scope and identity checks below are also automated by the same script: mint a read-only key (scopes ["memories:read"]) and a read+write key, then pass them in:

Terminal window
ENGRAM_READONLY_KEY=eng_… ENGRAM_WRITE_KEY=eng_… \
./scripts/verify-engram-server.sh

ENGRAM_READONLY_KEY drives the read-ok and write-denied checks; ENGRAM_WRITE_KEY drives the spoofed-userId check (it writes one short-term probe memory that self-expires after 60 seconds). Without the variables the script skips these checks with a notice. A presented key is validated and scoped even on an auth-off server, so the scoped checks work there too. To verify by hand instead — or to understand exactly what the script asserts — the checklist is:

  • Read-only key can recall. A tools/call for recall with the read-only key’s Authorization: Bearer header succeeds.
  • Read-only key cannot remember. The same key calling remember is rejected with a scope error (403-equivalent): the missing memories:write scope is denied, no memory is written.
  • Spoofed body userId is ignored. Send a remember/recall whose body sets userId to some other value (e.g. "attacker"); the server injects the key’s userId (qp) and the operation acts on qp, not the spoofed value. The token wins over the body.
  • No key is 401. A protected tools/call sent with no Authorization header returns 401 (auth is on).

If any check fails, do not distribute keys until AUTH_REQUIRED=true, loopback bind, and the per-agent keys above are all in place.