diff --git a/docs/python-sdk/fastmcp-exceptions.mdx b/docs/python-sdk/fastmcp-exceptions.mdx index c7dcfeb93..a6ef59df9 100644 --- a/docs/python-sdk/fastmcp-exceptions.mdx +++ b/docs/python-sdk/fastmcp-exceptions.mdx @@ -10,7 +10,7 @@ Custom exceptions for FastMCP. ## Functions -### `to_mcp_error` +### `to_mcp_error` ```python to_mcp_error(exc: Exception) -> MCPError @@ -119,3 +119,16 @@ or policy tripped. Error when authorization check fails. + +### `InsufficientScopeError` + + +Authorization failed because the token is missing required OAuth scopes. + +Unlike a bare ``AuthorizationError``, this carries the specific scopes the +caller must obtain. A component-level scope shortfall can then be signalled +as a spec-correct ``insufficient_scope`` step-up (SEP-2350 / RFC 6750 §3), +naming exactly what to re-authorize for instead of an opaque denial. The +named scopes are only the *unmet* ones, so an existing grant is accumulated +rather than replaced when the caller re-authorizes. + diff --git a/docs/python-sdk/fastmcp-utilities-authorization.mdx b/docs/python-sdk/fastmcp-utilities-authorization.mdx index f70c44283..0f64a0a4a 100644 --- a/docs/python-sdk/fastmcp-utilities-authorization.mdx +++ b/docs/python-sdk/fastmcp-utilities-authorization.mdx @@ -15,7 +15,7 @@ deny with a custom message; other exceptions are masked and treated as denial. ## Functions -### `require_scopes` +### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck @@ -25,7 +25,52 @@ require_scopes(*scopes: str) -> AuthCheck Require all of the given OAuth scopes. -### `restrict_tag` +### `require_roles` + +```python +require_roles(*roles: str) -> AuthCheck +``` + + +Require all of the given roles, read from the token's claims. + +Roles and groups are not part of OIDC, so every identity provider puts them +somewhere different: `realm_access.roles` on Keycloak, `roles` on Microsoft +Entra, `cognito:groups` on AWS Cognito, `permissions` or a namespaced custom +claim on Auth0. `extract` receives the token's claims and returns the +caller's roles, which keeps that provider-specific knowledge at the call +site instead of guessing it here. + +```python +from fastmcp.server.auth import require_roles + +keycloak = require_roles("admin", extract=lambda c: c["realm_access"]["roles"]) +cognito = require_roles("admins", extract=lambda c: c["cognito:groups"]) +``` + +A token missing the claim entirely is denied rather than treated as an +error, so `extract` may index into the claims without guarding. An +extractor returning a bare string is treated as one role, since a provider +that stores a single role as a scalar is common. + +Unlike `require_scopes`, this check cannot signal a shortfall: OAuth has no +way to request a role, so there is no `insufficient_scope` challenge to +emit. A role denial is therefore reported as a plain `AuthorizationError`, +and it suppresses any scope shortfall alongside it — a caller blocked by +their role must not be told to go obtain a scope that would not help. +Scope shortfalls are still reported normally whenever the role check +passes. + +**Args:** +- `*roles`: Roles the caller must hold. All are required (AND logic). +- `extract`: Callable mapping the token's claims to the caller's roles. + +**Raises:** +- `ValueError`: If no roles are given, which would allow any authenticated +caller and is more likely a mistake than an intent. + + +### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck @@ -35,14 +80,62 @@ restrict_tag(tag: str) -> AuthCheck Require scopes when the accessed component has a specific tag. -### `run_auth_checks` +### `scope_requirements` + +```python +scope_requirements(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> list[str] | None +``` + + +Scopes a check list requires but the token lacks, without running it. + +Returns ``None`` when the list contains any opaque (non-scope) check. Such a +check might deny for a reason unrelated to scopes, and evaluating it here +would run authorization logic — with whatever side effects it carries — +outside its normal place in the chain. Since its verdict is unknown, its +siblings' scopes must not be disclosed either, so the whole list is withheld. + +When every check is scope-aware, the result is their combined shortfall, +computed purely from the token and component (an empty list means the list is +already satisfied). This lets a shortfall be aggregated across authorization +layers without evaluating anything that would otherwise be skipped. + + +### `run_auth_checks_with_shortfall` + +```python +run_auth_checks_with_shortfall(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> tuple[bool, list[str]] +``` + + +Run auth checks with AND logic, classifying the denial cause. + +Returns ``(authorized, missing_scopes)``. ``missing_scopes`` names every +scope the caller must obtain to satisfy *all* scope requirements at once: +the union of the shortfalls across every scope-aware check, not just the +first one to fail. Reporting only the first would strand a caller in a +step-up loop — it obtains that scope, retries, and is denied again for the +next — so the union is what makes a single re-authorization converge. + +The challenge is withheld entirely (an empty list, which the caller surfaces +as a plain ``AuthorizationError``) unless every non-scope check passes. A +custom policy denial — a tenant check, say — must never be reported as an +``insufficient_scope`` shortfall, and must never name the scopes of a +component the caller could not otherwise reach. To guarantee that, the +opaque checks are all evaluated before any scope is disclosed; a shortfall +is only reported once they have all passed. + +An ``AuthorizationError`` raised by a check propagates unchanged. + + +### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool ``` -Run auth checks with AND logic. +Run auth checks with AND logic, stopping at the first failure. ## Classes diff --git a/docs/python-sdk/fastmcp-utilities-json_schema.mdx b/docs/python-sdk/fastmcp-utilities-json_schema.mdx index f4f952a2a..8f8e580bf 100644 --- a/docs/python-sdk/fastmcp-utilities-json_schema.mdx +++ b/docs/python-sdk/fastmcp-utilities-json_schema.mdx @@ -7,7 +7,7 @@ sidebarTitle: json_schema ## Functions -### `require_discriminator_property` +### `require_discriminator_property` ```python require_discriminator_property(schema: dict[str, Any]) -> dict[str, Any] @@ -24,7 +24,7 @@ model with ``union_tag_not_found``. No-op if there is no string ``propertyName``. -### `dereference_refs` +### `dereference_refs` ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] @@ -57,7 +57,7 @@ schemas from untrusted servers. - when no longer needed -### `resolve_root_ref` +### `resolve_root_ref` ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] @@ -79,7 +79,7 @@ the referenced definition while preserving $defs for nested references. - if no resolution is needed -### `compress_schema` +### `compress_schema` ```python compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 34d543b4b..6b8ca2ce8 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -77,7 +77,7 @@ This is used to exclude parameters from type adapter processing when they can't The excluded parameters are removed from the function's __annotations__ dictionary. -### `replace_type` +### `replace_type` ```python replace_type(type_, type_map: dict[type, type]) @@ -145,13 +145,13 @@ Helper class for returning audio from tools. **Methods:** -#### `to_audio_content` +#### `to_audio_content` ```python to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.AudioContent ``` -### `File` +### `File` Helper class for returning file data from tools. @@ -159,10 +159,10 @@ Helper class for returning file data from tools. **Methods:** -#### `to_resource_content` +#### `to_resource_content` ```python to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp_types.EmbeddedResource ``` -### `ContextSamplingFallbackProtocol` +### `ContextSamplingFallbackProtocol`