Skip to content

Auth

Backends, permissions, response builders, rate limits, and OAuth-related views (RFC 9728 PRM + the opt-in contrib oauth/ mount).

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) and returns a :class:ProtectedResourceMetadata dataclass; the contrib PRM ViewSet calls .to_dict() for the wire shape.

:meth:authorization_server_metadata is consumed by the optional rest_framework_mcp.contrib.oauth mount — backends that host an authorization server return an :class:AuthorizationServerMetadata dataclass; backends that don't host one raise :class:NotImplementedError so the contrib code can skip the AS endpoint matrix cleanly.

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 resource_url is set, the token's resource claim must match it exactly, and tokens without a bound resource are rejected. None (the default) disables enforcement — appropriate for dev, or where audience binding happens at an upstream gateway.

One resource URL per server. RFC 8707 binds a token to a resource, so each server needs its own canonical URL — that binding is precisely what stops a token issued for one resource being replayed against another. Two servers sharing a single URL defeat it: a token minted for /public/mcp would satisfy /internal/mcp. Hence resource_url is per-backend and the RESOURCE_URL setting is only its default::

MCPServer(
    name="internal",
    resource_url="https://example.com/internal/mcp/",
)

Every value is resolved once, here — the settings reads are defaults for the arguments, not per-request lookups — so two backends in one process can genuinely differ.

authorization_server_metadata

authorization_server_metadata() -> AuthorizationServerMetadata

Return the RFC 8414 metadata payload for the DOT-hosted authorization server.

Pulls the issuer, endpoint URLs, supported grant / response types, and scopes from this backend's authorization_servers (first entry) plus the contrib.oauth mount convention that endpoints live at /oauth/authorize/, /oauth/token/, /oauth/register/.

Missing values fall through as empty strings / lists so the wire shape is always valid JSON; consumers are expected to configure authorization_servers for production deployments.

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.

DRFPermissionAdapter

Bridge a DRF BasePermission class into the :class:MCPPermission Protocol.

Sister-repo 0.12.0 added permission_classes on ServiceSpec and SelectorSpec — a sequence of DRF BasePermission classes. The MCP transport doesn't go through DRF views, so each class is wrapped in this adapter at registration time; the wrapped instance is constructed once (mirrors sister-repo's [perm() for perm in spec.permission_classes] in its view get_permissions).

The DRF instance receives a synthesised :class:rest_framework.request.Request with user set to token.user and a lightweight view stand-in sufficient for the DRF permission contract (request, action). The HTTP method on the underlying HttpRequest is left untouched (unlike :func:~rest_framework_services.build_offline_context, which forces POST for mutation-flow dispatch) — permission evaluation is method-agnostic.

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.

Protected Resource Metadata (RFC 9728)

ProtectedResourceMetadataViewSet

Bases: ViewSet

RFC 9728 OAuth 2.0 Protected Resource Metadata endpoint.

Mounted at /.well-known/oauth-protected-resource by :class:MCPServer. Single-action ViewSet — the canonical GET is wired as list via ProtectedResourceMetadataViewSet.as_view({"get": "list"}, auth_backend=...) so the URL conf doesn't need a router.

Delegates payload construction to the configured :meth:MCPAuthBackend.protected_resource_metadata, which returns a :class:ProtectedResourceMetadata dataclass. The backend is instance-scoped — passed in via as_view — so multiple servers in one process can advertise different metadata.

DRF's default authentication / permission / throttling layers are disabled (empty lists below): PRM is a public discovery endpoint by design and the MCP transport owns its own auth pipeline through :class:MCPAuthBackend. The renderer is pinned to JSON because the payload shape is RFC-defined; content negotiation would be noise.

ProtectedResourceMetadata dataclass

RFC 9728 OAuth 2.0 Protected Resource Metadata payload.

Returned by :meth:MCPAuthBackend.protected_resource_metadata and serialised onto the wire by the PRM ViewSet. Keys map 1:1 to the RFC 9728 field names; warning is the package-local extension that AllowAnyBackend uses to make dev-mode misconfiguration loud.

OAuth contrib (opt-in)

build_oauth_urlpatterns(server, *, include_dcr=False, include_aliases=True, include_openid_discovery=True) returns a list of URL patterns ready to mount alongside your MCPServer.urls. Exposes RFC 8414 / OIDC discovery / RFC 7591 Dynamic Client Registration + the alias paths different LLM hosts probe (aliases render the canonical payload — they are not HTTP redirects).

build_oauth_urlpatterns

build_oauth_urlpatterns(
    *,
    server: MCPServer,
    include_dcr: bool = False,
    include_aliases: bool = True,
    include_openid_discovery: bool = True,
    include_authorize: bool = False,
    auth_user_adapter: AuthUserAdapter | None = None,
    dcr_enabled: bool | None = None,
    dcr_initial_access_token: str | None = None,
) -> list[URLPattern]

Return URL patterns for the OAuth endpoint matrix.

Endpoint matrix when all flags are True:

+---------------------------------------------------------+--------------------------------------+ | Path | View | +=========================================================+======================================+ | /.well-known/oauth-protected-resource | ProtectedResourceMetadataViewSet | +---------------------------------------------------------+--------------------------------------+ | /.well-known/oauth-protected-resource/mcp | alias | +---------------------------------------------------------+--------------------------------------+ | /mcp/.well-known/oauth-protected-resource | alias | +---------------------------------------------------------+--------------------------------------+ | /.well-known/oauth-authorization-server | AuthorizationServerMetadataViewSet | +---------------------------------------------------------+--------------------------------------+ | /.well-known/oauth-authorization-server/oauth | alias | +---------------------------------------------------------+--------------------------------------+ | /oauth/.well-known/oauth-authorization-server | alias | +---------------------------------------------------------+--------------------------------------+ | /.well-known/openid-configuration | OpenIDDiscoveryViewSet | +---------------------------------------------------------+--------------------------------------+ | /.well-known/openid-configuration/oauth | alias | +---------------------------------------------------------+--------------------------------------+ | /oauth/register/ | DynamicClientRegistrationViewSet | +---------------------------------------------------------+--------------------------------------+

The DOT-provided /oauth/authorize/ and /oauth/token/ views are NOT mounted here — consumers include oauth2_provider.urls separately. The contrib mount focuses on the discovery / DCR surface; the AS endpoints themselves belong to whichever framework actually hosts the AS.

Parameters:

Name Type Description Default
server MCPServer

The :class:MCPServer whose auth_backend should drive all of the discovery payloads. Passed in instead of looked up from settings so multi-server deployments work.

required
include_dcr bool

Mount /oauth/register/. Default False because DCR is gated by dcr_enabled anyway and consumers who don't need it shouldn't even see the URL.

False
include_aliases bool

Mount the alias URLs alongside the canonical ones. Default True because clients in the wild use varied paths.

True
include_openid_discovery bool

Mount the OIDC discovery alias. Default True for the same reason.

True
include_authorize bool

Mount /oauth/authorize/ as a thin DOT :class:AuthorizationView subclass with the configured :class:AuthUserAdapter hook. Default False because the consumer's own URL conf typically owns /oauth/authorize/ via include('oauth2_provider.urls'); flip this on when you want the adapter wired and you're not including DOT's urls otherwise. Requires the [oauth] extra — DOT is imported lazily inside the factory.

False
auth_user_adapter AuthUserAdapter | None

An :class:AuthUserAdapter instance that hydrates request.user before DOT's AuthorizationView dispatches. None (the default) means "no adapter; DOT's own dispatch decides the user" — typically a session-based login redirect. Only used when include_authorize is on.

None
dcr_enabled bool | None

Whether /oauth/register/ accepts registrations. None takes REST_FRAMEWORK_MCP['DCR_ENABLED'] (default False — an open DCR endpoint lets anyone create an OAuth client against your authorization server).

None
dcr_initial_access_token str | None

RFC 7591 §3 token a DCR client must present. None takes REST_FRAMEWORK_MCP ['DCR_INITIAL_ACCESS_TOKEN'], whose own default is None — meaning no token check.

None

Every value is resolved here, when the patterns are built, rather than on each request — the same reason server is a parameter: so two mounts in one project can differ.

AuthorizationServerMetadataViewSet

Bases: ViewSet

RFC 8414 OAuth 2.0 Authorization Server Metadata endpoint.

Mounted by :func:build_oauth_urlpatterns at the canonical /.well-known/oauth-authorization-server URL plus aliases. The canonical GET wires up as the list action via AuthorizationServerMetadataViewSet.as_view({"get": "list"}, auth_backend=...).

Delegates payload construction to :meth:MCPAuthBackend.authorization_server_metadata, which returns an :class:AuthorizationServerMetadata dataclass. Backends that don't host an authorization server raise :class:NotImplementedError on the method; this view surfaces that as 501 Not Implemented so clients see a deterministic "no AS here" signal rather than a 500.

DRF's default auth / permission / throttling layers are disabled — discovery is public and the MCP transport owns its own pipeline. The renderer is pinned to JSON; the payload shape is RFC-defined.

OpenIDDiscoveryViewSet

Bases: ViewSet

OIDC discovery alias — /.well-known/openid-configuration.

Some MCP / LLM-host clients probe /.well-known/openid-configuration before falling back to RFC 8414. This ViewSet returns the same payload as :class:AuthorizationServerMetadataViewSet plus a small set of OIDC defaults so the probe succeeds even though :mod:rest_framework_mcp doesn't implement an actual ID-token endpoint.

Single-action ViewSet — the canonical GET wires up as list via OpenIDDiscoveryViewSet.as_view({"get": "list"}, auth_backend=...).

Concretely, the payload is the backend's AS metadata composed with:

  • subject_types_supported: ["public"] — DOT-style pseudonymous identifiers.
  • id_token_signing_alg_values_supportedderived, not fixed. This used to be a hardcoded ["RS256"], on the reasoning that the value is inert because "we don't actually mint ID tokens". That reasoning was wrong wherever DOT is the authorization server with OIDC_ENABLED: DOT's token endpoint mints ID tokens, so a client that read this list, saw RS256 and requested openid reached Application.jwk_key on a DCR-registered client that had no algorithm — ImproperlyConfigured, surfacing as a 500 after the user had already logged in and consented. It now reports what the server can genuinely sign with, which is empty when no RSA key is configured.
  • response_modes_supported: ["query"] — standard.

Backends that don't host an authorization server raise :class:NotImplementedError; this view surfaces that as 501 for parity with :class:AuthorizationServerMetadataViewSet.

DynamicClientRegistrationViewSet

Bases: ViewSet

RFC 7591 Dynamic Client Registration endpoint.

Default state is locked down: DCR_ENABLED=False produces a 403 on every request. To turn DCR on, set the flag in REST_FRAMEWORK_MCP settings and (recommended) also set DCR_INITIAL_ACCESS_TOKEN to a static bearer that clients must present.

Single-action ViewSet — wired as the create action (POST) via DynamicClientRegistrationViewSet.as_view({"post": "create"}). Successful POST returns the RFC 7591 client information response (client_id / echoed registration metadata, plus a plaintext client_secret for confidential clients) and persists a DOT Application. A client that registers with token_endpoint_auth_method: none becomes a public client and is issued no secret — it authenticates at the token endpoint with PKCE alone, which is the only mode Claude's custom connectors can use.

DOT (oauth2_provider) is imported lazily inside the action so this module remains importable without the [oauth] extra. A request that arrives with DCR enabled but DOT absent surfaces a clear ImportError at first use rather than at server startup.

DRF's default auth / permission / throttling layers are disabled — DCR is gated by its own dcr_enabled / initial_access_token knobs, not by DRF's session/token authenticators. The CSRF / session middleware is sidestepped because DRF's APIView.dispatch (which ViewSet inherits) wraps responses with csrf_exempt semantics when no SessionAuthentication class is configured.

DynamicClientRegistrationSerializer

Bases: DataclassSerializer

RFC 7591 dynamic client registration request shape.

Wraps :class:DynamicClientRegistrationRequest so the validated payload arrives at :class:DynamicClientRegistrationViewSet as a typed dataclass instance (via .save()). Field overrides below replace the dataclass-derived auto-generated fields with shapes that actually validate the wire contract:

  • redirect_uris is required + non-empty + child is a URL.
  • token_endpoint_auth_method / grant_types are the RFC 7591 §2 spellings every interoperable client sends. They are the primary inputs: :meth:validate translates them into DOT's client_type / authorization_grant_type.
  • client_type / authorization_grant_type are DOT's non-standard spellings, retained as an escape hatch for callers already speaking DOT. They are ChoiceField with choices sourced from DOT's Application model constants at instance construction, so a malformed value is rejected per-field before the request ever reaches the database. The lazy import keeps this module importable without the [oauth] extra.

Supplying both spellings is allowed only when they agree; a contradiction is a 400 rather than a silent winner, because either choice would hand back a client that cannot complete the flow it registered for.

Other RFC 7591 fields are silently ignored — DOT doesn't model them and inventing a richer shape would diverge from the underlying authorization server.

validate

validate(attrs: DynamicClientRegistrationRequest) -> DynamicClientRegistrationRequest

Reconcile the RFC 7591 and DOT spellings, in both directions.

Every caller downstream reads all four fields already populated and mutually consistent, so nobody has to know which vocabulary the client used, and nobody re-applies defaults.

attrs is the dataclass instance built by DataclassSerializer.to_internal_value; it is mutable precisely so normalisation like this can happen in place. Fields the client omitted carry the dataclass defaults ("" / []), neither of which is an accepted choice, so "empty" unambiguously means "not supplied".

AuthorizationServerMetadata dataclass

RFC 8414 OAuth 2.0 Authorization Server Metadata payload.

Returned by :meth:MCPAuthBackend.authorization_server_metadata and serialised by the contrib AS metadata ViewSet. Backends that don't host an AS raise :class:NotImplementedError instead of returning this dataclass; the calling ViewSet maps the exception to 501 Not Implemented.

Field shapes mirror RFC 8414; str-typed endpoints default to "" so the wire shape is always valid JSON even when the configuration is incomplete (callers can populate SERVER_INFO to fill them).

OpenIDDiscoveryPayload dataclass

OIDC discovery alias payload — extends :class:AuthorizationServerMetadata.

Composes (not subclasses) the AS metadata so the underlying type stays exactly RFC 8414. The OIDC additions (subject_types_supported, id_token_signing_alg_values_supported, response_modes_supported) are advertised because some MCP / LLM-host clients probe /.well-known/openid-configuration first and skip the probe silently if these keys are absent.

See :class:OpenIDDiscoveryViewSet for the rationale on returning OIDC-shaped metadata without implementing an actual ID-token endpoint.

DynamicClientRegistrationRequest dataclass

RFC 7591 dynamic client registration request payload.

Mutable dataclass so :class:DynamicClientRegistrationSerializer (a DataclassSerializer over this type) can apply defaults via setdefault-style normalisation if needed. Frozen would force consumers to build a second instance just to change a defaulted field, which is awkward for a request-shape type.

Two vocabularies land here side by side:

  • token_endpoint_auth_method, grant_types and response_types are the RFC 7591 §2 fields an interoperable client actually sends.
  • client_type and authorization_grant_type are DOT's non-standard equivalents, kept as an escape hatch for callers that already speak DOT.

The serializer's validate reconciles the two, so everything downstream reads a consistent set. response_types has no DOT counterpart at all — RFC 7591 §2.1 makes it a function of the grant, so it is derived rather than stored, and an explicit value that contradicts the grant is rejected.

id_token_signed_response_alg maps to DOT's Application.algorithm, which decides whether an ID token can be signed at all. Only the viewset can resolve it, because whether an algorithm is usable depends on server configuration (an RSA key) and on the client type (HS256 signs with the client secret, which a public client does not have) — neither of which is visible from the payload alone.

RFC 7591 fields the server doesn't understand (contacts, logo_uri, jwks, …) are ignored, which §2 requires — DOT has nowhere to put them, and echoing metadata the authorization server won't honour is worse than dropping it.

DynamicClientRegistrationResponse dataclass

RFC 7591 client information response.

The wire shape returned by :class:DynamicClientRegistrationViewSet on a successful registration.

RFC 7591 §3.2.1 lets the authorization server substitute any metadata value it likes, but obliges it to return everything it registered. A substitution the client is never told about is what turns a legal downgrade into an undiagnosable failure: the client goes on behaving as what it asked to be while the token endpoint enforces something else. So every field here is the resolved value — what was written — not an echo of what was asked for.

client_secret is the plaintext secret, and it is only present for confidential clients. DOT hashes the column on save, so the value here has to be the one generated before the Application was written — read it back off the model and you emit the PBKDF2 digest, which no client can authenticate with. A public client (token_endpoint_auth_method: none) gets no secret at all, per RFC 7591 §2. client_secret_expires_at rides along whenever a secret is issued because §3.2.1 makes it REQUIRED in that case; 0 means "does not expire".

scope is optional (only emitted when the request supplied one).

DCR is gated by two build_oauth_urlpatterns arguments, each defaulting to the matching setting when omitted:

  • dcr_enabled (default: REST_FRAMEWORK_MCP["DCR_ENABLED"], itself False) — the DCR endpoint refuses every request while disabled.
  • dcr_initial_access_token (default: REST_FRAMEWORK_MCP["DCR_INITIAL_ACCESS_TOKEN"], itself None) — optional bearer required on the DCR POST, per RFC 7591 §3.

AuthUserAdapter

Bases: Protocol

Hydrate request.user before DOT's AuthorizationView dispatches.

The common production setup is "DRF backend with SimpleJWT cookies on the same host". DOT's AuthorizationView only knows about Django's standard session-based request.user — without an adapter, a JWT-authenticated user appears anonymous to the OAuth flow and the consent screen gets shown again. The adapter is the seam where the consumer's preferred authentication scheme decides which user the OAuth flow should attribute the grant to.

Implementations return:

  • The authenticated :class:AbstractBaseUser to set on the request before delegating to DOT.
  • None to leave request.user untouched — DOT then falls back to its own session-based flow (which may redirect to login).

Adapters MUST be safe to instantiate without arguments — settings-driven configuration belongs inside the adapter's own module so the dotted-path setting can resolve it directly.

SimpleJWTCookieAdapter

Reference :class:AuthUserAdapter for SimpleJWT cookie-authenticated apps.

Reads the access-token cookie (name configured via REST_FRAMEWORK_MCP['SIMPLEJWT_ACCESS_COOKIE'], default "access"), decodes it with :class:rest_framework_simplejwt.tokens.AccessToken, looks the user up by primary key, and returns it. Returns None for any failure mode (no cookie, malformed token, expired token, unknown user) — DOT's view then falls back to its session-based flow.

rest_framework_simplejwt is imported lazily inside :meth:hydrate so this module remains importable without the [jwt] extra. A consumer who configures this adapter without the extra installed surfaces a clear ImportError at first request, not at import.