docs/features/agent-tokens.md
Agent tokens provide scoped, revocable credentials for AI agents connecting to MCPProxy. Instead of sharing the admin API key with every agent, each agent gets its own token with restricted access to specific servers and permission tiers.
MCPProxy sits between AI agents and upstream MCP servers. Without agent tokens, every connection gets full admin access — any agent can call any tool on any server with no restrictions.
This creates real problems:
Agent tokens solve this with defense-in-depth scoping:
┌─────────────────────────────────────────┐
│ AI Agent (e.g., deploy-bot) │
│ Token: mcp_agt_a1b2c3... │
│ Servers: github, gitlab │
│ Permissions: read, write │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ MCPProxy │
│ │
│ 1. retrieve_tools → filters results │
│ to github + gitlab only │
│ │
│ 2. call_tool_write → allowed │
│ 3. call_tool_destructive → BLOCKED │
│ 4. call_tool_read(slack:...) → BLOCKED │
└─────────────────────────────────────────┘
Agent tokens use the mcp_agt_ prefix followed by 64 hex characters:
mcp_agt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Tokens are hashed with HMAC-SHA256 before storage — the raw token is shown once at creation and cannot be retrieved again.
mcpproxy token create \
--name deploy-bot \
--servers github,gitlab \
--permissions read,write \
--expires 30d
Output:
Agent token created successfully.
Token: mcp_agt_a1b2c3d4...
IMPORTANT: Save this token now. It cannot be retrieved again.
Name: deploy-bot
Servers: github, gitlab
Permissions: read, write
Expires: 2026-04-05 14:30
Agents authenticate by passing the token via any standard method:
# X-API-Key header
curl -H "X-API-Key: mcp_agt_a1b2c3d4..." http://localhost:8080/mcp
# Authorization: Bearer header
curl -H "Authorization: Bearer mcp_agt_a1b2c3d4..." http://localhost:8080/mcp
# Query parameter
curl "http://localhost:8080/mcp?apikey=mcp_agt_a1b2c3d4..."
In MCP client configurations:
{
"mcpServers": {
"mcpproxy": {
"url": "http://localhost:8080/mcp",
"headers": {
"X-API-Key": "mcp_agt_a1b2c3d4..."
}
}
}
}
By default, the /mcp endpoint allows unauthenticated access for backward compatibility with existing MCP clients. This means agent tokens are optional — agents that don't provide a token get full admin access.
To make agent tokens mandatory, enable require_mcp_auth:
{
"require_mcp_auth": true
}
Or via CLI flag:
mcpproxy serve --require-mcp-auth
With this enabled:
Recommended setup: Enable require_mcp_auth when deploying MCPProxy in environments where multiple agents connect, or when you want to enforce least-privilege access.
Each token specifies which permission tiers the agent can use:
| Permission | Tool Variants Allowed | Use Case |
|---|---|---|
read | call_tool_read | Monitoring, querying, status checks |
write | call_tool_read, call_tool_write | Creating issues, updating records |
destructive | All variants | Deleting resources, admin operations |
Permissions are cumulative — write implies read, and destructive implies both. The read permission is always required.
# Read-only monitoring agent
mcpproxy token create --name monitor --servers "*" --permissions read
# CI/CD agent that creates and updates
mcpproxy token create --name ci-agent --servers github --permissions read,write
# Full-access admin agent
mcpproxy token create --name admin-bot --servers "*" --permissions read,write,destructive
Tokens restrict which upstream servers an agent can access:
# Only GitHub and GitLab
mcpproxy token create --name deploy-bot --servers github,gitlab --permissions read,write
# All servers (wildcard)
mcpproxy token create --name all-access --servers "*" --permissions read
Server scoping is enforced at three levels:
Tool discovery (retrieve_tools) — only returns tools from allowed servers
Tool execution (call_tool_*) — blocks calls to out-of-scope servers
Enumeration — since issue #1166, allowed_servers also scopes what the
REST surface will list, not only what the token may call. A scoped token
sees only its own servers on GET /api/v1/servers (array and the
stats counters), GET /api/v1/status (upstream_stats), the /events
SSE stream, GET /api/v1/tools, GET /api/v1/index/search,
GET /api/v1/diagnostics / doctor, GET /api/v1/profiles,
GET /api/v1/annotations/coverage and GET /api/v1/security/scans.
The whole /api/v1/servers/{id} subtree answers 404 Server not found
for a server outside the scope — tools, logs, tool-calls,
diagnostics, scan/status, scan/report, scan/files, integrity,
tools/export, tools/{tool}/diff and every sub-resource added later, since
the gate is a middleware on the subtree. It is the same 404 a server that
does not exist returns — byte for byte, once the echoed name is normalised —
so the response cannot be used to probe for hidden servers. logs matters
most: upstream stderr routinely echoes the argv and env the server process
was launched with.
The activity, tool-call and usage doors are scoped to records
attributable to an allowed server: GET /api/v1/activity,
/activity/summary, /activity/usage, /activity/export, /activity/{id},
GET /api/v1/tool-calls and /tool-calls/{id} (plus its /replay). The
entitlement is applied inside the query, so total and the page always
describe the same record set, and a ?server= filter narrows within the
scope rather than escaping it. Records with no server attribution
(system_start, config_change, …) are operator-plane events and are not
shown. On /activity/usage, aggregates that cannot be re-derived per server
— the tokens-saved headline and the global timeline — are omitted rather than
reported fleet-wide.
Denied outright (403) to agent tokens, because there is nothing
per-server to project:
GET /api/v1/config — an admin document, and it carries the admin API key.GET /api/v1/stats/tokens — per_server_tool_list_sizes is keyed by every
configured server, and the scalars beside it are fleet-wide.GET /api/v1/sessions, GET /api/v1/sessions/{id} — an MCP session
describes a client and the user's workspace, with no server attribution.GET /api/v1/security/overview, GET /api/v1/security/queue — fleet-wide
scan and finding counts, and a queue that names every server waiting to be
scanned. A scoped caller reads its own server's verdict from
GET /api/v1/servers/{id}/scan/status, which the subtree gate scopes.GET /api/v1/telemetry/payload — the heartbeat carries server_count,
connected_server_count, tool_count and server_docker_isolated_count:
precisely the count oracle removed from /status.GET /api/v1/onboarding/state, POST /api/v1/onboarding/mark (which
echoes the same document) — configured_server_count is an inventory size
and connected_client_ids is the operator's MCP-client inventory.GET /api/v1/secrets/refs, GET /api/v1/secrets/config — values are
masked, so this is a credential inventory rather than a disclosure, but
it names the secrets of servers the caller may not enumerate. A strictly
narrower view of the document GET /api/v1/config already denies.Withheld rather than denied. GET /api/v1/status stays open — agents
legitimately poll it for liveness — but its activation block is omitted for
a scoped caller. mcp_clients_seen_ever is the operator's MCP-client
inventory and retrieve_tools_calls_24h is an exact deployment-wide counter,
neither of which has a per-server part to project. The key is already absent
when telemetry is unwired, so clients tolerate its absence.
PUT /api/v1/profiles/active answers 403: the active profile is
server-level shared state that decides what the Web UI and tray render, so a
read-scoped credential must not be able to change it. It is gated by the same
config_write policy as the other config-level writes.
On the /events stream, scoping applies per event, not only to the
servers.changed server list:
server_name, server, target_server or affected_entity, which is
every activity, OAuth and security event — is not delivered to that
subscriber at all. It is dropped rather than blanked, because a frame with
the name removed still discloses the mutation, its timing and the number
of servers being hidden.servers.changed is the exception and is always delivered, because it is
coalesced last-write-wins and carries the state a client renders. Its
server list is narrowed, its stats recomputed, and any coalescer extra
that names an out-of-scope server ("server": "beta") is removed.config.reloaded, config.saved and secrets.changed announce mutations
of the admin config document and are dropped, matching the 403 on
GET /api/v1/config.Admin subscribers — the API key, the Web UI, the tray over the unix socket — receive every event unchanged; the stream is rendered per connection.
Agent tokens can discover and call tools (within their scope and permission tier) but can never administer servers. Server-mutating operations require the admin API key (or a local tray/socket connection, which is admin by OS-level auth) on every surface — the MCP tools and the REST API share one policy (internal/auth), so an agent cannot do over HTTP what it is blocked from doing over MCP.
Denied to agent tokens on both surfaces:
On the MCP surface (upstream_servers, quarantine_security) these return a tool error; on the REST surface (mutating /api/v1/servers/..., /api/v1/config/..., and /api/v1/registries/... routes) they return 403 Forbidden (operation requires admin access). Read-only operations stay available to scoped tokens: upstream_servers list/tail_log, GET /api/v1/servers, per-server diagnostics, registry reads, and GET /api/v1/index/search (which honors quarantine — a quarantined server's tools are withheld from search on every surface). Those reads are scope-filtered as described above. GET /api/v1/config is the exception: it is an admin document (it carries the global api_key, every server's credentials, and a second enumeration of server names under profiles[].servers), so it returns 403 for an agent token rather than a filtered view.
A profile scopes tool discovery and calls to a named subset of upstream servers. With --profile-pin, you can bind a token to a single profile so it can never operate outside it — regardless of the URL it connects to or any set_profile call it makes.
# This token can ONLY ever see/use the "research" profile
mcpproxy token create \
--name research-agent \
--servers "*" \
--permissions read \
--profile-pin research
Server-side enforcement (no client cooperation required):
set_profile("other") is rejected — a pinned token cannot switch its session to a different profile (switching to its own pinned profile, or clearing, is allowed)./mcp/p/<other> returns 403 — connecting to any profile URL other than the pinned one is forbidden; the pinned profile's own URL works./mcp/p/<slug> URL scope and above a session set_profile selection.retrieve_tools, describe_tool, call_tool_*, the code_execution sandbox, direct-routing mode (server__tool) and preflight all bound themselves by the pin, so no routing mode is a way around it.Resolution precedence (highest wins):
1. agent-token profile_pin (server-enforced; this section)
2. /mcp/p/<slug> URL scope (per-request override)
3. set_profile session state (base /mcp endpoint default for the session)
4. none (no profile filtering — all allowed servers)
Validation & config changes: the pinned slug must name a configured profile at creation time (creation is rejected otherwise). If the profile is later removed from the configuration, the pin resolves to a deny-all scope: the token sees no upstream servers and no tools, on the MCP session path and in preflight alike. The request is logged with a warning naming the removed profile, not hard-failed at the transport. The pin is a restriction the operator applied, so losing the profile it names must never hand the token a wider view than it had the day before — re-create the profile, or re-mint the token against a live one, to restore it. Pinning composes with server scoping and permission tiers: a request must satisfy all of them.
The pin is shown by token list (PROFILE PIN column) and token show (Profile Pin field), and is preserved across token regenerate.
mcpproxy token list
NAME PREFIX SERVERS PERMISSIONS REVOKED EXPIRES
deploy-bot mcp_agt_a1b2 github,gitlab read,write no 2026-04-05 14:30
monitor mcp_agt_c3d4 * read no 2026-04-05 14:30
old-bot mcp_agt_e5f6 github read yes 2026-03-01 10:00
mcpproxy token show deploy-bot
Immediately invalidates the token. Revoke is a soft delete: the record is kept (so the token name stays reserved) and any further use is rejected:
mcpproxy token revoke deploy-bot
Permanently removes the token, freeing its name for reuse. Unlike revoke, delete removes the record entirely — after deleting, you can create a new token with the same name:
mcpproxy token delete deploy-bot # aliases: rm, remove
Invalidates the old secret and generates a new one, keeping the same name and settings:
mcpproxy token regenerate deploy-bot
The new token is displayed once — save it immediately.
All commands support JSON output for scripting:
mcpproxy token list -o json
mcpproxy token create --name bot --servers github --permissions read -o json
Agent token usage is tracked in the activity log. Each tool call records the agent identity:
# Filter activity by agent
mcpproxy activity list --agent deploy-bot
# Filter by auth type
mcpproxy activity list --auth-type agent
mcpproxy activity list --auth-type admin
Activity records include _auth_type, _auth_agent, and _auth_token_prefix metadata fields for audit trails.
Agent tokens can also be managed via the REST API (requires admin API key):
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/tokens | Create a new agent token |
GET | /api/v1/tokens | List all tokens |
GET | /api/v1/tokens/{name} | Get token details |
DELETE | /api/v1/tokens/{name} | Revoke a token (soft delete; name stays reserved) |
DELETE | /api/v1/tokens/{name}/permanent | Permanently delete a token (frees the name for reuse) |
POST | /api/v1/tokens/{name}/regenerate | Regenerate token secret |
curl -X POST http://localhost:8080/api/v1/tokens \
-H "X-API-Key: your-admin-key" \
-H "Content-Type: application/json" \
-d '{
"name": "deploy-bot",
"allowed_servers": ["github", "gitlab"],
"permissions": ["read", "write"],
"expires_in": "30d"
}'
mcp_agt_ prefix distinguishes agent tokens from admin API keys without database lookups{
"require_mcp_auth": false,
"api_key": "your-admin-key"
}
| Field | Type | Default | Description |
|---|---|---|---|
require_mcp_auth | bool | false | Require authentication on /mcp endpoint |
api_key | string | auto-generated | Admin API key for full access |
mcpproxy serve --require-mcp-auth # Enforce /mcp authentication
| Flag | Required | Default | Description |
|---|---|---|---|
--name | Yes | — | Unique token name |
--servers | Yes | — | Comma-separated server names or "*" |
--permissions | Yes | — | Comma-separated: read, write, destructive |
--expires | No | 30d | Expiry duration (e.g., 7d, 90d, 365d) |
--profile-pin | No | — | Pin the token to a single profile (see Profile Pinning) |