diff --git a/AGENTS.md b/AGENTS.md index 1d6ff4e9b..c726c0504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 93420c6b8..f91734ba6 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -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. diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index e55bae102..0baaedbd6 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -62,7 +62,7 @@ A template for dynamically creating resources. #### `from_function` ```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` @@ -237,7 +237,7 @@ FunctionResourceTemplate splats the params dict since .fn expects **kwargs. #### `from_function` ```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. diff --git a/docs/python-sdk/fastmcp-server-auth-authorization.mdx b/docs/python-sdk/fastmcp-server-auth-authorization.mdx index 6268118d8..28663f6f8 100644 --- a/docs/python-sdk/fastmcp-server-auth-authorization.mdx +++ b/docs/python-sdk/fastmcp-server-auth-authorization.mdx @@ -36,7 +36,7 @@ Example: ## Functions -### `require_scopes` +### `require_scopes` ```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` +### `restrict_tag` ```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` +### `run_auth_checks` ```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` +### `AuthContext` Context passed to auth check callables. @@ -115,7 +117,7 @@ access to the current authentication token and the component being accessed. **Methods:** -#### `tool` +#### `tool` ```python tool(self) -> Tool | None diff --git a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx index bfb3ccea1..44558f560 100644 --- a/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx +++ b/docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx @@ -14,7 +14,7 @@ registration functionality to LocalProvider. ## Classes -### `ToolDecoratorMixin` +### `ToolDecoratorMixin` Mixin class providing tool decorator functionality for LocalProvider. @@ -26,7 +26,7 @@ This mixin contains all methods related to: **Methods:** -#### `add_tool` +#### `add_tool` ```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` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] diff --git a/docs/python-sdk/fastmcp-tools-tool.mdx b/docs/python-sdk/fastmcp-tools-tool.mdx index 1068a7716..f13d63f1c 100644 --- a/docs/python-sdk/fastmcp-tools-tool.mdx +++ b/docs/python-sdk/fastmcp-tools-tool.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -15,17 +15,17 @@ default_serializer(data: Any) -> str ## Classes -### `ToolResult` +### `ToolResult` **Methods:** -#### `to_mcp_result` +#### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` -### `Tool` +### `Tool` Internal tool registration info. @@ -33,7 +33,7 @@ Internal tool registration info. **Methods:** -#### `to_mcp_tool` +#### `to_mcp_tool` ```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` +#### `from_function` ```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` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -66,7 +66,7 @@ implemented by subclasses. (list of ContentBlocks, dict of structured output). -#### `convert_result` +#### `convert_result` ```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` +#### `register_with_docket` ```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` +#### `add_to_docket` ```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` +#### `from_tool` ```python from_tool(cls, tool: Tool) -> TransformedTool ``` -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] diff --git a/docs/servers/authorization.mdx b/docs/servers/authorization.mdx index 0ad0d569e..a48d2a9e8 100644 --- a/docs/servers/authorization.mdx +++ b/docs/servers/authorization.mdx @@ -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 diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 916838ede..a58700a01 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -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. diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 8cf2d3265..07540629e 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -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. diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index 76c6f974c..bf6673552 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -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. diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 1f36fd5e2..26ed535de 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -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, diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 8ce650073..c2fb1b622 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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.""" diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py index dd0e16cc1..8455b81f5 100644 --- a/src/fastmcp/server/auth/authorization.py +++ b/src/fastmcp/server/auth/authorization.py @@ -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 diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py index 6a50ed656..abe33447b 100644 --- a/src/fastmcp/server/middleware/authorization.py +++ b/src/fastmcp/server/middleware/authorization.py @@ -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" ) diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py index a25d8fa52..5e01b7a04 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py +++ b/src/fastmcp/server/providers/local_provider/decorators/prompts.py @@ -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 diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py index f6985b164..52314378e 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/resources.py +++ b/src/fastmcp/server/providers/local_provider/decorators/resources.py @@ -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. diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index 7527f1b76..c59fa8a3f 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 66c60d978..bd0e5e264 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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 diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 812f5108c..6c1a361f6 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -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. diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index e13cda280..bdacdac58 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -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 diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py index 6bab4cecd..4bd0dff9a 100644 --- a/tests/server/auth/test_authorization.py +++ b/tests/server/auth/test_authorization.py @@ -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 # =============================================================================