Skip to content

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 AnonymousUser equivalent. Any because the user model is project-defined.

scopes tuple[str, ...]

OAuth scopes proven by the bearer token.

audience str | None

The aud claim. RFC 8707 requires it to match the canonical /mcp URL; backends own that comparison.

raw Any

Backend-specific opaque payload — the AccessToken row, the JWT claims dict — for advanced use cases.

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

audience_matches(token_audience: str | None, expected: str | None) -> bool

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_calls timestamps 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 capacity requests instantly, then holds the steady state at refill_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_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.

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

as_view(**initkwargs: Any) -> Callable[..., Any]

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.