Skip to main content

Profiles

A profile is a named view over your upstream servers plus a tool policy. It decides which servers a caller reaches, which of their tools the caller can discover and call, and whether the caller gets code execution and the management tools. The same profile is used by every surface that lets you work with MCPProxy: the config file, the Web UI and macOS app (Profiles in the sidebar), the CLI (mcpproxy profile ...), the MCP profiles tool and the REST API.

A profile is enforced by the core, on every path a tool can be discovered or called through. "Work Read-only" really is read-only: a session under it cannot find, describe or call a write, destructive, denied or unclassified tool, whichever way it connected.

Three ideas fit together:

  • A profile is the policy: servers, a tier cap, tool rules.
  • A binding attaches a profile to a client's credential or to an agent token, so the profile follows the caller without any URL or setup in the client. A binding is locked (the caller can never leave it) or switchable (within the profile's switchable_to).
  • anonymous_profile confines callers that present no credential.

Quick start​

{
"require_mcp_auth": true,
"profiles": [
{
"name": "work-readonly",
"title": "Work · Read-only",
"servers": ["github", "notion"],
"max_tier": "read"
},
{ "name": "work-full", "servers": ["github", "notion", "filesystem"] }
]
}

Then bind a client and a token to it (the client's own config file is written once, with its own credential):

mcpproxy connect cursor --profile work-readonly --lock
mcpproxy token create --name ci --profile work-readonly
mcpproxy profile show work-readonly --effective

See Profile and client commands for every command, and Connect clients for the credentials.

Profile fields​

FieldValuesDefaultMeaning
nameslug ^[a-z0-9][a-z0-9_-]{0,62}$requiredReserved: all, code, call, p (URL segments) and active, try (REST routes)
serverslist of server namesrequiredThe servers the profile reaches. An empty list is a legal "deny everything" profile
titletext, at most 80 charactersnameShown by the UIs, the explainer and activity. Never shown to an agent
descriptiontext, at most 500 charactersnoneFree text
max_tierread, write, destructiveno capThe highest tool tier the profile admits
unannotateddeny, as_write, as_readsee belowWhat to do with a tool that declares no tier
tools.allowserver:tool patternsnoneAdmit a tool the cap would hide (not a server outside servers)
tools.denyserver:tool patternsnoneHide a tool. Deny beats allow when both match
tools.classifymap server:tool to read, write or destructivenoneGive an unannotated tool a tier in this profile. Ignored for a tool that carries its own annotations
code_executiontrue, falseinheritWhether the code_execution tool exists for the profile
management_toolstrue, falseinheritWhether upstream_servers and quarantine_security are visible under the profile
switchable_tolist of profile namesnoneThe profiles a client bound to this profile (or a confined anonymous caller) may switch to with set_profile

The field names are identical in the config file, REST, the CLI (as kebab-case flags, --max-tier), the MCP profiles tool and the UIs. Enum values are spelled the same everywhere (as_write, as_read).

Fail-closed defaults. With max_tier read or write, an unset unannotated means deny and an unset code_execution means off, because a tool of unknown risk, or a tool built to orchestrate other tools, would otherwise sidestep the cap. With max_tier destructive or no cap, unannotated defaults to as_read and code_execution follows the global enable_code_execution setting. A profile can only narrow: it never widens a token's permissions, bypasses quarantine or approval, or overrides read_only_mode, disable_management or enable_code_execution.

Legacy profiles keep working. A profile that sets only name and servers behaves exactly as before: no cap, unannotated tools count as read, no rules.

Tiers​

A tool's tier comes from its MCP annotations: destructiveHint: true is destructive; readOnlyHint: false is write; readOnlyHint: true is read; a tool with neither hint is unannotated. MCPProxy never guesses a tier for an unannotated tool; a profile decides through unannotated or a tools.classify entry.

How a tool is decided​

One predicate computes the decision, and everything uses it: discovery, execution, the Tools "view as" listing, "Try it" and the explainer. In order:

  1. The tool's server is not in servers: server_not_in_profile.
  2. The tool matches a tools.deny pattern: denied_by_rule.
  3. The tool matches a tools.allow pattern: admitted, even above the cap.
  4. The tool is unannotated, unclassified and unannotated is deny: unannotated_hidden.
  5. The tool's tier is above max_tier: above_tier_cap.
  6. Otherwise admitted.

Patterns are server:tool with * as the only wildcard, matched case-sensitively. A pattern naming a server outside servers is saved with a warning and ignored.

What a caller sees​

SurfaceA tool the profile excludes
retrieve_toolsLeft out before the result limit, and counted in hidden_by_profile without naming any of them. The response names the caller's own profile (profile) when it came from the caller's own credential or choice, never the operator's anonymous profile
describe_toolThe same not-found answer as a tool that does not exist
call_tool_read, call_tool_write, call_tool_destructive, /mcp/all, REST /tools/callRefused before any upstream call with blocked by profile: <server>:<tool> is a <tier> tool; profile "<title>" (<slug>) allows <cap> tools only, or ... is denied by a rule in profile "<title>" (<slug>), or ... has no tier annotation; an operator can classify it in profile "<title>" (<slug>) to allow it
code_executionAbsent and refused when the profile turns it off; every nested call_tool goes through the same gate

A refusal names the profile only to a caller whose effective profile is its own: one that came from the caller's pin, its client binding, the URL or set_profile, so an agent can tell the operator which profile to change. A caller that connects without a credential and falls under anonymous_profile gets the same refusal without the profile name (... this profile allows <cap> tools only, ... is denied by a profile rule, ... in the profile to allow it), and so does a caller whose profile no longer exists, so the operator's anonymous confinement is never handed out. The title is quoted and escaped, so it cannot add a line to the refusal. The operator sees the profile in the activity record and in the explainer. A blocked call is recorded with status=blocked and block_reason profile_tier, profile_rule or profile_unannotated (profile_code_execution and profile_management for the tools above).

A profile does not change prompts: tier and rules apply to tools only.

Which profile applies​

When more than one source could select a profile, the highest wins:

#SourceNotes
1pinA locked client credential, or an agent token with a profile. Server-enforced and immutable for the connection. A pin whose profile was deleted denies everything instead of widening
2url/mcp/p/<slug>. Authoritative for that request. A locked or confined caller may name only its own profile (or, if switchable, one in switchable_to)
3sessionThe set_profile selection. Re-checked on every request
4bindingThe profile of a switchable client credential
5anonymousanonymous_profile, for a caller with no credential
6noneNo profile: all servers

Activity records, session rows and the Clients page show the profile together with its source (pin, binding, url, session, anonymous). The UIs word them as "locked by credential", "switchable", "from URL", "switched in session" and "anonymous".

Client credentials and bindings​

mcpproxy connect (and the Connect screens) never write the instance admin API key into a client's config. They mint a per-client credential (mcp_cli_...), bound to a profile, valid on MCP endpoints only. Reassigning a client to another profile takes effect on its next request without touching its config file, and its live session is told its tool list changed. Reassign from the Web UI Clients page, the macOS Clients view or tray submenu, mcpproxy client set-profile, or the profiles MCP tool. Details are in Connect clients.

A locked client cannot switch: set_profile and /mcp/p/<other> get the same refusal as an unknown profile. A switchable client may switch to the profiles in its bound profile's switchable_to and nowhere else.

Callers without a credential​

Set anonymous_profile to confine every caller that presents no credential (or an unrecognised token while require_mcp_auth is off) to that profile. Set it in the config, in Settings, Security, Anonymous callers (Web and macOS) or with mcpproxy profile anonymous <name>. Changing it applies immediately, without a restart.

The binding guard​

While require_mcp_auth is off, a client bound to a named profile could escape it by simply omitting its credential. MCPProxy refuses any change that would leave such a binding bypassable: connecting, rebinding, bulk moves, and also profile edits, classification changes, deletes and reassign_to that narrow what a binding reaches or widen what anonymous callers reach. The refusal is 409 with code binding_bypassable_without_auth:

a client bound to profile <p> could escape it by omitting its credential while require_mcp_auth is off

It lists the bindings concerned and two fixes: turn require_mcp_auth on, or set anonymous_profile to a profile that is not wider than the binding. After either, the same request succeeds. "Wider" compares everything a caller can reach: servers, tier cap, unannotated handling, allow and deny rules, switchable_to reachability and the code-execution and management capabilities. The refusal text and code are the same on REST, the CLI, the Web UI, macOS and MCP.

Management tools​

management_tools decides whether upstream_servers and quarantine_security are visible under a profile: false hides and refuses them, true shows them, unset keeps the pre-profile behaviour. It never widens a credential: an agent token or client credential, and a confined anonymous caller, can only list and tail_log servers inside their scope, and quarantine_security stays administrator-only; adding, changing, restarting or removing servers remains an operator action. The profiles tool is separate: it is listed only for an administrator session (API key or socket), and only when the session's effective profile is none or sets management_tools: true.

Managing profiles from every surface​

Web UImacOSCLIMCPREST
List with tool counts by tierProfiles pageProfiles viewprofile listprofiles listGET /profiles
Effective tools with reasonsEditor tableEditor tableprofile show --effectiveprofiles effective_toolsGET /profiles/{name}/effective-tools
Create, updateEditorEditorprofile create, updateprofiles create, updatePOST /profiles, PUT /profiles/{name}
Rename, deleteEditorEditorprofile rename, deleteprofiles rename, deletePOST /profiles/{name}/rename, DELETE /profiles/{name}
Classify a toolEditor tableEditor tableprofile classifyprofiles classifyPUT /profiles/{name}
Try a draftTry itTry itprofile trynot offeredPOST /profiles/try
Explain accessExplain accessExplain accessaccess explainprofiles explainGET /access/explain

Try it runs a real retrieve_tools under the unsaved draft and shows what is returned and what is hidden with reasons, without saving anything.

Deleting a profile that clients, tokens or anonymous_profile still use is refused with 409 profile_in_use (listing who) until you give reassign_to; force leaves the references dangling, which denies everything and never widens. A profile that is the anonymous_profile is refused even with force. Renaming moves every pin, binding, switchable_to and anonymous_profile reference to the new name.

The access explainer answers "why can't Cursor use github:create_issue?" by walking the chain in the order enforcement uses: credential, profile, server in scope, tool rule, tier cap, token permission, global gate, server state, tool approval. It stops at the first failing link, names the fix, and its verdict (allowed, blocked, hidden) is computed by the same predicate as an actual call.

Attribution and filters​

Every activity record carries the profile, its source, the client id, the token name and (advisory) the client's self-reported name. Activity, Sessions, Usage, Tools, Servers, Clients and Tokens can be filtered by profile, client and token in the Web UI (the filters live in the URL, so a link keeps them), in the macOS app, with mcpproxy activity list --profile --client --token, and with the REST query parameters of the same names. A record keeps the profile it was made under, so filtering by the profile a client had before you reassigned it still lists the earlier calls. /tools?client=cursor shows exactly what Cursor can see and call, with the reason for every row it cannot.

Upgrading and downgrading​

  • Upgrading. Existing profiles and tokens keep working. Existing client entries that hold the admin API key or no credential are reported on the Clients page and by mcpproxy doctor; nothing is rewritten automatically. Use the previewed "Upgrade all clients holding the admin key" action (mcpproxy client upgrade-admin-key-holders), then rotate the admin API key.
  • The old "active profile". GET and PUT /api/v1/profiles/active still answer, with a Deprecation: true header, for one more minor release and are called by no first-party surface. The Web UI header no longer has a "Profile:" switcher; a Viewing chip filters what you look at and is labelled as a view filter.
  • Downgrading. A pre-profiles-v3 binary does not recognise mcp_cli_ credentials. With require_mcp_auth on it rejects them (401); with it off it treats them like an omitted credential, which gives them unconfined access, and it knows nothing of anonymous_profile or bindings. Turn require_mcp_auth on before downgrading to a pre-108 binary, otherwise bound clients run unconfined.

URL profiles and set_profile​

Profiles are also addressable as permanent URLs at /mcp/p/<slug>, and an agent can switch inside a live session with set_profile. These mechanics are unchanged and are described below.

Stateful selection: set_profile​

The set_profile MCP tool switches the active profile inside a live session — no reconnect, no re-index:

// request
{ "name": "set_profile", "arguments": { "profile": "research" } }
// result
{ "active_profile": "research", "servers": ["research-srv"] }
  • The selection is keyed by the MCP session id (stable per streamable-HTTP / SSE connection) and persists for the lifetime of that session.
  • It applies to subsequent retrieve_tools, call_tool_*, code_execution and direct-mode (server__tool) calls on the base /mcp endpoint — retrieve_tools searches the profile's per-profile index directly.
  • Passing an empty string ("") clears the selection and returns to all servers. active_profile always reports the stored session selection — "" after a clear, even for a token with a profile_pin — while servers reports the effective scope the session can actually reach after the update: the pin's servers for a pinned token (nothing once the pinned profile has been deleted), the URL profile on a /mcp/p/<slug> endpoint, otherwise the selection or every configured server.
  • The servers list is always bounded by the caller's credential, using the same rule that scopes retrieve_tools: for an agent token scoped to specific servers it is the intersection of the effective profile (resolved pin > URL > session, see Which profile applies) with the token's allowed_servers, so a token restricted to one server is never told about the others. On a /mcp/p/<slug> endpoint the URL still governs the request, so set_profile("other") there stores other as active_profile but reports <slug> ∩ allowed_servers in servers. API-key and socket callers see the full lists.
  • An unknown slug is rejected. An administrator (API key, socket, anonymous back-compat) gets the discovery affordance: unknown profile '<slug>' (available: research, deploy). An agent token gets unknown profile '<slug>' with no list at all: it may select only the profiles overlapping its allowed_servers (or its pin while the pin still has reach), and a profile entirely outside its reach (an empty profile, a profile whose servers are all outside allowed_servers, or the token's own pin once it no longer exists or no longer overlaps the token's servers) is rejected with that same error rather than confirmed as existing. A pinned token asking for any profile OTHER than its pin (see profile pinning) is rejected with that same unknown profile '<slug>' error too — never a distinct "pinned to..." message, which would let the token confirm from the wording alone that it is pinned, and to what, from a refusal aimed at a different slug. The check looks only at the requested slug (and the token's pin) and tests the token's own allowed_servers against that profile's precomputed server set — its cost does not depend on how many other profiles are configured, on how many servers the requested profile declares or on how many servers are configured at all, only on the size of the token's own grant — so a token cannot learn which profiles or servers exist, or whether it is pinned, from set_profile, by body or by timing.
  • Session state is cleared automatically on session close.

set_profile is available on the default /mcp server and the call_tool / code_execution routing-mode servers.

REST API​

Method & pathDescription
GET /api/v1/profilesList profiles with their policy, effective servers, tool counts by tier, the unannotated count, who uses each (administrators only) and last-24h calls and blocked calls
POST /api/v1/profiles, PUT /api/v1/profiles/{name}, DELETE /api/v1/profiles/{name}, POST /api/v1/profiles/{name}/renameCreate, replace, delete (reassign_to, force) and rename
GET /api/v1/profiles/{name}/effective-toolsEvery tool with its verdict and reason
POST /api/v1/profiles/tryRun retrieve_tools under an unsaved draft
GET /api/v1/access/explainExplain access for a client, token, profile or anonymous caller
GET /api/v1/profiles/active, PUT /api/v1/profiles/activeDeprecated: the server-level default for UI surfaces. They send a Deprecation: true header and are removed in the next minor release

The deprecated "active profile" is independent of, and does not override, a live MCP session's set_profile selection. All responses use the standard { "success", "data" } envelope and require the API key. Clients, tokens and activity filters are in the REST API reference.

Activity logging​

Tool-call activity records carry the profile and its source (profile, profile_source), plus client_id, token_name and block_reason when they apply. Records written before profiles v3 keep the legacy metadata["profile"] and remain filterable.

Per-profile search index​

Each profile gets a physically separate Bleve index so switching profiles is fast and a config reload that changes one profile does not re-index the others.

Layout under the data dir (~/.mcpproxy/ by default):

index.bleve/ # shared default index — all servers' tools (used by /mcp)
index.bleve/profiles/<slug>/ # one index per profile — only that profile's servers' tools

Notes:

  • Per-profile indexes live under index.bleve/profiles/ (not directly under index.bleve/<slug>/) so they never collide with Bleve's own internal files and store/ subdirectory.
  • A per-profile index is a derived view: it is (re)built from the shared default index, so the shared index remains the source of truth and the allow-all fallback for /mcp.
  • <slug> is the validated profile name (^[a-z0-9][a-z0-9_-]{0,62}$), so the directory name is always filesystem-safe.

Lifecycle:

EventEffect
Profile added / first useIts index is built lazily from the shared index.
A member server's tools changeOnly the profiles that include that server are rebuilt.
Profile membership changes on reloadOnly the affected profile is rebuilt; others are untouched.
Profile removed from configIts index directory is deleted (including orphans left by a prior run).
Server disabled / quarantinedProfiles that include it are refreshed so its tools drop out.

Hot reload​

Profile changes, and a change of anonymous_profile, take effect without a restart: new requests use the new policy and the sessions a change governs receive notifications/tools/list_changed. In-flight sessions keep a consistent snapshot for the request they are serving.

404 responses​

For API-key, socket and (when require_mcp_auth is off) unauthenticated callers:

ConditionBody
No profiles configured{"error":"no profiles configured"}
Unknown slug{"error":"unknown profile '<slug>'","available":["research","deploy"]}

An agent token may initialize through /mcp/p/<slug> only when that profile is one it could select with set_profile — its servers overlap the token's allowed_servers, or it is the token's pin and the pin still has reach. Every other request — a missing or deleted slug, a configured profile outside the token's reach, an empty profile, a pin mismatch, the slug-less /mcp/p and /mcp/p/, and an empty fleet — receives one and the same 404 {"error":"unknown profile '<slug>'"} with no available list, and the check itself looks only at the requested slug (and the token's pin), testing the token's own allowed_servers against that profile's precomputed server set — its cost does not depend on how many other profiles are configured, on how many servers the requested profile declares, on how many servers are configured at all, or on whether this is the first request after a reload (the profile index is rebuilt when the configuration changes, not on demand); it scales only with the size of the token's own grant — so a scoped caller cannot learn which profiles or servers exist from the profile URL, by body or by timing. The refusal is silent towards the agent only: each one is logged (profile URL refused for scoped caller, with the token name, the requested slug and the remote address) so an operator can spot a token probing the slug space.