Skip to content

Auth

Backends, permissions, response builders, and the RFC 9728 PRM view.

Protocols

MCPAuthBackend

Bases: Protocol

Pluggable authentication for the MCP transport.

The transport calls :meth:authenticate on every request. A backend that returns None signals "no valid credentials" — the transport then emits a 401 with WWW-Authenticate built from :meth:www_authenticate_challenge. protected_resource_metadata powers the /.well-known/oauth-protected-resource view (RFC 9728).

Backends MUST be safe to instantiate without arguments — settings-driven configuration belongs inside the backend's own module.

TokenInfo dataclass

Authenticated principal carried alongside an MCP request.

Backends construct this once per request and attach it to the Django HttpRequest (as request.mcp_token). Permission classes consult it to gate tool/resource access.

  • user: the resolved Django user (or AnonymousUser-equivalent). Any here because the user model is project-defined.
  • scopes: OAuth scopes proven by the bearer token.
  • audience: the aud claim — must match the canonical /mcp URL per RFC 8707; backends are responsible for that comparison.
  • raw: backend-specific opaque payload (the AccessToken row, the JWT claims dict, etc.) for advanced use cases.

MCPPermission

Bases: Protocol

Per-tool / per-resource gate evaluated after authentication.

The transport pulls authenticated state from the request as a :class:TokenInfo and asks each permission whether the call may proceed. Returning False becomes a 403 + WWW-Authenticate; raising lets the permission supply a richer payload via the JSON-RPC error path.

Permissions MUST be cheap to construct (we instantiate them per-binding at discovery time) and side-effect free at evaluation.

required_scopes

required_scopes() -> list[str]

Scopes to advertise in WWW-Authenticate when this permission denies.

Defaulting via Protocol: implementations that don't override this should return []. We type it as a method (not a property) so it's easy to compute dynamically from constructor args.

Backends

AllowAnyBackend

Development / test backend that authenticates every request as anonymous.

DO NOT use in production. The protected_resource_metadata payload is intentionally minimal so misconfiguration is loud rather than silent.

DjangoOAuthToolkitBackend

Resource-server adapter for django-oauth-toolkit (DOT).

Validates the bearer token using DOT's own validators, then projects the result into a :class:TokenInfo. The oauth2_provider package is imported lazily inside authenticate because it's an optional extra (pip install "djangorestframework-mcp-server[oauth]") — importing this module never blows up just because DOT is absent; the ImportError only fires when a request actually reaches authentication.

Audience enforcement (RFC 8707): when REST_FRAMEWORK_MCP["RESOURCE_URL"] is configured, the token's resource claim must match exactly. Tokens without a bound resource are rejected. Setting RESOURCE_URL to None (the default) disables enforcement — appropriate for dev or for setups where audience binding is performed by an upstream gateway.

Permissions

ScopeRequired

Allow only requests whose token carries every listed OAuth scope.

Constructor takes the scopes positionally so usage stays compact: ScopeRequired(["invoices:write"]).

DjangoPermRequired

Allow only requests whose user has the given Django permission(s).

Wraps user.has_perm from the standard Django auth backend. A token backed by AnonymousUser will always be rejected — that is the point of using this class instead of :class:ScopeRequired.

Rate limits

MCPRateLimit

Bases: Protocol

Per-binding rate limiter, evaluated after authentication and permissions.

The single consume call is the gate AND the bookkeeping update — there is no separate "check then commit" because that pattern races under concurrency. Implementations decrement quotas atomically in storage; the return value is the suggested Retry-After in seconds when the limit has been hit, or None to allow the call.

Returning 0 is allowed (e.g. "denied but window resets immediately") but most implementations will return a positive integer.

Limiters are constructed per-binding at registration time; keep them cheap to construct and thread-safe at evaluation. State that crosses requests must live in shared storage (Django cache, Redis, …), not on the instance — instance state would be lost the moment the binding is re-used across worker processes.

FixedWindowRateLimit

A fixed-window-counter rate limiter backed by django.core.cache.

The window is bucketed by absolute time: each integer multiple of per_seconds since the epoch starts a new counter. Simple and sufficient for protecting against runaway clients; not as smooth as a sliding window but doesn't require a sorted set.

The cache key is namespaced with namespace so multiple limits on the same binding (e.g. burst + steady-state) don't share counters. key customises the bucket dimension — defaults to per-token-user.

The cache must be a shared backend in multi-process deployments; Django's locmem cache is fine for tests but won't enforce a global limit across worker processes.

SlidingWindowRateLimit

Sliding-window rate limiter using a list of timestamps in cache.

Avoids the well-known fixed-window edge case where a client can issue 2 * max_calls requests across two adjacent windows. Stores the timestamps of recent calls in a Django cache entry; on each call, expired entries are pruned and the live count is compared against max_calls.

Trade-offs vs :class:FixedWindowRateLimit:

  • Smoother: limits actual request rate over the trailing per_seconds window, not bucketed counts.
  • Memory cost: the timestamp list grows with traffic up to max_calls entries per key; negligible for typical limits.
  • Read-modify-write: doesn't have the atomic guarantees of the fixed-window's cache.add + cache.incr primitives. Concurrent calls can see slightly stale state and admit a small number of extra requests under contention. For strict atomicity in multi-worker deployments, use a Redis-backed limiter with Lua.

The cache must be a shared backend in multi-process deployments; Django's locmem works for tests but won't share state across workers.

TokenBucketRateLimit

Token-bucket rate limiter using Django cache for state.

A bucket has at most capacity tokens. Each accepted call consumes one token; the bucket refills continuously at refill_per_second tokens per second. When empty, the limiter returns the suggested retry-after time until at least one token is available again.

Trade-offs vs the sliding-window scheme:

  • Burst-friendly: a full bucket can absorb a burst of capacity requests instantly, then rate-limit the steady state at refill_per_second. Useful when consumers naturally batch.
  • Read-modify-write: like the sliding-window class, this is not strictly atomic across concurrent workers. Under contention a small number of extra tokens may slip through. For strict atomicity in multi-worker deployments, back the limiter with a Redis-Lua script.

The cache must be a shared backend (Memcached or Redis) in multi-process deployments; Django's locmem is fine for tests but won't share state across workers.

Response helpers

build_unauthenticated_response

build_unauthenticated_response(challenge: str) -> HttpResponse

Build a spec-compliant 401 response with the supplied WWW-Authenticate value.

The body is a small JSON envelope so MCP clients that surface error payloads to the user see a meaningful message rather than an empty body.

build_insufficient_scope_response

build_insufficient_scope_response(challenge: str) -> HttpResponse

Build a 403 response signalling missing OAuth scope.

Per RFC 6750, the error="insufficient_scope" value belongs in the WWW-Authenticate header — that's already baked into challenge.

ProtectedResourceMetadataView

Bases: View

RFC 9728 OAuth 2.0 Protected Resource Metadata endpoint.

Mounted at /.well-known/oauth-protected-resource by :class:MCPServer. Delegates payload construction to the configured :class:MCPAuthBackend.protected_resource_metadata. The backend is instance-scoped — passed in via as_view — so multiple servers in one process can advertise different metadata.