Auth¶
Backends, response builders, rate limits, and the RFC 9728 protected-resource metadata view.
A tool or resource is guarded by its spec's own permissions, the kernel's
PermissionCheck, and by the
scopes= gate at registration; see
Who may call a tool. A prompt or an
interactive view takes the same kernel checks through permissions=, each
handed the prompt's or view's binding as the spec, and the same scopes=; see
Prompts and views.
Protocols¶
MCPAuthBackend ¶
Bases: Protocol
Pluggable authentication for the MCP transport.
The transport calls authenticate on every request; returning
None signals "no valid credentials" and the transport emits a 401 whose
WWW-Authenticate comes from www_authenticate_challenge.
protected_resource_metadata powers the
/.well-known/oauth-protected-resource view (RFC 9728); the PRM view
calls .to_dict() on the returned dataclass for the wire shape.
The backend is the resource server's half of OAuth: the authorization server that issues the tokens publishes its own metadata, so nothing here describes it.
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). A binding's scope gate reads its
scopes, and a spec's PermissionCheck reads it off the request in
call_context.
Attributes:
| Name | Type | Description |
|---|---|---|
user |
Any
|
The resolved Django user, or an |
scopes |
tuple[str, ...]
|
OAuth scopes proven by the bearer token. |
audience |
str | None
|
The |
raw |
Any
|
Backend-specific opaque payload — the |
SelfChecking ¶
Bases: Protocol
An auth backend that can say whether it is able to serve requests.
Deliberately a second protocol rather than a method on
:class:~django_spec_mcp.auth.types.auth_backend.MCPAuthBackend. Adding
it there would make it required, and a backend already written against the
four-method protocol would stop satisfying it -- so the one thing this
exists to prevent, a transport mounted with a backend that cannot run,
would be bought by breaking every backend anyone has already written.
Structural and runtime-checkable, so a backend opts in by having the method and nothing has to register anywhere. A backend that does not implement it is treated as ready, which is the right default: needing no setup is the ordinary case, and a backend that needs some is the one able to say so.
It is called when the server is mounted, and that is the whole point.
A backend becomes load-bearing at the moment HTTP requests become possible,
not when the server object is built: an in-process server -- call_tool,
list_tools, the in-process route django-ag-ui uses -- never
authenticates anything, so requiring a backend's optional dependency to
construct one would refuse a supported deployment. Mounting is the
earliest point at which the requirement is real, and it is still startup:
server.urls runs while the URLConf is imported, so manage.py check
reaches it and a missing dependency is a refusal at boot rather than a 500
on the first request.
check_configuration raises -- ImproperlyConfigured, naming what is
missing and how to supply it -- or returns None. It must not return a
bool: a caller that has to interpret a False has been given an error that
knows more than it says.
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.
This package ships no backend that verifies tokens; a project writes its own
(Writing your own backend). A backend of
its own compares the token's audience with audience_matches, the exact
comparison RFC 8707 asks for, and builds its 401 challenge with
bearer_challenge.
audience_matches ¶
Return True if the token's audience satisfies the configured resource URL.
RFC 8707 audience binding. expected is None disables enforcement (the
binding happens upstream, e.g. at a gateway). Otherwise the comparison is
exact — token audiences are URLs, not patterns — and a token carrying no
audience is rejected rather than accepted, which is the whole point of
configuring the canonical URL.
bearer_challenge ¶
bearer_challenge(
*,
resource_metadata_url: str | None,
scopes: list[str] | None = None,
error: str | None = None,
) -> str
Build the WWW-Authenticate value for a 401 or an insufficient-scope 403.
A backend's www_authenticate_challenge returns this, so the format is
decided here once. resource_metadata_url is required by name because it
is the parameter a client reads to find the authorization server (RFC 9728):
a challenge missing it fails with no error on either side, and None is
the explicit way to say a backend has no metadata document to point at.
Pass the URL the server's protected-resource-metadata route reverses to.
The parts come in the order a client parses them: the realm, then the
metadata pointer, then error, then scope (the scopes joined by a
space, which is how RFC 6750 spells a scope list). A part that is None
or empty is left out.
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 and
return the suggested Retry-After in seconds once the limit is hit
(0 is legal, meaning the window resets immediately), or None to
allow the call.
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, which is not shared 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. Not as smooth as a
sliding window, but it needs no sorted set.
namespace keeps multiple limits on one binding (burst plus
steady-state) from sharing counters; key customises the bucket
dimension and defaults to per-token-user.
The cache must be a shared backend in multi-process deployments;
Django's locmem is fine for tests but enforces no global limit across
worker processes.
SlidingWindowRateLimit ¶
Sliding-window rate limiter using a list of timestamps in cache.
Avoids the fixed-window edge case where a client issues 2 * max_calls
requests across two adjacent windows: the timestamps of recent calls are
stored in a cache entry, and each call prunes the expired ones and compares
the live count against max_calls.
Trade-offs against
FixedWindowRateLimit:
- Smoother: limits the actual rate over the trailing
per_seconds, not bucketed counts. - Memory cost: up to
max_callstimestamps per key. - Read-modify-write: no atomic guarantee, unlike the fixed window's
cache.add+cache.incr. Concurrent calls can read stale state and admit a few extra requests under contention; for strict atomicity in a multi-worker deployment, back the limiter with a Redis-Lua script.
The cache must be a shared backend in multi-process deployments;
Django's locmem works for tests but shares no state across workers.
TokenBucketRateLimit ¶
Token-bucket rate limiter using Django cache for state.
A bucket holds at most capacity tokens, each accepted call consumes
one, and the bucket refills continuously at refill_per_second. When
empty, the limiter returns the time until one token is available again.
Trade-offs against the sliding-window scheme:
- Burst-friendly: a full bucket absorbs
capacityrequests instantly, then holds the steady state atrefill_per_second. Useful when consumers naturally batch. - Read-modify-write: like the sliding window, not strictly atomic across workers, so a few extra tokens can slip through under contention. For strict atomicity, 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 shares
no state across workers.
Response helpers¶
build_unauthenticated_response ¶
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 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.
Protected Resource Metadata (RFC 9728)¶
The view serving the document is a plain Django view, mounted under the
server's URLs as protected-resource-metadata.
ProtectedResourceMetadataView ¶
Bases: View
RFC 9728 OAuth 2.0 Protected Resource Metadata endpoint.
Mounted at /.well-known/oauth-protected-resource by
MCPServer. A plain Django view
answering GET, wired as
ProtectedResourceMetadataView.as_view(auth_backend=...); HEAD takes
the same handler and OPTIONS names the methods. The payload comes from the instance-scoped
MCPAuthBackend.protected_resource_metadata, so multiple servers in one process
advertise different metadata.
Open to everyone: PRM is a public discovery endpoint, so the view is
CSRF-exempt and never redirected by LoginRequiredMiddleware, and the MCP
transport owns its own auth pipeline through
MCPAuthBackend.
The answer is always JSON, because the payload shape is RFC-defined, and an
Accept header admitting no JSON is refused with a 406.
as_view ¶
Build the view function, opted out of CSRF and of the login redirect.
The opt-outs are set as attributes, as the MCP endpoint's are, because the middleware reads them off the resolved callable.
ProtectedResourceMetadata
dataclass
¶
RFC 9728 OAuth 2.0 Protected Resource Metadata payload.
Returned by MCPAuthBackend.protected_resource_metadata and
serialised onto the wire by the PRM view. Keys map 1:1 to the RFC 9728
field names, except warning: a package-local extension serialised as
_warning, which AllowAnyBackend uses to make dev-mode
misconfiguration loud in client tooling.