Support async auth checks (#3152)

Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
This commit is contained in:
Jeremiah Lowin 2026-02-11 12:48:51 -05:00 committed by GitHub
commit 50b23299f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 292 additions and 118 deletions

View file

@ -96,7 +96,7 @@ When modifying MCP functionality, changes typically need to be applied across al
- Uses Mintlify framework
- Files must be in docs.json to be included
- Never modify `docs/python-sdk/**` (auto-generated)
- Do not manually modify `docs/python-sdk/**` — a bot automatically updates these files via commits added to PRs
- **Core Principle:** A feature doesn't exist unless it is documented!
### Documentation Guidelines

View file

@ -66,6 +66,10 @@ async def get_emails(
Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/jlowin/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration.
### Async Auth Checks
Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/jlowin/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy).
### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed
Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved.

View file

@ -62,7 +62,7 @@ A template for dynamically creating resources.
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/resources/template.py#L130" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate
from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate
```
#### `set_default_mime_type` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/resources/template.py#L163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
@ -237,7 +237,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs.
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/resources/template.py#L460" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None) -> FunctionResourceTemplate
from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate
```
Create a template from a function.

View file

@ -36,7 +36,7 @@ Example:
## Functions
### `require_scopes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L77" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `require_scopes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L78" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
require_scopes(*scopes: str) -> AuthCheck
@ -52,7 +52,7 @@ in the token (AND logic).
- `*scopes`: One or more scope strings that must all be present.
### `restrict_tag` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `restrict_tag` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L106" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
restrict_tag(tag: str) -> AuthCheck
@ -69,7 +69,7 @@ required scopes. If the component doesn't have the tag, access is allowed.
- `scopes`: List of scopes required when the tag is present.
### `run_auth_checks` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L133" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `run_auth_checks` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L134" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool
@ -78,7 +78,8 @@ run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool
Run auth checks with AND logic.
All checks must pass for authorization to succeed.
All checks must pass for authorization to succeed. Checks can be
synchronous or asynchronous functions.
Auth checks can:
- Return True to allow access
@ -88,6 +89,7 @@ Auth checks can:
**Args:**
- `checks`: A single check function or list of check functions.
Each check can be sync (returns bool) or async (returns Awaitable[bool]).
- `ctx`: The auth context to pass to each check.
**Returns:**
@ -99,7 +101,7 @@ Auth checks can:
## Classes
### `AuthContext` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L47" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `AuthContext` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L48" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Context passed to auth check callables.
@ -115,7 +117,7 @@ access to the current authentication token and the component being accessed.
**Methods:**
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/auth/authorization.py#L64" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self) -> Tool | None

View file

@ -14,7 +14,7 @@ registration functionality to LocalProvider.
## Classes
### `ToolDecoratorMixin` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L31" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ToolDecoratorMixin` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L32" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Mixin class providing tool decorator functionality for LocalProvider.
@ -26,7 +26,7 @@ This mixin contains all methods related to:
**Methods:**
#### `add_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L39" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `add_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L40" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool
@ -37,19 +37,19 @@ Add a tool to this provider's storage.
Accepts either a Tool object or a decorated function with __fastmcp__ metadata.
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L90" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L91" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self: LocalProvider, name_or_fn: AnyFunction) -> FunctionTool
```
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L112" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L113" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool]
```
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L137" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/server/providers/local_provider/decorators/tools.py#L138" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool]

View file

@ -7,7 +7,7 @@ sidebarTitle: tool
## Functions
### `default_serializer` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L59" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `default_serializer` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L56" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
default_serializer(data: Any) -> str
@ -15,17 +15,17 @@ default_serializer(data: Any) -> str
## Classes
### `ToolResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L63" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `ToolResult` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L60" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
**Methods:**
#### `to_mcp_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L108" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `to_mcp_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L105" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult
```
### `Tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L124" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
### `Tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L121" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
Internal tool registration info.
@ -33,7 +33,7 @@ Internal tool registration info.
**Methods:**
#### `to_mcp_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L166" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `to_mcp_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L163" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
to_mcp_tool(self, **overrides: Any) -> MCPTool
@ -42,7 +42,7 @@ to_mcp_tool(self, **overrides: Any) -> MCPTool
Convert the FastMCP tool to an MCP tool.
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L193" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `from_function` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L190" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_function(cls, fn: Callable[..., Any]) -> FunctionTool
@ -51,7 +51,7 @@ from_function(cls, fn: Callable[..., Any]) -> FunctionTool
Create a Tool from a function.
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L233" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `run` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L230" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
run(self, arguments: dict[str, Any]) -> ToolResult
@ -66,7 +66,7 @@ implemented by subclasses.
(list of ContentBlocks, dict of structured output).
#### `convert_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L245" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `convert_result` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L242" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
convert_result(self, raw_value: Any) -> ToolResult
@ -78,7 +78,7 @@ Handles ToolResult passthrough and converts raw values using the tool's
attributes (serializer, output_schema) for proper conversion.
#### `register_with_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L337" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `register_with_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L334" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
register_with_docket(self, docket: Docket) -> None
@ -87,7 +87,7 @@ register_with_docket(self, docket: Docket) -> None
Register this tool with docket for background execution.
#### `add_to_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L343" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `add_to_docket` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L340" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution
@ -103,13 +103,13 @@ Schedule this tool for background execution via docket.
- `**kwargs`: Additional kwargs passed to docket.add()
#### `from_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L367" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `from_tool` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L364" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
from_tool(cls, tool: Tool) -> TransformedTool
```
#### `get_span_attributes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L398" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
#### `get_span_attributes` <sup><a href="https://github.com/jlowin/fastmcp/blob/main/src/fastmcp/tools/tool.py#L395" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
```python
get_span_attributes(self) -> dict[str, Any]

View file

@ -24,7 +24,7 @@ When an `AuthProvider` is configured, all requests to the MCP endpoint must carr
## Auth Checks
An auth check is any callable that accepts an `AuthContext` and returns a boolean. The `AuthContext` provides access to the current token (if any) and the component being accessed.
An auth check is any callable that accepts an `AuthContext` and returns a boolean. Auth checks can be synchronous or asynchronous, so checks that need to perform async operations (like reading server state or calling external services) work naturally.
```python
from fastmcp.server.auth import AuthContext
@ -137,6 +137,34 @@ def advanced_feature() -> str:
return "Advanced feature"
```
### Async Auth Checks
Auth checks can be `async` functions, which is useful when the authorization decision depends on asynchronous operations like reading server state or querying external services.
```python
from fastmcp import FastMCP
from fastmcp.server.auth import AuthContext
mcp = FastMCP("Async Auth Server")
async def check_user_permissions(ctx: AuthContext) -> bool:
"""Async auth check that reads server state."""
if ctx.token is None:
return False
user_id = ctx.token.claims.get("sub")
# Async operations work naturally in auth checks
permissions = await fetch_user_permissions(user_id)
return "admin" in permissions
@mcp.tool(auth=check_user_permissions)
def admin_tool() -> str:
return "Admin action completed"
```
Sync and async checks can be freely combined in a list — each check is handled according to its type.
### Error Handling
Auth checks can raise exceptions for explicit denial with custom messages:
- **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied
@ -346,7 +374,7 @@ def require_matching_tag(ctx: AuthContext) -> bool:
from fastmcp.server.auth import (
AccessToken, # Token with .token, .client_id, .scopes, .expires_at, .claims
AuthContext, # Context with .token, .component
AuthCheck, # Type alias: Callable[[AuthContext], bool]
AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool]
require_scopes, # Built-in: requires specific scopes
restrict_tag, # Built-in: tag-based scope requirements
run_auth_checks, # Utility: run checks with AND logic

View file

@ -25,12 +25,12 @@ import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.exceptions import PromptError
from fastmcp.prompts.prompt import Prompt, PromptArgument, PromptResult
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@ -67,7 +67,7 @@ class PromptMeta:
tags: set[str] | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
@ -91,7 +91,7 @@ class FunctionPrompt(Prompt):
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.
@ -377,7 +377,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@overload
def prompt(
@ -391,7 +391,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@ -406,7 +406,7 @@ def prompt(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP prompt.

View file

@ -27,8 +27,8 @@ from mcp.types import PromptArgument as SDKPromptArgument
from pydantic import Field
from pydantic.json_schema import SkipJsonSchema
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import (
@ -195,7 +195,7 @@ class Prompt(FastMCPComponent):
arguments: list[PromptArgument] | None = Field(
default=None, description="Arguments that can be passed to the prompt"
)
auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field(
auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field(
default=None, description="Authorization checks for this prompt", exclude=True
)
@ -237,7 +237,7 @@ class Prompt(FastMCPComponent):
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.

View file

@ -16,12 +16,12 @@ import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
if TYPE_CHECKING:
@ -57,7 +57,7 @@ class ResourceMeta:
annotations: Annotations | None = None
meta: dict[str, Any] | None = None
task: bool | TaskConfig | None = None
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
@ -94,7 +94,7 @@ class FunctionResource(Resource):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
"""Create a FunctionResource from a function.
@ -246,7 +246,7 @@ def resource(
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]:
"""Standalone decorator to mark a function as an MCP resource.

View file

@ -29,8 +29,8 @@ from pydantic import (
from pydantic.json_schema import SkipJsonSchema
from typing_extensions import Self
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
@ -227,7 +227,7 @@ class Resource(FastMCPComponent):
Field(description="Optional annotations about the resource's behavior"),
] = None
auth: Annotated[
SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None],
SkipJsonSchema[AuthCheck | list[AuthCheck] | None],
Field(description="Authorization checks for this resource", exclude=True),
] = None
@ -247,7 +247,7 @@ class Resource(FastMCPComponent):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResource:
from fastmcp.resources.function_resource import (
FunctionResource,

View file

@ -24,12 +24,12 @@ from pydantic import (
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
)
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.tools.tool import AuthCheckCallable
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.types import get_cached_typeadapter
@ -117,7 +117,7 @@ class ResourceTemplate(FastMCPComponent):
annotations: Annotations | None = Field(
default=None, description="Optional annotations about the resource's behavior"
)
auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field(
auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field(
default=None,
description="Authorization checks for this resource template",
exclude=True,
@ -140,7 +140,7 @@ class ResourceTemplate(FastMCPComponent):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResourceTemplate:
return FunctionResourceTemplate.from_function(
fn=fn,
@ -471,7 +471,7 @@ class FunctionResourceTemplate(ResourceTemplate):
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionResourceTemplate:
"""Create a template from a function."""

View file

@ -28,8 +28,9 @@ Example:
from __future__ import annotations
import inspect
import logging
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast
@ -70,8 +71,8 @@ class AuthContext:
return self.component if isinstance(self.component, Tool) else None
# Type alias for auth check functions
AuthCheck = Callable[[AuthContext], bool]
# Type alias for auth check functions (sync or async)
AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]]
def require_scopes(*scopes: str) -> AuthCheck:
@ -130,13 +131,14 @@ def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck:
return check
def run_auth_checks(
async def run_auth_checks(
checks: AuthCheck | list[AuthCheck],
ctx: AuthContext,
) -> bool:
"""Run auth checks with AND logic.
All checks must pass for authorization to succeed.
All checks must pass for authorization to succeed. Checks can be
synchronous or asynchronous functions.
Auth checks can:
- Return True to allow access
@ -146,6 +148,7 @@ def run_auth_checks(
Args:
checks: A single check function or list of check functions.
Each check can be sync (returns bool) or async (returns Awaitable[bool]).
ctx: The auth context to pass to each check.
Returns:
@ -159,7 +162,10 @@ def run_auth_checks(
for check in check_list:
try:
if not check(ctx):
result = check(ctx)
if inspect.isawaitable(result):
result = await result
if not result:
return False
except AuthorizationError:
# Let AuthorizationError propagate with its custom message

View file

@ -102,7 +102,7 @@ class AuthMiddleware(Middleware):
authorized_tools: list[Tool] = []
for tool in tools:
ctx = AuthContext(token=token, component=tool)
if run_auth_checks(self.auth, ctx):
if await run_auth_checks(self.auth, ctx):
authorized_tools.append(tool)
return authorized_tools
@ -143,7 +143,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=tool)
if not run_auth_checks(self.auth, ctx):
if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for tool '{tool_name}': insufficient permissions"
)
@ -169,7 +169,7 @@ class AuthMiddleware(Middleware):
authorized_resources: list[Resource] = []
for resource in resources:
ctx = AuthContext(token=token, component=resource)
if run_auth_checks(self.auth, ctx):
if await run_auth_checks(self.auth, ctx):
authorized_resources.append(resource)
return authorized_resources
@ -210,7 +210,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=component)
if not run_auth_checks(self.auth, ctx):
if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for resource '{uri}': insufficient permissions"
)
@ -238,7 +238,7 @@ class AuthMiddleware(Middleware):
authorized_templates: list[ResourceTemplate] = []
for template in templates:
ctx = AuthContext(token=token, component=template)
if run_auth_checks(self.auth, ctx):
if await run_auth_checks(self.auth, ctx):
authorized_templates.append(template)
return authorized_templates
@ -262,7 +262,7 @@ class AuthMiddleware(Middleware):
authorized_prompts: list[Prompt] = []
for prompt in prompts:
ctx = AuthContext(token=token, component=prompt)
if run_auth_checks(self.auth, ctx):
if await run_auth_checks(self.auth, ctx):
authorized_prompts.append(prompt)
return authorized_prompts
@ -301,7 +301,7 @@ class AuthMiddleware(Middleware):
# Global auth check
token = get_access_token()
ctx = AuthContext(token=token, component=prompt)
if not run_auth_checks(self.auth, ctx):
if not await run_auth_checks(self.auth, ctx):
raise AuthorizationError(
f"Authorization failed for prompt '{prompt_name}': insufficient permissions"
)

View file

@ -17,8 +17,8 @@ from mcp.types import AnyFunction
import fastmcp
from fastmcp.prompts.function_prompt import FunctionPrompt
from fastmcp.prompts.prompt import Prompt
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import AuthCheckCallable
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@ -82,7 +82,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt: ...
@overload
@ -99,7 +99,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
@ -115,7 +115,7 @@ class PromptDecoratorMixin:
enabled: bool = True,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt

View file

@ -17,8 +17,8 @@ import fastmcp
from fastmcp.resources.function_resource import resource as standalone_resource
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.tool import AuthCheckCallable
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
@ -117,7 +117,7 @@ class ResourceDecoratorMixin:
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]:
"""Decorator to register a function as a resource.

View file

@ -16,9 +16,10 @@ import mcp.types
from mcp.types import AnyFunction, ToolAnnotations
import fastmcp
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool import AuthCheckCallable, Tool
from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import NotSet, NotSetT
if TYPE_CHECKING:
@ -105,7 +106,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool: ...
@overload
@ -127,7 +128,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
# NOTE: This method mirrors fastmcp.tools.tool() but adds registration,
@ -152,7 +153,7 @@ class ToolDecoratorMixin:
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool

View file

@ -61,7 +61,7 @@ from fastmcp.server.apps import (
app_config_to_meta_dict,
resolve_ui_mime_type,
)
from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.lifespan import Lifespan
from fastmcp.server.low_level import LowLevelServer
@ -78,7 +78,7 @@ from fastmcp.server.transforms import (
from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled
from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool import AuthCheckCallable, Tool, ToolResult
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
@ -509,7 +509,7 @@ class FastMCP(
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
try:
if not run_auth_checks(tool.auth, ctx):
if not await run_auth_checks(tool.auth, ctx):
continue
except AuthorizationError:
continue
@ -540,7 +540,7 @@ class FastMCP(
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
try:
if not run_auth_checks(tool.auth, ctx):
if not await run_auth_checks(tool.auth, ctx):
return None
except AuthorizationError:
return None
@ -607,7 +607,7 @@ class FastMCP(
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
try:
if not run_auth_checks(resource.auth, ctx):
if not await run_auth_checks(resource.auth, ctx):
continue
except AuthorizationError:
continue
@ -638,7 +638,7 @@ class FastMCP(
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
try:
if not run_auth_checks(resource.auth, ctx):
if not await run_auth_checks(resource.auth, ctx):
return None
except AuthorizationError:
return None
@ -706,7 +706,7 @@ class FastMCP(
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
try:
if not run_auth_checks(template.auth, ctx):
if not await run_auth_checks(template.auth, ctx):
continue
except AuthorizationError:
continue
@ -737,7 +737,7 @@ class FastMCP(
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
try:
if not run_auth_checks(template.auth, ctx):
if not await run_auth_checks(template.auth, ctx):
return None
except AuthorizationError:
return None
@ -801,7 +801,7 @@ class FastMCP(
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
try:
if not run_auth_checks(prompt.auth, ctx):
if not await run_auth_checks(prompt.auth, ctx):
continue
except AuthorizationError:
continue
@ -832,7 +832,7 @@ class FastMCP(
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
try:
if not run_auth_checks(prompt.auth, ctx):
if not await run_auth_checks(prompt.auth, ctx):
return None
except AuthorizationError:
return None
@ -1283,7 +1283,7 @@ class FastMCP(
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool: ...
@overload
@ -1304,7 +1304,7 @@ class FastMCP(
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
def tool(
@ -1324,7 +1324,7 @@ class FastMCP(
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
| FunctionTool
@ -1445,7 +1445,7 @@ class FastMCP(
meta: dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]:
"""Decorator to register a function as a resource.
@ -1576,7 +1576,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionPrompt: ...
@overload
@ -1592,7 +1592,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[AnyFunction], FunctionPrompt]: ...
def prompt(
@ -1607,7 +1607,7 @@ class FastMCP(
tags: set[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> (
Callable[[AnyFunction], FunctionPrompt]
| FunctionPrompt

View file

@ -24,11 +24,11 @@ from pydantic.json_schema import SkipJsonSchema
import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.dependencies import without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema
from fastmcp.tools.tool import (
AuthCheckCallable,
Tool,
ToolResult,
ToolResultSerializerType,
@ -78,7 +78,7 @@ class ToolMeta:
exclude_args: list[str] | None = None
serializer: Any | None = None
timeout: float | None = None
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
auth: AuthCheck | list[AuthCheck] | None = None
enabled: bool = True
@ -123,7 +123,7 @@ class FunctionTool(Tool):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool:
"""Create a FunctionTool from a function.
@ -345,7 +345,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@overload
def tool(
@ -364,7 +364,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Callable[[F], F]: ...
@ -384,7 +384,7 @@ def tool(
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP tool.

View file

@ -26,6 +26,7 @@ from mcp.types import Tool as MCPTool
from pydantic import BaseModel, Field, model_validator
from pydantic.json_schema import SkipJsonSchema
from fastmcp.server.auth.authorization import AuthCheck
from fastmcp.server.tasks.config import TaskConfig, TaskMeta
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
@ -37,10 +38,6 @@ from fastmcp.utilities.types import (
NotSetT,
)
# Runtime type alias for auth checks to avoid circular imports with authorization.py
# AuthCheck is Callable[[AuthContext], bool] but we use Any to avoid the import
AuthCheckCallable: TypeAlias = Callable[[Any], bool]
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
@ -147,7 +144,7 @@ class Tool(FastMCPComponent):
),
] = None
auth: Annotated[
SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None],
SkipJsonSchema[AuthCheck | list[AuthCheck] | None],
Field(description="Authorization checks for this tool", exclude=True),
] = None
timeout: Annotated[
@ -207,7 +204,7 @@ class Tool(FastMCPComponent):
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
auth: AuthCheck | list[AuthCheck] | None = None,
) -> FunctionTool:
"""Create a Tool from a function."""
from fastmcp.tools.function_tool import FunctionTool

View file

@ -120,31 +120,31 @@ class TestRestrictTag:
class TestRunAuthChecks:
def test_single_check_passes(self):
async def test_single_check_passes(self):
ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool())
assert run_auth_checks(require_scopes("test"), ctx) is True
assert await run_auth_checks(require_scopes("test"), ctx) is True
def test_single_check_fails(self):
async def test_single_check_fails(self):
ctx = AuthContext(token=None, component=make_tool())
assert run_auth_checks(require_scopes("test"), ctx) is False
assert await run_auth_checks(require_scopes("test"), ctx) is False
def test_multiple_checks_all_pass(self):
async def test_multiple_checks_all_pass(self):
token = make_token(scopes=["test", "admin"])
ctx = AuthContext(token=token, component=make_tool())
checks = [require_scopes("test"), require_scopes("admin")]
assert run_auth_checks(checks, ctx) is True
assert await run_auth_checks(checks, ctx) is True
def test_multiple_checks_one_fails(self):
async def test_multiple_checks_one_fails(self):
token = make_token(scopes=["read"])
ctx = AuthContext(token=token, component=make_tool())
checks = [require_scopes("read"), require_scopes("admin")]
assert run_auth_checks(checks, ctx) is False
assert await run_auth_checks(checks, ctx) is False
def test_empty_list_passes(self):
async def test_empty_list_passes(self):
ctx = AuthContext(token=None, component=make_tool())
assert run_auth_checks([], ctx) is True
assert await run_auth_checks([], ctx) is True
def test_custom_lambda_check(self):
async def test_custom_lambda_check(self):
token = make_token()
token.claims = {"level": 5}
ctx = AuthContext(token=token, component=make_tool())
@ -152,9 +152,9 @@ class TestRunAuthChecks:
def check(ctx: AuthContext) -> bool:
return ctx.token is not None and ctx.token.claims.get("level", 0) >= 3
assert run_auth_checks(check, ctx) is True
assert await run_auth_checks(check, ctx) is True
def test_authorization_error_propagates(self):
async def test_authorization_error_propagates(self):
"""AuthorizationError from auth check should propagate with custom message."""
from fastmcp.exceptions import AuthorizationError
@ -163,9 +163,9 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
with pytest.raises(AuthorizationError, match="Custom denial reason"):
run_auth_checks(custom_auth_check, ctx)
await run_auth_checks(custom_auth_check, ctx)
def test_generic_exception_is_masked(self):
async def test_generic_exception_is_masked(self):
"""Generic exceptions from auth checks should be masked (return False)."""
def buggy_auth_check(ctx: AuthContext) -> bool:
@ -173,9 +173,9 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
# Should return False, not raise the ValueError
assert run_auth_checks(buggy_auth_check, ctx) is False
assert await run_auth_checks(buggy_auth_check, ctx) is False
def test_authorization_error_stops_chain(self):
async def test_authorization_error_stops_chain(self):
"""AuthorizationError should stop the check chain and propagate."""
from fastmcp.exceptions import AuthorizationError
@ -195,11 +195,62 @@ class TestRunAuthChecks:
ctx = AuthContext(token=make_token(), component=make_tool())
with pytest.raises(AuthorizationError, match="Explicit denial"):
run_auth_checks([check_1, check_2, check_3], ctx)
await run_auth_checks([check_1, check_2, check_3], ctx)
# Check 3 should not be called
assert call_order == [1, 2]
async def test_async_check_passes(self):
"""Async auth check functions should be awaited."""
async def async_check(ctx: AuthContext) -> bool:
return ctx.token is not None
ctx = AuthContext(token=make_token(), component=make_tool())
assert await run_auth_checks(async_check, ctx) is True
async def test_async_check_fails(self):
"""Async auth check that returns False should deny access."""
async def async_check(ctx: AuthContext) -> bool:
return False
ctx = AuthContext(token=make_token(), component=make_tool())
assert await run_auth_checks(async_check, ctx) is False
async def test_mixed_sync_and_async_checks(self):
"""A mix of sync and async checks should all be evaluated."""
def sync_check(ctx: AuthContext) -> bool:
return True
async def async_check(ctx: AuthContext) -> bool:
return ctx.token is not None
ctx = AuthContext(token=make_token(scopes=["test"]), component=make_tool())
checks = [sync_check, async_check, require_scopes("test")]
assert await run_auth_checks(checks, ctx) is True
async def test_async_check_exception_is_masked(self):
"""Async checks that raise non-AuthorizationError should be masked."""
async def buggy_async_check(ctx: AuthContext) -> bool:
raise ValueError("async error")
ctx = AuthContext(token=make_token(), component=make_tool())
assert await run_auth_checks(buggy_async_check, ctx) is False
async def test_async_check_authorization_error_propagates(self):
"""Async checks that raise AuthorizationError should propagate."""
from fastmcp.exceptions import AuthorizationError
async def async_denial(ctx: AuthContext) -> bool:
raise AuthorizationError("Async denial")
ctx = AuthContext(token=make_token(), component=make_tool())
with pytest.raises(AuthorizationError, match="Async denial"):
await run_auth_checks(async_denial, ctx)
# =============================================================================
# Tests for tool-level auth with FastMCP
@ -454,6 +505,91 @@ class TestAuthIntegration:
auth_context_var.reset(tok)
# =============================================================================
# Integration tests with async auth checks
# =============================================================================
class TestAsyncAuthIntegration:
async def test_async_auth_check_filters_tool_listing(self):
"""Async auth checks should work for filtering tool lists."""
mcp = FastMCP()
async def check_claims(ctx: AuthContext) -> bool:
return ctx.token is not None and ctx.token.claims.get("role") == "admin"
@mcp.tool(auth=check_claims)
def admin_tool() -> str:
return "admin"
@mcp.tool
def public_tool() -> str:
return "public"
# Without token, only public tool visible
tools = await mcp.list_tools()
assert len(tools) == 1
assert tools[0].name == "public_tool"
# With correct claims, both visible
token = make_token()
token.claims = {"role": "admin"}
tok = set_token(token)
try:
tools = await mcp.list_tools()
assert len(tools) == 2
finally:
auth_context_var.reset(tok)
async def test_async_auth_check_on_tool_call(self):
"""Async auth checks should work for tool execution via client."""
mcp = FastMCP()
async def check_claims(ctx: AuthContext) -> bool:
return ctx.token is not None and ctx.token.claims.get("role") == "admin"
@mcp.tool(auth=check_claims)
def admin_tool() -> str:
return "secret"
token = make_token()
token.claims = {"role": "admin"}
tok = set_token(token)
try:
async with Client(mcp) as client:
result = await client.call_tool("admin_tool", {})
assert result.content[0].text == "secret"
finally:
auth_context_var.reset(tok)
async def test_async_auth_middleware(self):
"""Async auth checks should work with AuthMiddleware."""
async def async_scope_check(ctx: AuthContext) -> bool:
return ctx.token is not None and "api" in ctx.token.scopes
mcp = FastMCP(middleware=[AuthMiddleware(auth=async_scope_check)])
@mcp.tool
def api_tool() -> str:
return "api"
# Without token, tool is hidden
result = await mcp._list_tools_mcp(__import__("mcp").types.ListToolsRequest())
assert len(result.tools) == 0
# With token containing "api" scope, tool is visible
token = make_token(scopes=["api"])
tok = set_token(token)
try:
result = await mcp._list_tools_mcp(
__import__("mcp").types.ListToolsRequest()
)
assert len(result.tools) == 1
finally:
auth_context_var.reset(tok)
# =============================================================================
# Tests for transformed tools preserving auth
# =============================================================================