MCP

Auth & scoping

Scoped API keys, OAuth for MCP clients, tool allowlists and how secrets stay out of context.

In the cloud, MCP and agent access is authenticated with API keys. A key can be scoped to one group and an allowlist of tools, so the key you hand an agent reaches only what it needs, and your workspace key never leaves your server. MCP clients you use yourself can instead sign in with OAuth and get the same one-group scope.

On your own machine there is no auth: primitives dev serves http://localhost:7777/mcp/<group> with no token, as it serves REST. See Local access without a token.

Credentials

CredentialPrefixScopeWhere it lives
Workspace API key (cloud)pk_Every group and every tool: create, list and destroy groups, create, delete, rename, move, pause and resume primitives, read the audit trail, store secretsYour server only. Created in the dashboard at app.useprimitives.com
Scoped API key (cloud)pk_One group or every group, and optionally a list of toolsGiven to the agent or MCP client. Created in the dashboard
Root token (local, optional)anyAccount-level calls, for primitives dev --token <secret>. Without it, localhost needs no authYour machine
OAuth access token (cloud)po_One group and the tools picked on the consent page, /mcp onlyHeld by the MCP client that signed in; refreshed with a pr_ refresh token

Each is sent as Authorization: Bearer <credential>.

Scoped API keys

An API key is scoped when you create it under API keys in the dashboard:

  • Group: one named group, or every group in the workspace (including ones created later).
  • Tools: every tool, or a list of tools and globs such as db_query or fs_*. primitive_list is always available, so you don't need to list it.

A key with every group and every tool is a workspace key. Use it for the code that provisions groups. Anything narrower is a scoped key: give each agent its own, limited to its group and the tools it needs. To cut an agent off, revoke its key in the dashboard.

Picking the group on /mcp

https://mcp.useprimitives.com/mcp is the same URL for every group, so the key decides which group a session reaches:

  • A key scoped to one group always reaches that group.
  • A key for every group names one in the X-Primitives-Group header. Without it, /mcp answers 400 invalid_request.
  • A header naming a group outside the key's scope answers 403 group_mismatch.
claude mcp add --transport http primitives https://mcp.useprimitives.com/mcp \
  --header "Authorization: Bearer $PRIMITIVES_API_KEY" \
  --header "X-Primitives-Group: agent-42"

Over REST the path already names the group (/v1/groups/{group}/…) or the primitive (/v1/primitives/{id}), so no header is needed.

OAuth for MCP clients

In the cloud, https://mcp.useprimitives.com/mcp supports the MCP authorization spec, so a client can connect with only the URL. See Cloud, sign in with OAuth for client setup.

  1. A request without a token answers 401 with WWW-Authenticate: Bearer resource_metadata="https://mcp.useprimitives.com/.well-known/oauth-protected-resource/mcp". That document names the authorization server, https://app.useprimitives.com/api/auth.
  2. The client registers itself (dynamic client registration) and sends you to sign in. Public clients only, and PKCE with S256 is required.
  3. On the consent page you pick one workspace, one group and the tools. The page shows the app's name and where it sends you back; the name is chosen by the app, so check the address.
  4. The client gets an access token (po_, valid for an hour, bound to the MCP URL) and a refresh token (pr_, 30 days, replaced on every use). The client stores and refreshes them; you never handle them.

An OAuth token works like a group-scoped API key on /mcp: the same allowlist, scoping rules and plan limits apply, and X-Primitives-Group can't name another group. It doesn't work on the REST API.

Access stops at once when you click Disconnect under API keys → Connected apps, or when you leave the workspace. Any member of the workspace can see and disconnect its apps. To change the group or tools, disconnect and connect again.

Scoping rules

These apply to scoped API keys and OAuth tokens alike:

  • One group. A key scoped to one group reaches only that group and the primitives in it. A primitive in another group (/v1/primitives/{id}) answers 404 not_found, so its existence isn't leaked; GET /v1/primitives lists only the key's group; another group's /v1/groups/{group} paths fail with 403 group_mismatch.
  • Allowlisted tools only. Tools outside the allowlist are left out of tools/list and rejected with tool_not_allowed. The one exception is primitive_list: every key can always list its group's primitives, over MCP and REST, so agents can discover what to pass as primitive. Over REST the same allowlist gates each endpoint: for example, fs_read covers reading files, fs_write covers writes, moves and deletes, and a key without db_exec may only run statements that don't write.
  • One tool at a time. Each MCP call is narrowed to the tool being called, so db_query stays read-only even when the key also allows db_exec.
  • Every primitive in the group. A key reaches every database, filesystem, queue, memory, vault, key-value store and object store in its group, including ones created later. Over MCP the agent sees them with primitive_list and addresses one by id or name with the primitive argument. With primitive_create on its allowlist it can add primitives to its group; with primitive_snapshot and primitive_fork it can snapshot, restore and fork them within its own group.
  • No escalation. Scoped keys can't create, list or destroy groups, delete, rename, move, pause or resume primitives, read the audit trail, or store, delete or grant secrets: those return 403 forbidden and need a workspace key. A scoped key can't fork into another group; your server forks across groups with its workspace key if it wants to.

Unknown, malformed and revoked keys all fail the same way, with 401 invalid_token.

Local access without a token

primitives dev started without --token needs no auth on localhost, for REST and MCP alike:

  • /mcp/<group> takes no token. The group comes from the path (a valid group name, or 400 invalid_request) and is created on first use. Every tool is allowed; ?tools=kv_*,storage_* narrows them with the same patterns as primitives mcp --tools. Calls go through the same checks as a scoped key's, narrowed to one tool at a time and limited to that group, and the audit trail records their actor as local.
  • Stdio too. primitives mcp --group <name> serves one group over stdio with the same checks. See Stdio clients.
  • Localhost only. Requests addressed to another host (DNS rebinding) and cross-site requests from web pages get 403 forbidden. An MCP session belongs to the group path that started it; its id answers 404 anywhere else.
  • Root token. With primitives dev --token <secret>, send the same value as the Bearer token; requests without it answer 401 invalid_token.

?tools= keeps a local agent to the tools you list, but on localhost anything that can reach the port can open any group. Use scoped API keys in the cloud when the agent isn't trusted.

How secrets stay out of context

The vault is the one primitive designed so the model never sees the data:

  1. Your server stores the value in one of the group's vaults (group.vault('prod').set) and grants it a host and method policy (.grant).
  2. The agent calls vault_list and sees names, versions and grants, such as GITHUB_TOKEN.
  3. The agent calls vault_use with "auth": { "bearer": "GITHUB_TOKEN" }. The engine checks the grant, injects the value into the outgoing request, and returns the response with the value redacted.

The value never appears in tool arguments, tool results or logs, so it can't leak through the model's context, a prompt injection or a transcript. See Vault.

Signed URLs are capabilities

storage_sign (and presign over REST) turns the key's access into a URL that needs no credential at all. Anyone holding the URL can download, or upload to, that one object until it expires, even after the key that signed it is revoked. Grant storage_sign only to agents you'd let share or accept files on your behalf, keep expires_in short, and call revokeSignedUrls() on the store to invalidate every URL it has issued. Forking a store or moving it to another group also invalidates its URLs. See Signed URLs.

Recommendations

  • Give each agent its own scoped API key, limited to its group and the tools it needs. Never hand an agent a workspace key.
  • Start with the read-only tools and add write tools as the agent needs them.
  • Keep keys in environment variables, not in checked-in config files. Reference them as ${env:PRIMITIVES_API_KEY} (Cursor) or $PRIMITIVES_API_KEY (Claude Code).
  • Name keys after the agent. The audit trail records each call's actor as key:<id>.
  • Revoke a key in the dashboard as soon as its agent no longer needs it, or if you believe it's exposed.
  • For apps you use yourself, such as Claude or Cursor, prefer OAuth over pasting a key: nothing long-lived ends up in a config file, and you can disconnect the app from the dashboard.