datarobot_genai.drmcpbase.oauth_scopes
oauth_scopes
Per-component OAuth scope declarations.
The DataRobot gateway does the authentication; the server's ASGI middleware
does the authorization (see drmcp.core.middleware): the audience claim is
validated on every request and a tools/call is checked against the scopes
the tool declares. This module owns those declarations — which scopes a
component requires. scopes_supported in the published RFC 9728 document
cannot do that job on its own: it is a flat list with no component in it, so it
can say "this server understands mcp:tools:write" but never "run_sql
requires mcp:tools:write". Something has to bind component to scopes, and
there are two places to write that binding:
in code
@dr_mcp_tool(auth=require_scopes("mcp:tools:write")) — :func:require_scopes
from this module (re-exported by datarobot_genai.drmcp), on the tool's own
decorator. The requirement travels with the component it guards and survives
a tag rename.
in configuration a scope requirement keyed on a tag the component already declares, so one setting guards every component carrying it and can differ per environment without a code change.
The checks attached here do two jobs. They record the requirement on the
component itself, where :func:declared_scopes_for_one_tool (what the
scope-validation middleware enforces a tools/call against) and
:func:derived_scopes (what the published scopes_supported is generated from)
can read it back. And they enforce it at the tool level with the same subset
test (:func:satisfies) against the scopes on the request's token — the token
the DataRobot gateway authenticated and OAuthJWTTokenHandlerMiddleware parsed
into request.scope["user"], which FastMCP hands to the check as ctx.token
— so an under-scoped token does not see the component in tools/list, while a
tools/call is refused with 403 by the middleware before FastMCP is reached. No
token on the request (the gate off, or no request at all) admits: this module
verifies nothing itself, authentication is the gateway's job. Server-side listings
that must see every component regardless of the caller's token (the /static/*
catalog, the lineage sync) run under :func:without_component_auth_checks.
The scope source selects which declaration mechanism is live and defaults to
both, so each mechanism simply applies wherever it is declared; set
code or tags only to silence the other one. It governs what the
middleware enforces and the published scopes_supported together, so the
server can never advertise a scope it is not enforcing — that list is always
derived from the declarations, never hand-written.
Matching (done by the middleware) is a subset test, never equality. Every
required scope must be present; anything else the token carries is not examined,
because one token serves a whole session and normally holds the scopes for every
component the client might call. Requirements also stack: each declaration adds
its own scopes and a tools/call must cover the union, so a component
matching two mapped tags — or carrying a code declaration and a tag under
both — requires all of them. Nothing is any-of.
This module holds the mechanism and nothing about where settings come from:
:class:ScopeSettings is handed in by the server that owns the configuration,
the same way the protected-resource metadata entities take theirs.
ScopeSource
Bases: StrEnum
Which declaration mechanism the server reads.
Defaults to :attr:BOTH, so each mechanism simply applies wherever it is
declared and neither has to be switched on. CODE and TAGS exist to
deliberately silence the other one.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
reads_code
property
Whether require_scopes(...) declared on components in code are read.
parse
classmethod
Return the matching member, falling back to the default.
An unrecognised value logs and falls back rather than failing the server: refusing to start would turn a typo into an outage.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
ScopeSettings
dataclass
What the scope declarations need, independent of where they were configured.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
normalize_tag
Return a tag in the form used to match configuration against components.
Tags are written one way in code (read-only) and another in an
environment variable name (READ_ONLY), so both sides are folded to upper
case with - treated as _. A consequence worth knowing: read-only
and read_only are the same tag as far as scope configuration goes.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
configure_scopes
active_settings
reset_scope_state
request_scopes
Return the scopes on this request's token, or None when it carries none.
ctx.token is what FastMCP read off request.scope["user"] — set by drmcp's
OAuthJWTTokenHandlerMiddleware from the gateway-authenticated token while
MCP_ENABLE_OAUTH_CLAIM_VALIDATION is on. Nothing is verified here.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
satisfies
Return whether the scopes on a request cover a requirement.
A subset test, never equality: every required scope must be present, anything
else the token carries is not examined. None — no token on the request —
admits: with the gate off no token is parsed, and outside a request there is
nothing to check against.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
require_scopes
Require OAuth scopes on one component, declared where it is defined.
Use on the tool's own decorator — @dr_mcp_tool(auth=require_scopes("a", "b"));
all listed scopes are required, not any one of them. The check enforces
:func:satisfies against the request's token (:func:request_scopes), so a
token short of a scope does not see the tool in tools/list; the
scope-validation middleware refuses its tools/call with 403
insufficient_scope first. A request without a token is admitted. It also
records the scope names on the component (:data:DECLARED_SCOPES_ATTR) so the
middleware, scopes_supported and the REST required_scopes field can read
them back — FastMCP's own require_scopes keeps them in a closure and fails a
tokenless request, which is why it is not used directly.
Inert under MCP_OAUTH_SCOPE_SOURCE=tags.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
restrict_tag_scopes
Require scopes on a component carrying tag.
The same contract as :func:require_scopes — subset test against the request's
token, tokenless requests admitted, scope names recorded. :func:apply_tag_scopes
attaches it only to the components that actually carry the tag, so no membership
test is needed at check time. The tag marker records which tag produced the check
(any truthy value marks it as configuration rather than code), so re-wiring can
replace these without touching in-code declarations. Inert under
MCP_OAUTH_SCOPE_SOURCE=code.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
collect_code_declared_scopes
async
Read back the scopes declared in code so they reach the published document.
Must run after the component modules are imported, and after any dynamic registration: before that there is nothing to read.
Checks attached by :func:apply_tag_scopes record their scopes too, but
they are configuration, not code, and are skipped here — counting them
would re-report every active tag rule as a code declaration, and under
source=tags warn it inert against the very source enforcing it.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
declared_scopes_of_component
Return the scopes component declares, whichever way they were declared.
The union across both declaration spellings — require_scopes(...) declared
on the component in code and tag-keyed configuration.
Honours the scope source, so a declaration the source silences is not
reported either: in-code
declarations contribute nothing under source=tags. (Tag rules under
source=code are never attached at all — see :func:apply_tag_scopes —
but the tag test keeps a stale check harmless between re-wires.)
One reader for everything that answers "what does this component require":
the scope-validation middleware (via :func:declared_scopes_for_one_tool)
and the static-catalog REST route's required_scopes field, so what is
reported is always what is enforced.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
declared_scopes_for_one_tool
async
Return the scopes tool_name declares, or None when no such tool exists.
What the scope-validation middleware checks a tools/call's token
against — :func:declared_scopes_of_component, looked up by name.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
without_component_auth_checks
Make FastMCP skip per-component auth checks for the listings inside.
For code that describes the server rather than serving a caller — the
/static/* catalog, the lineage sync. FastMCP evaluates auth checks inside
every list_tools() against the current request's token, so such a listing
made during a REST call would shrink to what that caller may call. FastMCP
skips component auth only for the stdio transport (see its _get_auth_context),
so this borrows that switch for the duration of the block through FastMCP's
public set_transport / reset_transport.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
apply_tag_scopes
async
Attach per-tag scope declarations to the components carrying those tags.
Returns the number of components that gained a declaration. Covers resources, resource templates and prompts as well as tools: this is where every component's requirements are recorded, so leaving the other component types out would make them undeclarable.
A no-op unless the scope source selects tags.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
derived_scopes
Return every scope this server declares, from whichever mechanisms are selected.
Both mechanisms are readable data, which is what lets the published
scopes_supported be generated rather than maintained by hand.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
report_enforcement_state
Say at startup which scope requirements are declared and which are inert.
A declaration silenced by the scope source fails silently in both
directions — it still publishes nothing and it still looks configured —
so the state is stated once rather than inferred from behaviour later.
Enforcement itself is the scope-validation middleware's job (each
tools/call is checked against :func:declared_scopes_for_one_tool),
so what this module can report on is the declarations.
Source code in datarobot_genai/drmcpbase/oauth_scopes.py
wire_scopes
async
Install settings, apply tag scopes, and read back code-declared ones.
Call at startup after every component is registered, and again whenever one is registered later — wiring attaches declarations to the components that exist, so a component added afterwards carries none of them until the next call. Re-wiring is idempotent: each pass replaces its own previous declarations rather than stacking a second copy, and leaves checks declared in code alone. Called with no settings, the ones already installed are reused.