Permission-Aware Discovery
Category: guide
Slug: permission-aware-discovery
Audience: Developers configuring per-identity tool surface filtering in frisian-mcp
What This Feature Does
By default, frisian-mcp exposes the same tool surface to every caller of a given permission tier. All callers at the read tier see the same read tools; all callers at read_write see the same read-write tools.
FRISIAN_MCP_PERMISSION_AWARE_DISCOVERY changes this: it filters tools/list so that each caller sees only the tools their specific identity is permitted to use, based on Django's standard permission interface. An agent whose identity has view permission on DNS records but nothing else receives a tools/list containing only DNS read tools. Tools for other systems do not appear.
This serves two related goals:
- Agent focus — an agent assigned a narrow task works from a narrow surface. It does not need to reason about or navigate through operations unrelated to its task.
- Context reduction — out-of-scope tools are absent from the agent's
tools/list, so a compromised or prompt-injected agent is much less likely to be steered toward them. This shrinks the surface an agent is aware of; it is not an execution boundary — a caller that already knows or guesses a tool name can still attempt to invoke it, so execution must be enforced separately (see below).
Important: This feature controls tool visibility (discovery), not execution enforcement. Read Security Guidance before deploying this feature in production.
Enabling the Feature
Add to settings.py:
FRISIAN_MCP_PERMISSION_AWARE_DISCOVERY = True
This enables the filter with the default DjangoPermissionAdapter, which resolves each capability through Django's user.has_perm() — the same predicate the host authorizes with, so superuser status, EXEMPT_VIEW_PERMISSIONS, and custom auth backends are all honored natively. No adapter selection is needed for those cases.
By default, the feature is off. Default-off means upgrading installs see zero behavior change unless they explicitly opt in.
How the Filter Works
On every tools/list request, frisian-mcp:
- Resolves
request.user(the authenticated identity for this request) - Calls
adapter.is_unrestricted(user)— ifTrue(e.g. superuser), all tools are returned with no filtering - Calls
adapter.get_capabilities(user)— returns the set of"app_label.action_model"strings this user holds - Filters the tool registry: a tool is included only if the user holds the required permission for its content type and action
With the default DjangoPermissionAdapter, this adds one cached query per tools/list request and subsequent capability checks are O(1) in-memory lookups, so at 50 or 500 tools the overhead is negligible. A custom adapter's get_capabilities() is still called on every request — its cost depends on what it does, so cache any database or network work it performs; the negligible-overhead claim applies to the default adapter, not to arbitrary custom ones.
CRUD action mapping
Standard CRUD actions map automatically:
| ViewSet action | Permission required |
|---|---|
list, retrieve |
app_label.view_<model> |
create |
app_label.add_<model> |
update, partial_update |
app_label.change_<model> |
destroy |
app_label.delete_<model> |
Non-CRUD actions require explicit annotation (see backend_action below).
Dispatcher visibility
Group dispatchers are filtered: a dispatcher group tool is shown only if the user holds at least one permission covering a resource in that group.
Plain class-based dispatchers (registered via @mcp_dispatcher without group configuration) are always visible at runtime — per-content-type visibility filtering for class-based dispatchers is a V2 concern. This is separate from the E003 startup check below: a non-CRUD action still needs a backend_action annotation to pass E003 (which validates annotation completeness at startup), whether or not the dispatcher is visibility-filtered.
Custom @mcp_tool registrations (without model metadata) are always visible.
Superuser behavior
Superusers bypass the filter and see all tools. This matches the behavior of most Django backends where superusers implicitly hold all permissions regardless of explicit assignments.
Built-In Adapters
DjangoPermissionAdapter (default)
Works for any project using Django's standard auth backend. Resolves each capability through user.has_perm() — a strict superset of user.get_all_permissions() that also honors superuser status, EXEMPT_VIEW_PERMISSIONS, and custom auth backends, so what a caller can see in discovery matches what the host will actually authorize on invocation.
No configuration needed when FRISIAN_MCP_PERMISSION_AWARE_DISCOVERY = True — this adapter is used automatically.
ExemptViewPermissionAdapter (deprecated in 1.1.0 — do not use)
This adapter existed to patch a gap in the old get_all_permissions()-based default: on a host with an EXEMPT_VIEW_PERMISSIONS setting, a view-exempt model's tool was hidden from discovery even though the caller could still invoke it. Since 1.1.0 the default DjangoPermissionAdapter resolves capabilities through user.has_perm(), which honors EXEMPT_VIEW_PERMISSIONS (and custom auth backends) natively, so this adapter is now a deprecated no-op — it subclasses DjangoPermissionAdapter, adds nothing, emits a DeprecationWarning, and will be removed in the next minor release.
Migration: delete the setting; nothing replaces it. The default adapter is already correct on exemption-using hosts.
# Remove this — the default adapter handles view exemptions natively:
# FRISIAN_MCP_PERMISSION_ADAPTER = (
# "frisian_mcp.contrib.permissions.exempt_view_adapter.ExemptViewPermissionAdapter"
# )
Custom Adapter
To integrate with a non-standard permission backend, implement the PermissionAdapter protocol:
import logging
from frisian_mcp.contrib.permissions.base import PermissionAdapter
logger = logging.getLogger(__name__)
class MyPermissionAdapter:
def get_capabilities(self, user) -> frozenset[str]:
"""
Return frozenset of 'app_label.action_model' strings the user holds.
Return an empty frozenset on error (fail closed, not open).
"""
try:
return frozenset(str(p) for p in user.get_all_permissions())
except Exception: # narrow this to your backend's expected errors
# Log a stable, non-sensitive identifier only — never the raw user
# object or exception text, which can leak usernames, emails, or
# backend/request data into logs.
logger.warning(
"Permission adapter failed for user id=%s; returning no capabilities (fail-closed)",
getattr(user, "pk", "?"),
)
return frozenset()
def is_unrestricted(self, user) -> bool:
"""Return True when the user should see all tools (e.g. superuser)."""
return bool(getattr(user, "is_superuser", False))
Register it in settings:
FRISIAN_MCP_PERMISSION_ADAPTER = "myapp.permissions.MyPermissionAdapter"
The adapter is loaded once at startup and called on every tools/list request.
OAuth Configuration
When an OAuth token maps to a real Django user, discovery is filtered by that user's permissions. When it does not — the default OAuthServicePrincipal identity, used when no user mapping is configured — the request is treated as a service principal: it bypasses capability filtering entirely and the permission tier is the sole gate. Such a client sees every tool at or below its tier, not a filtered per-identity surface. Map the client to a Django user (below) when you want its discovery scoped by that user's permissions.
Mapping a client to a Django user is optional: an unmapped client falls back to the service-principal behavior above (tier-only gating), so leaving it unmapped is a supported configuration, not a startup error.
Per-client user (recommended)
Each OAuthClient record has a user field in the admin. Set it to the Django user whose permissions should define that client's tool surface. This gives independent scoping per OAuth client.
OAuthClient "dns-agent"
└─ user: dns_service_account
OAuthClient "device-agent"
└─ user: device_service_account
Each agent's discovery is scoped to its configured user; execution is authorized as that user only when the execution path enforces the resolved Django user.
Global fallback (FRISIAN_MCP_OAUTH_SERVICE_USER)
When all OAuth clients should use the same execution identity:
FRISIAN_MCP_OAUTH_SERVICE_USER = "mcp_service_account"
If neither per-client user nor the global fallback is set, unmapped clients fall back to service-principal (tier-only) discovery — no startup error is raised.
Startup Checks
E003 — Unannotated non-CRUD action
Trigger: FRISIAN_MCP_PERMISSION_AWARE_DISCOVERY = True and a @mcp_dispatcher has a non-CRUD action without a backend_action annotation. This is a startup-validation check on annotation completeness; it fires regardless of whether the dispatcher is visibility-filtered at runtime (class-based dispatchers are not filtered, but their non-CRUD actions still need the annotation so the permission mapping is unambiguous).
Fix: Add backend_action to the @mcp_action decorator (see below).
backend_action for Non-CRUD Actions
Standard CRUD actions (list, retrieve, create, update, partial_update, destroy) map to Django permission verbs automatically. Custom actions do not — they require explicit annotation.
from frisian_mcp.decorators import mcp_dispatcher, mcp_action
@mcp_dispatcher(
name="network_device",
description="Dispatch network device operations.",
)
class NetworkDeviceDispatcher:
@mcp_action(name="list", description="List devices.")
def list(self, request, params): # CRUD — no annotation needed
...
@mcp_action(
name="diagnostics",
description="Run a diagnostics check on a device.",
backend_action="view", # maps to app_label.view_<model>
)
def diagnostics(self, request, params): # non-CRUD — annotation required
...
Valid backend_action values are the Django permission verbs: "view", "add", "change", "delete", or any custom action string your backend supports. The adapter's get_capabilities() result is checked against f"{app_label}.{backend_action}_{model}".
If backend_action is missing on a non-CRUD action and FRISIAN_MCP_PERMISSION_AWARE_DISCOVERY is enabled, startup check E003 fires.
V1 Scope and Limitations
- Content-type + action granularity only. Discovery filters at the model level, not the object level. An agent scoped to "devices in region X" sees device tools, not only region-X device tools. Object-level authorization is not provided by this feature and is not automatic in Django — the host application must enforce it during execution (for example per-user queryset scoping such as Nautobot's
restrict(), or explicit object-permission checks). Discovery filtering does not substitute for that. - Class-based dispatchers are not filtered. Only group-based dispatcher tools participate in the capability filter. This limitation is documented in ADR-008 and will be addressed in V2.
- Anonymous callers. Anonymous users are not authenticated, so
get_capabilities()returns an empty set under most auth backends. An anonymous caller will see no tools whenFRISIAN_MCP_PERMISSION_AWARE_DISCOVERYis enabled.
Related
- Permission-Aware Discovery — Security Guidance — the discovery vs. execution gap, service account configuration, and production deployment
- Dispatcher Pattern — how
@mcp_dispatcherand@mcp_actionwork - Installation & Configuration Reference — full settings reference for all
FRISIAN_MCP_PERMISSION_*settings