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 (orAnonymousUser-equivalent).Anyhere because the user model is project-defined.scopes: OAuth scopes proven by the bearer token.audience: theaudclaim — must match the canonical/mcpURL per RFC 8707; backends are responsible for that comparison.raw: backend-specific opaque payload (theAccessTokenrow, 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 ¶
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 ¶
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_secondswindow, not bucketed counts. - Memory cost: the timestamp list grows with traffic up to
max_callsentries per key; negligible for typical limits. - Read-modify-write: doesn't have the atomic guarantees of the
fixed-window's
cache.add+cache.incrprimitives. 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
capacityrequests instantly, then rate-limit the steady state atrefill_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 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)¶
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: |
required |
include_dcr
|
bool
|
Mount |
False
|
include_aliases
|
bool
|
Mount the alias URLs alongside the canonical ones.
Default |
True
|
include_openid_discovery
|
bool
|
Mount the OIDC discovery alias. Default
|
True
|
include_authorize
|
bool
|
Mount |
False
|
auth_user_adapter
|
AuthUserAdapter | None
|
An :class: |
None
|
dcr_enabled
|
bool | None
|
Whether |
None
|
dcr_initial_access_token
|
str | None
|
RFC 7591 §3 token a DCR client must
present. |
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_supported— derived, 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 withOIDC_ENABLED: DOT's token endpoint mints ID tokens, so a client that read this list, saw RS256 and requestedopenidreachedApplication.jwk_keyon 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_urisis required + non-empty + child is a URL.token_endpoint_auth_method/grant_typesare the RFC 7591 §2 spellings every interoperable client sends. They are the primary inputs: :meth:validatetranslates them into DOT'sclient_type/authorization_grant_type.client_type/authorization_grant_typeare DOT's non-standard spellings, retained as an escape hatch for callers already speaking DOT. They areChoiceFieldwith choices sourced from DOT'sApplicationmodel 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 ¶
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_typesandresponse_typesare the RFC 7591 §2 fields an interoperable client actually sends.client_typeandauthorization_grant_typeare 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"], itselfFalse) — the DCR endpoint refuses every request while disabled.dcr_initial_access_token(default:REST_FRAMEWORK_MCP["DCR_INITIAL_ACCESS_TOKEN"], itselfNone) — optional bearer required on the DCR POST, per RFC 7591 §3.
User-adapter hook (cookie-session bridge for /authorize)¶
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:
AbstractBaseUserto set on the request before delegating to DOT. Noneto leaverequest.useruntouched — 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.