Agent Tokens
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.
Why Agent Tokens?
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:
- A CI/CD bot that only needs to read GitHub issues can also delete repositories
- A monitoring agent that checks server status can also modify configurations
- A compromised agent has unlimited access to all upstream servers
- No audit trail — you can't tell which agent performed which action
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 │
└─────────────────────────────────────────┘
Token Format
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.
Quick Start
Create a Token
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
Use the Token
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..."
}
}
}
}
Enforcing Authentication on /mcp
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:
- Requests without a token → 401 Unauthorized
- Requests with an invalid token → 401 Unauthorized
- Requests with a valid agent token → scoped access
- Requests with the admin API key → full admin access
- Tray/socket connections → always trusted (OS-level auth)
Recommended setup: Enable require_mcp_auth when deploying MCPProxy in environments where multiple agents connect, or when you want to enforce least-privilege access.
Permission Tiers
Each token lists the permission tiers the agent holds. A tier unlocks the matching call_tool_* variant:
| Permission | Tool Variant Unlocked | Use Case |
|---|---|---|
read | call_tool_read | Monitoring, querying, status checks |
write | call_tool_write | Creating issues, updating records |
destructive | call_tool_destructive | Deleting resources, admin operations |
Permissions are exact-match, not cumulative: the token holds exactly the tiers listed, so destructive does not imply write. mcpproxy token create stores the list verbatim; the only rule is that the list must include read. A token minted as read,destructive can use call_tool_read and call_tool_destructive but is refused on call_tool_write — list every tier the agent needs.
Target tool tier
Holding a tier for a variant is only half the check. Every dispatch path — the call_tool_* variants, direct-name dispatch on /mcp/all (see Routing Modes) and call_tool() inside code execution — also authorizes the token against the tier of the target tool, derived from the tool's own MCP annotations (readOnlyHint / destructiveHint) as reported at discovery. The two checks are independent and both are exact-match: call_tool_read on a write-tier tool needs write, and a read,destructive token is refused on a write-tier tool on every path.
- Annotation-less tools default to
read. A discovered tool that publishes no annotations derives to the read tier, so a read-only token can call it. Operators who want stricter handling of unannotated tools use the intent declaration validation rules. - Unresolved identity on a known server is refused for every caller. The tier comes from the tool's registration identity in the live discovery snapshot — the exact
server:toolpair that will be dispatched. If the server is known and connected but its discovery has not completed yet, or its completed discovery result does not list the tool (undiscovered name, stale name after a server redeployed its tool set, a server that lists no tools at all), no tier can be established: the call is refused with the insufficient-permission body and never reaches the upstream, for agent tokens and for administrators — including stdio and in-process callers inside code execution. The refusal names the reason: while discovery has not completed for the server, retry shortly (there is no list to refresh from yet); once it has and the name is absent, refresh withretrieve_toolsand retry with a listed name. A server MCPProxy does not know at all keeps its ordinary "server not found" answer, and a server whose snapshot is not authoritative because of its own state — quarantined, disabled, disconnected or still connecting — keeps its server-level answer (the quarantine analysis, the disabled block, the not-connected message andreconnect_on_use) exactly as before, for every name on that server alike. Those server-level verdicts and the identity check read the same source: quarantined and disabled come from the persisted server configuration (not from the cached status view, which catches up with an operator's write a moment later), and connected comes from the live upstream client. So an operator who quarantines or disables a server right after its tools were discovered gets the quarantine analysis or the disabled block for an unlisted name and a listed name alike — never an "undiscovered or stale name" refusal whoseretrieve_toolsremediation cannot heal a quarantined server — and the identity refusal fires only when the same read cannot answer quarantined or disabled. - No approval record under an active quarantine gate is pending. While tool-level quarantine applies to a server (
quarantine_enabledon and the server not opted out viatrust_mode: auto/auto_approve_tool_changes), a tool the snapshot contains that has no approval record of its own is treated as pending approval — never as implicitly approved — at every gate: dispatch, preflight anddescribe_tool. A name on a server whose snapshot is empty because of its own state (quarantined, disabled, disconnected, connecting) is not held pending — the server-level answer owns it, as above — and once the server reconnects, a name its fresh discovery result does not list is refused as unresolved before any upstream call. The refusal saysno_approval_record: nothing is listed for review yet, and the record is filed on the server's next discovery pass (upstream_serversoperationrefresh, ormcpproxy upstream restart <server>). Approval records are keyed by the exact upstream tool name, so a namespaced tool such asns:eraseis approved only by its own name and never inherits the approval of a siblingerase— see Namespaced tool names.
# 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
Server Scoping
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_serversalso scopes what the REST surface will list, not only what the token may call. A scoped token sees only its own servers onGET /api/v1/servers(array and thestatscounters),GET /api/v1/status(upstream_stats), the/eventsSSE stream,GET /api/v1/tools,GET /api/v1/index/search,GET /api/v1/diagnostics/doctor,GET /api/v1/profiles,GET /api/v1/annotations/coverageandGET /api/v1/security/scans.The whole
/api/v1/servers/{id}subtree answers404 Server not foundfor a server outside the scope —tools,logs,tool-calls,diagnostics,scan/status,scan/report,scan/files,integrity,tools/export,tools/{tool}/diffand every sub-resource added later, since the gate is a middleware on the subtree. It is the same404a 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.logsmatters 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-callsand/tool-calls/{id}(plus its/replay). The entitlement is applied inside the query, sototaland 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_sizesis 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 fromGET /api/v1/servers/{id}/scan/status, which the subtree gate scopes.GET /api/v1/telemetry/payload— the heartbeat carriesserver_count,connected_server_count,tool_countandserver_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_countis an inventory size andconnected_client_idsis 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 documentGET /api/v1/configalready denies.
Withheld rather than denied.
GET /api/v1/statusstays open — agents legitimately poll it for liveness — but itsactivationblock is omitted for a scoped caller.mcp_clients_seen_everis the operator's MCP-client inventory andretrieve_tools_calls_24his 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/activeanswers403: 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 sameconfig_writepolicy as the other config-level writes.On the
/eventsstream, scoping applies per event, not only to theservers.changedserver list:- An event that names a server the token may not enumerate — through
server_name,server,target_serveroraffected_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.changedis 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, itsstatsrecomputed, and any coalescer extra that names an out-of-scope server ("server": "beta") is removed.config.reloaded,config.savedandsecrets.changedannounce mutations of the admin config document and are dropped, matching the403onGET /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.
-
Cached responses (
read_cache) — a truncated response is parked behind a cache key, and the key is a hash, not a credential. Every entry is stamped with the authorization that produced it (server scope, permission tier, profile pin, effective profile, caller kind).read_cacherefuses, on every page, any request whose own authorization could not have produced the entry, so a narrower token sharing the same MCP session cannot page a broader token's response. An unrestricted admin may read any entry; a token may read its own entries and those of tokens at least as narrow as itself. Profile scope is compared as a server set, so deleting or narrowing a profile after the entry was produced revokes cached access as well (a stale pin resolves to a deny-all scope and reads nothing). An unauthenticated/mcpcaller ranks below an authenticated admin: it cannot page an entry an API-key admin produced.
Administrative Operations Are Admin-Only
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:
- Lifecycle: add, remove, update/patch, enable, disable, restart, reconnect, refresh/discover-tools, add-from-registry, login/logout, move-config-value-to-secret
- Security state: quarantine, unquarantine, tool approve/block, and the security scanner (scan start/cancel, security approve/reject)
- Config & registries: applying/patching configuration (which can add/remove/enable/disable servers) and mutating registry sources — an agent must not bypass the per-server gate by rewriting config or a registry wholesale
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.
Profile Pinning
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>returns403— connecting to any profile URL other than the pinned one is forbidden; the pinned profile's own URL works.- The pin is the highest-precedence resolver source, above an explicit
/mcp/p/<slug>URL scope and above a sessionset_profileselection. - Every dispatch surface resolves it —
retrieve_tools,describe_tool,call_tool_*, thecode_executionsandbox, 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.
Managing Tokens
Token Limit
A deployment stores at most 100 agent tokens, and in the server edition
each signed-in user may hold at most 25 of them. Revoked tokens still
occupy a slot until they are permanently deleted, so once a limit is reached,
creating another token answers 409 Conflict:
- Your own quota (server edition, 25 per user). The message tells you it is your limit; permanently delete one of your unused tokens to free a slot. The quota keeps one user from taking the whole pool, but every stored token still counts toward the deployment limit below, so a deployment whose records add up to 100 refuses the next token for everyone. The quota is checked first: a user already at 25 always sees this message, whatever the deployment total.
- The deployment limit (100 stored records). In the personal edition every token belongs to the one operator, so this is the only limit and deleting one of your tokens frees a slot. In the server edition a caller who is still under their own quota gets this message; it says the limit is shared and points at an administrator, because deleting your own tokens may not free a slot that other users' records are filling.
List All Tokens
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
Show Token Details
mcpproxy token show deploy-bot
Revoke a Token
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
Delete a Token
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
Regenerate a Token
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.
JSON Output
All commands support JSON output for scripting:
mcpproxy token list -o json
mcpproxy token create --name bot --servers github --permissions read -o json
Activity Logging
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.
REST API
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 |
Create Token via API
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"
}'
Security Model
- HMAC-SHA256 hashing — raw tokens are never stored; only HMAC hashes are persisted
- Constant-time comparison — prevents timing attacks during token validation
- Automatic expiry — tokens expire after a configurable duration (default: 30 days)
- Revocation — tokens can be immediately invalidated
- Prefix identification — the
mcp_agt_prefix distinguishes agent tokens from admin API keys without database lookups - Tray bypass — local tray/socket connections always get admin access (authenticated by OS-level socket permissions)
Configuration Reference
Config File
{
"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 |
CLI Flags
mcpproxy serve --require-mcp-auth # Enforce /mcp authentication
Token Create Flags
| 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) |
Server-edition incident response
Administrators authenticated through a server-edition session or bearer JWT can list safe metadata for all owners with GET /api/v1/admin/tokens. Each entry includes user_id, name, scope, permissions, timestamps, prefix, profile pin, and revocation state. Raw credentials and token hashes are never listed.
Revoke one tenant credential with POST /api/v1/admin/users/{user_id}/tokens/revoke and JSON body {"name":"exact stored name"}. The name is body data so names containing slashes, percent signs, spaces, or Unicode remain addressable through routers and reverse proxies. The older POST /api/v1/admin/users/{user_id}/tokens/{name}/revoke form remains available for URL-safe names. Owner and name identify the credential together; another user's same-named token is unaffected. Revocation is durable and takes effect on the next authenticated request, including requests using an existing MCP session.
Owned tokens are checked against the owner's current server entitlement on every authentication. Unsharing an administrator-configured server removes it from a tenant token's effective scope without rotation or restart. Explicit scopes never gain additional servers; historical wildcard grants are bounded by current entitlement. Current administrator owners may still access administrator-configured servers. Missing owners, disabled accounts, and entitlement lookup errors fail closed. Ownerless operator tokens retain their existing behavior.
Calls already authorized and running are not cancelled. A long-lived SSE /events response revalidates its agent token before each status or runtime event: unsharing immediately narrows the next frame, while token revocation closes the stream before another event is delivered.
Sharing CLI status safely
mcpproxy status masks the API key in both its key field and Web UI URL across table, JSON, and YAML output. --show-key reveals the key. --web-url deliberately prints a usable login URL containing the unmasked key; treat that output as a credential. --reset-key also explicitly reveals the newly generated key.