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 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 ViewSet calls .to_dict() on the returned dataclass for the wire shape.

authorization_server_metadata is consumed by the optional rest_framework_mcp.contrib.oauth mount. A backend that hosts no authorization server raises 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.

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.

MCPPermission

Bases: Protocol

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

The transport pulls authenticated state from the request as a 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 — they are instantiated 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.

Implementations with no scope semantics return [].

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 TokenInfo. oauth2_provider is imported lazily inside the method bodies because it is an optional extra (pip install "djangorestframework-mcp-server[oauth]"): importing this module without DOT is fine, and the ImportError fires only when a request actually reaches authentication.

Audience enforcement (RFC 8707) is controlled by enforce_audience, not by resource_url. resource_url is the identity this server publishes in its protected-resource metadata; enforcement is whether a token that doesn't carry that identity is rejected. resource_url is effectively required by RFC 9728; enforcement is a separate decision.

The [oauth] extra floors django-oauth-toolkit at >=3.4, so this works out of the box. That release added RFC 8707 resource indicators: stock AccessToken carries a resource field and an allows_audience check, so turning enforcement on needs nothing else.

The default nonetheless stays off, and the reason is no longer the floor. audience_matches rejects a token carrying no resource, and a token only carries one if the client sent the RFC 8707 resource parameter at the authorize and token endpoints. The MCP specification requires clients to send it, but the authorization server this backend reads is a general OAuth server whose other clients have no such obligation — so defaulting True would 401 every token minted for a browser app, a script, or an MCP client that has not caught up, on the release that changed the default. UnenforcedAudienceWarning is how a deployment that could conform is told so, at construction, instead.

With a swapped model that drops the field, or a DOT installed outside this extra and pinned below 3.4, enforcement still works if the resource is somewhere else:

  • a swapped OAUTH2_PROVIDER["ACCESS_TOKEN_MODEL"] carrying a resource field (DOT supports substituting the model), or
  • an explicit audience_getter= reading it from wherever it lives — a JWT claim, an upstream gateway header, a related row.

The check is capability-based rather than version-based: it asks the configured token model whether it has the field. Turning enforcement on with no route to the resource raises ImproperlyConfigured at construction, naming every way out, instead of 401-ing every request.

One resource URL per server. RFC 8707 binds a token to a resource, and that binding is precisely what stops a token issued for one resource being replayed against another: two servers sharing a single URL would let a token minted for /public/mcp 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 argument defaults, not per-request lookups — so two backends in one process can genuinely differ.

check_configuration

check_configuration() -> None

Refuse at mount time if DOT cannot serve a request, rather than at the first one.

Implements the opt-in :class:~rest_framework_mcp.auth.types.self_checking.SelfChecking protocol. The import stays lazy everywhere else on purpose -- see the class docstring -- and this does not change that: an in-process server is never mounted, so it is never called.

Two things have to be true, and they fail differently. The distribution has to be installed, and the app has to be in INSTALLED_APPS, because authenticate resolves DOT's access-token model and a model needs the app registry rather than the module. The first version of this checked only the import and let the second case through -- a project that had installed the extra got past the mount and then failed on the first request with a RuntimeError about app_label, naming neither this package nor the setting that fixes it.

The order is the order the two fail in. apps.is_installed is False both when the app is missing from the setting and when the distribution is absent entirely, so asking it first would tell someone without the extra to edit a setting.

authorization_server_metadata

authorization_server_metadata() -> AuthorizationServerMetadata

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

Endpoints are built from this backend's first authorization_servers entry plus the endpoint paths, which default to the contrib.oauth mount convention (/oauth/authorize/, /oauth/token/, /oauth/register/) and are constructor arguments for a project that mounts DOT somewhere else.

The issuer is treated as a site root and the paths are appended to it, so passing the value DOT advertises as its issuer (<host>/oauth) publishes <host>/oauth/oauth/authorize/ — three 404s in a document clients read to find the login flow. That mistake warns at construction; see MountedAuthorizationServerWarning.

Missing values fall through as empty strings / lists so the wire shape is always valid JSON; configure authorization_servers for production. client_id_metadata_document_supported is read from DOT rather than asserted here — see _cimd_enabled.

registration_endpoint is advertised whenever an issuer is configured, including where DCR is switched off, and that is deliberate on two counts.

Structurally it is the only answer available here. Whether the endpoint accepts registrations is decided per mount: build_oauth_urlpatterns(dcr_enabled=...) resolves it into as_view(...), and REST_FRAMEWORK_MCP['DCR_ENABLED'] is only that argument's default. This backend never sees the argument, so gating the advertisement on the global would withdraw a working endpoint from any deployment that enables DCR at the mount — the flow that exists so two mounts in one project can differ. A client that registers against a disabled endpoint gets an immediate, well-formed 403 invalid_request, not a hang.

By protocol the advertisement also costs a modern client nothing. The 2026-07-28 registration priority order is pre-registration, then Client ID Metadata Documents, then DCR, so a client that can read client_id_metadata_document_supported never reaches registration_endpoint. It is read only by clients with no other way in, which is still most of them.

Permissions

ScopeRequired

Allow only requests whose token carries every listed OAuth scope.

Takes a list, or a bare string for the single-scope case:

ScopeRequired(["invoices:read", "invoices:write"])
ScopeRequired("invoices:write")

The bare string is not sugar — it closes a trap. Normalising with list(scopes) would silently turn ScopeRequired("mcp:admin") into nine one-character scopes: nothing fails at registration, and the misconfiguration surfaces much later as a permission that can never be satisfied and a nonsense challenge. DjangoPermRequired takes a bare string too, so the siblings agree.

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 is always rejected — that is the point of using this class instead of ScopeRequired. Takes a list or a bare string, like ScopeRequired.

DRFPermissionAdapter

Bridge a DRF BasePermission class into the MCPPermission Protocol.

ServiceSpec / SelectorSpec carry permission_classes as DRF BasePermission classes, and the MCP transport doesn't go through DRF views, so each class is wrapped here at registration time and instantiated once — mirroring what a DRF view's get_permissions does.

The DRF instance receives a synthesised 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 build_offline_context, which forces POST for mutation dispatch — because permission evaluation is method-agnostic.

auth is set alongside user, and has to be. DRF resolves request.auth lazily: reading it on a request that has never authenticated runs the (here empty) authenticator chain, which ends in _not_authenticated() and overwrites request.user with UNAUTHENTICATED_USER — on the wrapper and on the HttpRequest underneath it. A permission class as ordinary as TokenHasScope reads request.auth first and every request.user read after it would see AnonymousUser, denying a properly scoped caller with nothing in the response explaining why. Assigning the backend's opaque payload — DRF's own convention for what auth holds — means the getter never reaches for the chain.

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)

ProtectedResourceMetadataViewSet

Bases: ViewSet

RFC 9728 OAuth 2.0 Protected Resource Metadata endpoint.

Mounted at /.well-known/oauth-protected-resource by 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. The payload comes from the instance-scoped MCPAuthBackend.protected_resource_metadata, so multiple servers in one process advertise different metadata.

DRF authentication and permissions are deliberately open: PRM is a public discovery endpoint, and the MCP transport owns its own auth pipeline through MCPAuthBackend. The renderer is pinned to JSON because the payload shape is RFC-defined.

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 ViewSet. 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.

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.

With every flag on, the canonical paths and their aliases are:

/.well-known/oauth-protected-resource     ProtectedResourceMetadataViewSet
  + /.well-known/oauth-protected-resource/mcp
  + /mcp/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server   AuthorizationServerMetadataViewSet
  + /.well-known/oauth-authorization-server/oauth
  + /oauth/.well-known/oauth-authorization-server
/.well-known/openid-configuration         OpenIDDiscoveryViewSet
  + /.well-known/openid-configuration/oauth
/oauth/register/                          DynamicClientRegistrationViewSet

DOT's own /oauth/authorize/ and /oauth/token/ are not mounted: this covers the discovery and DCR surface, while the AS endpoints belong to whichever framework hosts the authorization server. Every argument is resolved here, when the patterns are built, rather than per request, so two mounts in one project can differ.

Parameters:

Name Type Description Default
server MCPServer

The MCPServer whose auth_backend drives every discovery payload. A parameter rather than a settings lookup so multi-server deployments work.

required
include_dcr bool

Mount /oauth/register/. Off by default, so a consumer who does not want DCR never exposes the URL at all. RFC 7591 Dynamic Client Registration is deprecated by MCP revision 2026-07-28 in favour of Client ID Metadata Documents, with an earliest removal of the first revision released on or after 2027-07-28; see DynamicClientRegistrationViewSet for the timetable and for why this endpoint is kept meanwhile.

False
include_aliases bool

Mount the alias URLs alongside the canonical ones.

True
include_openid_discovery bool

Mount the OIDC discovery alias.

True
include_authorize bool

Mount /oauth/authorize/ as a thin DOT AuthorizationView subclass carrying the auth_user_adapter hook. Off by default because the consumer's URL conf usually owns that path via include('oauth2_provider.urls'); turn it on to wire the adapter when it does not. Requires the [oauth] extra.

False
auth_user_adapter AuthUserAdapter | None

Hydrates request.user before DOT's AuthorizationView dispatches. None leaves the user to DOT's own dispatch, typically a session-based login redirect. Only read when include_authorize is on.

None
dcr_enabled bool | None

Whether /oauth/register/ accepts registrations. None takes REST_FRAMEWORK_MCP['DCR_ENABLED'].

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'], itself None, which means no token check.

None

AuthorizationServerMetadataViewSet

Bases: ViewSet

RFC 8414 OAuth 2.0 Authorization Server Metadata endpoint.

Mounted by build_oauth_urlpatterns at /.well-known/oauth-authorization-server plus aliases, wired as the list action: as_view({"get": "list"}, auth_backend=...).

The payload comes from MCPAuthBackend.authorization_server_metadata. A backend that hosts no authorization server raises NotImplementedError, which this view surfaces as 501 so clients get a deterministic "no AS here" rather than a 500.

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

OpenIDDiscoveryViewSet

Bases: ViewSet

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

Some MCP hosts probe this path before falling back to RFC 8414, so the payload is the backend's AS metadata plus a few OIDC defaults, letting the probe succeed even though this package implements no ID-token endpoint. Wired as the list action: as_view({"get": "list"}, auth_backend=...).

The additions are subject_types_supported: ["public"], response_modes_supported: ["query"], and id_token_signing_alg_values_supported — the last derived rather than fixed, because wherever DOT is the authorization server with OIDC_ENABLED its token endpoint really does mint ID tokens. A client that read a hardcoded RS256 and requested openid would reach Application.jwk_key on a client registered with no algorithm and take an ImproperlyConfigured 500, after logging in and consenting. The list is empty when no RSA key is configured.

A backend that hosts no authorization server raises NotImplementedError, surfaced as 501 for parity with AuthorizationServerMetadataViewSet.

DynamicClientRegistrationViewSet

Bases: ViewSet

RFC 7591 Dynamic Client Registration endpoint.

Deprecated by the MCP specification, and kept until the specification removes it. Revision 2026-07-28 deprecated Dynamic Client Registration in favour of Client ID Metadata Documents, and the spec's deprecated-features registry gives it an earliest removal of the first revision released on or after 2027-07-28. Deprecated is not removed: DCR remains a MAY in the current revision, and this endpoint is supported for as long as that holds. The replacement is CIMD, which is an authorization-server capability rather than one of ours — on django-oauth-toolkit 3.4 and later, OAUTH2_PROVIDER["CIMD_ENABLED"] = True is the whole configuration, and AuthorizationServerMetadata.client_id_metadata_document_supported then advertises it. New deployments should reach for that first; a client that reads it never reaches this endpoint, because the spec's registration priority order puts pre-registration and CIMD above DCR.

Why the surface stays, and why nothing warns at runtime. Most MCP clients in the field have no CIMD implementation at all, and several have no alternative to DCR whatsoever, so a server that withdraws this endpoint ahead of the timetable is a server those clients simply cannot connect to. A deployment reaching this code has already opted in twice — include_dcr at the mount and dcr_enabled on top of it, both defaulting off — so a DeprecationWarning here would fire on a correct configuration whose operator has nothing to act on, and it would fire once per registration request, since this package forbids the warn-once module state that would quieten it. An announcement that cannot be acted on trains people to filter the module, which is worse than no announcement. It belongs where the decision is made — this docstring, the settings reference, and the authentication guide — not on every request after it.

Locked down by default: dcr_enabled=False answers 403 to every request. Turn it on with REST_FRAMEWORK_MCP['DCR_ENABLED'] and, recommended, a DCR_INITIAL_ACCESS_TOKEN clients must present. Wired as the create action: as_view({"post": "create"}).

A successful POST persists a DOT Application and returns the RFC 7591 client information response — client_id, the registered metadata, and a plaintext client_secret for confidential clients. Registering with token_endpoint_auth_method: none makes a public client, issued no secret and authenticating with PKCE alone, which is the only mode some connectors can use.

DOT is imported lazily inside the action, so this module stays importable without the [oauth] extra and a request arriving with DCR enabled but DOT absent gets a clear ImportError rather than a startup failure.

DRF's default auth / permission / throttling layers are off: DCR is gated by its own knobs, not by DRF's authenticators. CSRF is sidestepped because APIView.dispatch applies csrf_exempt semantics when no SessionAuthentication is configured.

DynamicClientRegistrationSerializer

Bases: DataclassSerializer

RFC 7591 dynamic client registration request shape.

Dynamic Client Registration is deprecated by MCP revision 2026-07-28 in favour of Client ID Metadata Documents; the timetable, the replacement and the reason this surface is nonetheless kept are recorded once, on DynamicClientRegistrationViewSet.

Wraps DynamicClientRegistrationRequest so .save() hands DynamicClientRegistrationViewSet a typed dataclass instance. The field overrides replace the dataclass-derived defaults with shapes that validate the wire contract: redirect_uris is required, non-empty and absolute-URI-valued, and application_type is checked against OIDC's two values and echoed without imposing the redirect-URI constraints an OIDC provider would derive from it.

token_endpoint_auth_method / grant_types are the RFC 7591 §2 spellings every interoperable client sends, and are the primary inputs: validate translates them into DOT's client_type / authorization_grant_type. Those two remain accepted as an escape hatch for callers already speaking DOT, their choices sourced from Application's constants at instance construction — so a malformed value is rejected per-field before reaching the database, and the lazy import keeps this module usable without the [oauth] extra.

Supplying both spellings is allowed only when they agree. A contradiction is a 400 rather than a silent winner, since either choice would hand back a client that cannot complete the flow it registered for. Other RFC 7591 fields are ignored: DOT does not model them, and inventing a richer shape would diverge from the underlying authorization server.

authorization_code is the only primary grant registerable here, in either vocabulary — see _REGISTERABLE_GRANT_TYPES. refresh_token may ride along.

validate_redirect_uris

validate_redirect_uris(value: list[str]) -> list[str]

Require absolute URIs whose scheme the authorization server will honour.

DRF's URLField was the wrong instrument: its URLValidator allowlists the http family, so it refused exactly the private-use schemes (com.example.app:/oauth2redirect) that RFC 8252 §7.1 defines for the native clients this serializer's own application_type exists to describe, while admitting ftp, which no OAuth client redirects to.

The authority on which schemes are acceptable is the authorization server that will later match the redirect, not this endpoint: DOT publishes it as ALLOWED_REDIRECT_URI_SCHEMES, defaulting to the http family and widened by the operator who deploys native clients. Checking the same list here means a registration is refused only where the authorization request would have been refused anyway — with an actionable 400 at registration rather than a dead end mid-flow — and never invents a restriction DOT does not apply.

validate

validate(attrs: DynamicClientRegistrationRequest) -> DynamicClientRegistrationRequest

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

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 DataclassSerializer.to_internal_value built, mutable precisely so normalisation can happen in place. Omitted fields 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 MCPAuthBackend.authorization_server_metadata and serialised by the contrib AS metadata ViewSet. A backend that hosts no authorization server raises NotImplementedError instead, which that ViewSet maps to 501 Not Implemented.

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

client_id_metadata_document_supported class-attribute instance-attribute

client_id_metadata_document_supported: bool = False

Whether the authorization server accepts an HTTPS URL as a client_id.

Clients check this to decide how to register: the priority order is pre-registration, then CIMD, then the deprecated Dynamic Client Registration, so a server that supports CIMD but stays silent sends every client down the deprecated path.

Never hardcode this to True. It describes the authorization server, not this package, so a backend must source it from what the AS actually does or the advertisement drifts from the behaviour.

OpenIDDiscoveryPayload dataclass

OIDC discovery alias payload — extends AuthorizationServerMetadata.

Composes rather than subclasses the AS metadata, so the underlying type stays exactly RFC 8414. The OIDC additions are advertised because some MCP hosts probe /.well-known/openid-configuration first and skip the probe silently when these keys are absent. See OpenIDDiscoveryViewSet for why OIDC-shaped metadata is returned with no ID-token endpoint behind it.

DynamicClientRegistrationRequest dataclass

RFC 7591 dynamic client registration request payload.

Mutable, so DynamicClientRegistrationSerializer can normalise in place; frozen would force a second instance just to change a defaulted field.

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 sends; client_type and authorization_grant_type are DOT's non-standard equivalents, kept as an escape hatch. The serializer's validate reconciles them, so everything downstream reads a consistent set.

response_types has no DOT counterpart: RFC 7591 §2.1 makes it a function of the grant, so it is derived rather than stored and an explicit value contradicting the grant is rejected. id_token_signed_response_alg maps to Application.algorithm, which decides whether an ID token can be signed at all; only the viewset can resolve it, because usability depends on server configuration (an RSA key) and client type (HS256 signs with a secret a public client does not have). application_type is validated and echoed but has no DOT counterpart — the MCP spec makes sending it a client MUST, so dropping it silently would leave a client that declared native unable to tell whether it had been heard.

RFC 7591 fields the server does not understand (contacts, logo_uri, jwks, …) are ignored, as §2 requires: DOT has nowhere to put them, and echoing metadata the authorization server will not honour is worse than dropping it.

DynamicClientRegistrationResponse dataclass

RFC 7591 client information response.

The wire shape DynamicClientRegistrationViewSet returns 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, so every field here is the resolved value rather than an echo of the request: an untold substitution turns a legal downgrade into an undiagnosable failure, with the client behaving as what it asked to be while the token endpoint enforces something else.

client_secret is the plaintext secret and is present only for confidential clients — DOT hashes the column on save, so this has to be the value generated before the Application was written; read back off the model it would be the PBKDF2 digest, which no client can authenticate with. A public client gets no secret at all, per RFC 7591 §2. client_secret_expires_at rides along whenever one is issued, §3.2.1 making it REQUIRED in that case, with 0 meaning "does not expire". scope is emitted only when the request supplied one.

DCR is deprecated by MCP revision 2026-07-28, in favour of Client ID Metadata Documents, with an earliest removal of the first specification revision released on or after 2027-07-28. It is kept, and stays off by default; the timetable and the reasoning are on DynamicClientRegistrationViewSet above and in Authentication.

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.

DOT's AuthorizationView knows only Django's session-based request.user, so on the common "DRF backend with SimpleJWT cookies" setup an authenticated user appears anonymous to the OAuth flow and is shown the consent screen again. The adapter is the seam where the consumer's own authentication scheme decides which user the flow should attribute the grant to.

hydrate returns the authenticated user to set on the request before delegating to DOT, or None to leave request.user untouched — DOT then falls back to its session-based flow, which may redirect to login.

Implementations MUST be safe to instantiate without arguments.

SimpleJWTCookieAdapter

Reference AuthUserAdapter for SimpleJWT cookie-authenticated apps.

Reads the access-token cookie (cookie_name=, defaulting to REST_FRAMEWORK_MCP['SIMPLEJWT_ACCESS_COOKIE']), decodes it with rest_framework_simplejwt.tokens.AccessToken and looks the user up by primary key. Every failure mode — no cookie, malformed or expired token, unknown user — returns None, so DOT's view falls back to its session-based flow.

rest_framework_simplejwt is imported lazily inside hydrate, so this module stays importable without the [jwt] extra and a consumer who configures the adapter without it gets a clear ImportError at first request rather than at import.