Per-Route Permissions
Category: guide
Slug: per-route-permissions
Audience: Operators configuring read / read-write / admin access on separate MCP routes
Since: v1.1.0
What this feature does
FRISIAN_MCP_ROUTES lets you mount several MCP endpoints — typically a
read-only route, a read/write route, and an admin route — each on its own path,
each exposing its own subset of tools at its own permission ceiling.
The organizing principle is absence: a tool that is not allowed on a route
does not exist on that route. It is missing from tools/list and from
tools/call on that path, exactly as if it had never been registered — not
present-but-rejected. A caller cannot tell a denied tool apart from one that was
never built.
For the design rationale behind that guarantee, see ADR-010. This guide covers how to configure it.
The config surface
# settings.py
FRISIAN_MCP_ROUTES = {
"default": {"path": "/mcp", "highest_tier": "read"},
"elevated": {"path": "/mcp/elevated", "highest_tier": "read_write",
"allow_list": ["*"]},
"admin": {"path": "/mcp/ops", "highest_tier": "admin",
"allow_list": ["*"], "deny_list": ["billing"]},
}
There are three fixed tier keys — default, elevated, admin. Each maps
to a route config with these fields:
| Field | Meaning | Default |
|---|---|---|
path |
Operator-defined mount path (any string) | required |
highest_tier |
Permission ceiling: read < read_write < admin |
the tier key's secure default |
allow_list |
Tools/groups exposed on the route | [] (nothing) |
deny_list |
Carve-outs removed from allow_list |
[] |
auto_discover |
Newly discovered ViewSets join a ["*"] surface |
False |
auto_register |
Client self-enrollment (OAuth walk-up) | False |
A tier key you do not list is not mounted — it does not exist. Configure only
default and only /mcp exists.
Avoid
adminin thepath. A route mounted at/mcp/adminwas refused client-side by ChatGPT, Claude.ai and Grok — before any request reached the server. Renaming the path, tier key unchanged, let all three connect. The tier key isadmin; the path does not have to be.Other segments may behave the same way, and the server log is how you tell. A client refusing the path either sends nothing at all, or skips the challenge and fetches the bare
/.well-known/oauth-protected-resourceinstead of the path-suffixed form. That document names exactly one route; where it is not the route you configured, the connection then fails rather than falling back to it. If you see either shape, change the path suffix.
Permission tiers
Tiers are canonical and ordered: read < read_write < admin. There are
no synonyms — readonly, rw, read-only, and a bare write are rejected at
startup with ImproperlyConfigured. Use read_write, never write.
An omitted highest_tier on a configured route resolves to that key's secure
default (default → read, elevated → read_write, admin → admin) — it
never means "uncapped." The effective ceiling on any request is
min(token tier, route ceiling, FRISIAN_MCP_MAX_TIER).
Allow / deny: a deny-all firewall
The baseline is deny-all. A route exposes nothing until you open it.
allow_list: ["*"]— expose everything.*is dynamic: it also covers tools discovered later on a route withauto_discover: True.allow_list: ["catalog", "order"]— expose only those groups/tools.deny_list— a carve-out evaluated afterallow_list; a tool in both is denied.deny_listmay not contain*.
Entries address tools at two granularities, separated by a colon:
| Entry | Selects |
|---|---|
catalog |
the whole catalog group (dispatcher + all members) |
catalog:item |
just the item resource within catalog |
catalog:* |
normalized to catalog (the whole group) |
On a flat surface with no dispatch groups, a bare entry matches a tool name
directly. Partial globs (item*, cat_*) are not supported — they match
nothing and draw a soft startup warning.
Worked example — expose everything except one group on the read route:
"default": {"path": "/mcp", "highest_tier": "read",
"allow_list": ["*"], "deny_list": ["billing"]},
The billing tools do not exist on /mcp. A caller who holds billing-read
permission at the token layer still sees nothing for billing here — route-level
absence is authoritative over the per-token permission layer, and the two
compose without overlapping (see
Permission-Aware Discovery).
Startup audit
Route configuration is checked at startup. Misconfigurations are graded:
- FATAL (boot refused via Django system checks) — e.g. an open-world
defaultroute with a ceiling aboveread; a privileged (elevated/admin) route whose permission classes would admit anonymous callers; a route path that collides with another route or shadows a reserved package path. - LOUD (prominent startup warning) — e.g.
allow_list: [](the route exposes nothing); anallow_listfully cancelled bydeny_list; an anonymous-reachable SSE surface. - SOFT (logged, non-blocking) — e.g. an allow/deny entry that matched no
tool;
auto_discover: True; a globalFRISIAN_MCP_MAX_TIERcapping a route below its declared ceiling.
Run python manage.py mcp_doctor (see mcp_doctor) and
python manage.py check --deploy to surface these before deploying.
What is intentionally not here
- The path segment is not a credential. A route path is a routing label; it does no authorization on its own. Elevated and admin routes are guarded by the host auth backend and the token's permission tier, exactly as on a single-path deployment.
- A mounted route returns
401on bad auth; an unmounted path returns404. A prober can therefore learn a route path exists. This is standard web behavior and is not treated as a leak — do not attempt to mask it.
See ADR-010 for the full model, the absence invariants, and the alternatives that were rejected.