diff --git a/CLAUDE.md b/CLAUDE.md index 58f33c5c2..a11bea87c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,7 @@ gh api -X POST repos/PrefectHQ/fastmcp/releases/generate-notes \ ### Module Exports +- **Do not create overeager `__init__.py` files.** Package initializers should not import heavy submodules, provider stacks, optional integrations, or modules that can point back into the package. Overeager re-exports make the framework sprawl and create circular imports that only appear in fresh interpreters or clean installs. - **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces - Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`) - Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`) diff --git a/fastmcp_slim/fastmcp/apps/__init__.py b/fastmcp_slim/fastmcp/apps/__init__.py index d8fc21696..3c0ec32c6 100644 --- a/fastmcp_slim/fastmcp/apps/__init__.py +++ b/fastmcp_slim/fastmcp/apps/__init__.py @@ -7,7 +7,8 @@ This package contains the app-related components: - ``ResourceCSP`` / ``ResourcePermissions`` — security configuration """ -from fastmcp.apps.app import FastMCPApp as FastMCPApp +from typing import TYPE_CHECKING as _TYPE_CHECKING + from fastmcp.apps.config import AppConfig as AppConfig from fastmcp.apps.config import PrefabAppConfig as PrefabAppConfig from fastmcp.apps.config import ResourceCSP as ResourceCSP @@ -16,3 +17,26 @@ from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID from fastmcp.apps.config import app_config_to_meta_dict as app_config_to_meta_dict from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type + +__all__ = [ + "UI_EXTENSION_ID", + "UI_MIME_TYPE", + "AppConfig", + "FastMCPApp", + "PrefabAppConfig", + "ResourceCSP", + "ResourcePermissions", + "app_config_to_meta_dict", + "resolve_ui_mime_type", +] + +if _TYPE_CHECKING: + from fastmcp.apps.app import FastMCPApp as FastMCPApp + + +def __getattr__(name: str) -> object: + if name == "FastMCPApp": + from fastmcp.apps.app import FastMCPApp + + return FastMCPApp + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/fastmcp_slim/fastmcp/apps/app.py b/fastmcp_slim/fastmcp/apps/app.py index 70783e48a..5be26b042 100644 --- a/fastmcp_slim/fastmcp/apps/app.py +++ b/fastmcp_slim/fastmcp/apps/app.py @@ -30,16 +30,18 @@ from __future__ import annotations import inspect from collections.abc import AsyncIterator, Callable, Sequence from contextlib import asynccontextmanager -from typing import Any, Literal, TypeVar, overload +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload from mcp.types import AnyFunction, Icon, ToolAnnotations -from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.tools.base import Tool +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from fastmcp.server.providers.local_provider import LocalProvider + from fastmcp.tools.base import Tool + logger = get_logger(__name__) F = TypeVar("F", bound=Callable[..., Any]) @@ -149,9 +151,11 @@ class FastMCPApp(Provider): """ def __init__(self, name: str) -> None: + from fastmcp.server.providers.local_provider import LocalProvider + super().__init__() self.name = name - self._local = LocalProvider(on_duplicate="error") + self._local: LocalProvider = LocalProvider(on_duplicate="error") def __repr__(self) -> str: return f"FastMCPApp({self.name!r})" @@ -215,6 +219,8 @@ class FastMCPApp(Provider): ) def _register(fn: F, tool_name: str | None) -> F: + from fastmcp.tools.base import Tool + resolved_name = tool_name or getattr(fn, "__name__", None) if resolved_name is None: raise ValueError(f"Cannot determine tool name for {fn!r}") @@ -315,6 +321,7 @@ class FastMCPApp(Provider): from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, ) + from fastmcp.tools.base import Tool resolved = tool_name or getattr(fn, "__name__", None) or "unknown" app_config = AppConfig( @@ -360,6 +367,8 @@ class FastMCPApp(Provider): The tool is tagged with this app's name for routing. """ + from fastmcp.tools.base import Tool + if not isinstance(tool, Tool): tool = Tool._ensure_tool(tool) diff --git a/fastmcp_slim/fastmcp/prompts/base.py b/fastmcp_slim/fastmcp/prompts/base.py index db399bde2..0b8680d47 100644 --- a/fastmcp_slim/fastmcp/prompts/base.py +++ b/fastmcp_slim/fastmcp/prompts/base.py @@ -30,10 +30,10 @@ from pydantic import Field from pydantic.json_schema import SkipJsonSchema from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import ( FastMCPBaseModel, ) diff --git a/fastmcp_slim/fastmcp/prompts/function_prompt.py b/fastmcp_slim/fastmcp/prompts/function_prompt.py index 3c4bd2f07..ffc5a35be 100644 --- a/fastmcp_slim/fastmcp/prompts/function_prompt.py +++ b/fastmcp_slim/fastmcp/prompts/function_prompt.py @@ -26,19 +26,15 @@ import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.exceptions import FastMCPDeprecationWarning, FastMCPError, PromptError from fastmcp.prompts.base 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.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import get_cached_typeadapter if TYPE_CHECKING: @@ -193,6 +189,11 @@ class FunctionPrompt(Prompt): ) # Transform Context type annotations to Depends() for unified DI + from fastmcp.server.dependencies import ( + transform_context_annotations, + without_injected_parameters, + ) + fn = transform_context_annotations(fn) # Wrap fn to handle dependency resolution internally diff --git a/fastmcp_slim/fastmcp/resources/base.py b/fastmcp_slim/fastmcp/resources/base.py index 70d422dab..c3024eff3 100644 --- a/fastmcp_slim/fastmcp/resources/base.py +++ b/fastmcp_slim/fastmcp/resources/base.py @@ -31,9 +31,9 @@ from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent +from fastmcp.utilities.tasks import TaskConfig, TaskMeta class ResourceContent(pydantic.BaseModel): diff --git a/fastmcp_slim/fastmcp/resources/function_resource.py b/fastmcp_slim/fastmcp/resources/function_resource.py index df542fcce..48a1d99c2 100644 --- a/fastmcp_slim/fastmcp/resources/function_resource.py +++ b/fastmcp_slim/fastmcp/resources/function_resource.py @@ -17,17 +17,13 @@ import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.exceptions import FastMCPDeprecationWarning from fastmcp.resources.base import Resource, ResourceResult -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.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.mime import resolve_ui_mime_type +from fastmcp.utilities.tasks import TaskConfig if TYPE_CHECKING: from docket import Docket @@ -182,6 +178,11 @@ class FunctionResource(Resource): fn = fn.__func__ # Transform Context type annotations to Depends() for unified DI + from fastmcp.server.dependencies import ( + transform_context_annotations, + without_injected_parameters, + ) + fn = transform_context_annotations(fn) # Wrap fn to handle dependency resolution internally diff --git a/fastmcp_slim/fastmcp/resources/template.py b/fastmcp_slim/fastmcp/resources/template.py index 0f6eb5e61..7fb36db69 100644 --- a/fastmcp_slim/fastmcp/resources/template.py +++ b/fastmcp_slim/fastmcp/resources/template.py @@ -24,15 +24,11 @@ from pydantic import ( ) from fastmcp.resources.base import Resource, ResourceResult -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.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.mime import resolve_ui_mime_type +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import get_cached_typeadapter @@ -580,6 +576,11 @@ class FunctionResourceTemplate(ResourceTemplate): raise ValueError("URI template must contain at least one parameter") # Use wrapper to get user-facing parameters (excludes injected params) + from fastmcp.server.dependencies import ( + transform_context_annotations, + without_injected_parameters, + ) + wrapper_fn = without_injected_parameters(fn) user_sig = inspect.signature(wrapper_fn) func_params = set(user_sig.parameters.keys()) diff --git a/fastmcp_slim/fastmcp/server/auth/authorization.py b/fastmcp_slim/fastmcp/server/auth/authorization.py index 64eb5f721..ba252f9d8 100644 --- a/fastmcp_slim/fastmcp/server/auth/authorization.py +++ b/fastmcp_slim/fastmcp/server/auth/authorization.py @@ -1,182 +1,17 @@ -"""Authorization checks for FastMCP components. +"""Backward-compatible exports for component authorization primitives.""" -This module provides callable-based authorization for tools, resources, and prompts. -Auth checks are functions that receive an AuthContext and return True to allow access -or False to deny. +from fastmcp.utilities.authorization import ( + AuthCheck, + AuthContext, + require_scopes, + restrict_tag, + run_auth_checks, +) -Auth checks can also raise exceptions: -- AuthorizationError: Propagates with the custom message for explicit denial -- Other exceptions: Masked for security (logged, treated as auth failure) - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth import require_scopes - - mcp = FastMCP() - - @mcp.tool(auth=require_scopes("write")) - def protected_tool(): ... - - @mcp.resource("data://secret", auth=require_scopes("read")) - def secret_data(): ... - - @mcp.prompt(auth=require_scopes("admin")) - def admin_prompt(): ... - ``` -""" - -from __future__ import annotations - -import inspect -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, cast - -from fastmcp.exceptions import AuthorizationError - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from fastmcp.server.auth import AccessToken - from fastmcp.tools.base import Tool - from fastmcp.utilities.components import FastMCPComponent - - -@dataclass -class AuthContext: - """Context passed to auth check callables. - - This object is passed to each auth check function and provides - access to the current authentication token and the component being accessed. - - Attributes: - token: The current access token, or None if unauthenticated. - component: The component (tool, resource, or prompt) being accessed. - tool: Backwards-compatible alias for component when it's a Tool. - """ - - token: AccessToken | None - component: FastMCPComponent - - @property - def tool(self) -> Tool | None: - """Backwards-compatible access to the component as a Tool. - - Returns the component if it's a Tool, None otherwise. - """ - from fastmcp.tools.base import Tool - - return self.component if isinstance(self.component, Tool) else None - - -# Type alias for auth check functions (sync or async) -AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] - - -def require_scopes(*scopes: str) -> AuthCheck: - """Require specific OAuth scopes. - - Returns an auth check that requires ALL specified scopes to be present - in the token (AND logic). - - Args: - *scopes: One or more scope strings that must all be present. - - Example: - ```python - @mcp.tool(auth=require_scopes("admin")) - def admin_tool(): ... - - @mcp.tool(auth=require_scopes("read", "write")) - def read_write_tool(): ... - ``` - """ - required = set(scopes) - - def check(ctx: AuthContext) -> bool: - if ctx.token is None: - return False - return required.issubset(set(ctx.token.scopes)) - - return check - - -def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: - """Restrict components with a specific tag to require certain scopes. - - If the component has the specified tag, the token must have ALL the - required scopes. If the component doesn't have the tag, access is allowed. - - Args: - tag: The tag that triggers the scope requirement. - scopes: List of scopes required when the tag is present. - - Example: - ```python - # Components tagged "admin" require the "admin" scope - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) - ``` - """ - required = set(scopes) - - def check(ctx: AuthContext) -> bool: - if tag not in ctx.component.tags: - return True # Tag not present, no restriction - if ctx.token is None: - return False - return required.issubset(set(ctx.token.scopes)) - - return check - - -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. Checks can be - synchronous or asynchronous functions. - - Auth checks can: - - Return True to allow access - - Return False to deny access - - Raise AuthorizationError to deny with a custom message (propagates) - - Raise other exceptions (masked for security, treated as denial) - - 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: - True if all checks pass, False if any check fails. - - Raises: - AuthorizationError: If an auth check explicitly raises it. - """ - check_list = [checks] if not isinstance(checks, list) else checks - check_list = cast(list[AuthCheck], check_list) - - for check in check_list: - try: - result = check(ctx) - if inspect.isawaitable(result): - result = await result - if not result: - return False - except AuthorizationError: - # Let AuthorizationError propagate with its custom message - raise - except Exception: - # Mask other exceptions for security - log and treat as auth failure - logger.warning( - f"Auth check {getattr(check, '__name__', repr(check))} " - "raised an unexpected exception", - exc_info=True, - ) - return False - - return True +__all__ = [ + "AuthCheck", + "AuthContext", + "require_scopes", + "restrict_tag", + "run_auth_checks", +] diff --git a/fastmcp_slim/fastmcp/server/providers/base.py b/fastmcp_slim/fastmcp/server/providers/base.py index e2429ee93..2025c4353 100644 --- a/fastmcp_slim/fastmcp/server/providers/base.py +++ b/fastmcp_slim/fastmcp/server/providers/base.py @@ -35,17 +35,17 @@ from typing import TYPE_CHECKING, Literal, cast from typing_extensions import Self -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms.visibility import Visibility -from fastmcp.tools.base import Tool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: + from fastmcp.prompts.base import Prompt + from fastmcp.resources.base import Resource + from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import Transform + from fastmcp.tools.base import Tool class Provider: @@ -480,10 +480,10 @@ class Provider: self._list_resource_templates(), self._list_prompts(), ) - tools = cast(Sequence[Tool], results[0]) - resources = cast(Sequence[Resource], results[1]) - templates = cast(Sequence[ResourceTemplate], results[2]) - prompts = cast(Sequence[Prompt], results[3]) + tools = cast("Sequence[Tool]", results[0]) + resources = cast("Sequence[Resource]", results[1]) + templates = cast("Sequence[ResourceTemplate]", results[2]) + prompts = cast("Sequence[Prompt]", results[3]) # Apply provider's own transforms sequentially # For tasks, we need the fully-transformed names diff --git a/fastmcp_slim/fastmcp/server/tasks/config.py b/fastmcp_slim/fastmcp/server/tasks/config.py index 1d5befa2a..b7fe2c50b 100644 --- a/fastmcp_slim/fastmcp/server/tasks/config.py +++ b/fastmcp_slim/fastmcp/server/tasks/config.py @@ -1,147 +1,19 @@ -"""TaskConfig for MCP SEP-1686 background task execution modes. +"""Backward-compatible exports for task configuration primitives.""" -This module defines the configuration for how tools, resources, and prompts -handle task-augmented execution as specified in SEP-1686. -""" +from fastmcp.utilities.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_POLL_INTERVAL_MS, + DEFAULT_TTL_MS, + TaskConfig, + TaskMeta, + TaskMode, +) -from __future__ import annotations - -import functools -import inspect -from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Literal - -from fastmcp.utilities.async_utils import is_coroutine_function - -# Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport -TaskMode = Literal["forbidden", "optional", "required"] - -# Default values for task metadata (single source of truth) -DEFAULT_POLL_INTERVAL = timedelta(seconds=5) # Default poll interval -DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000) -DEFAULT_TTL_MS = 60_000 # Default TTL in milliseconds - - -@dataclass -class TaskMeta: - """Metadata for task-augmented execution requests. - - When passed to call_tool/read_resource/get_prompt, signals that - the operation should be submitted as a background task. - - Attributes: - ttl: Client-requested TTL in milliseconds. If None, uses server default. - fn_key: Docket routing key. Auto-derived from component name if None. - """ - - ttl: int | None = None - fn_key: str | None = None - - -@dataclass -class TaskConfig: - """Configuration for MCP background task execution (SEP-1686). - - Controls how a component handles task-augmented requests: - - - "forbidden": Component does not support task execution. Clients must not - request task augmentation; server returns -32601 if they do. - - "optional": Component supports both synchronous and task execution. - Client may request task augmentation or call normally. - - "required": Component requires task execution. Clients must request task - augmentation; server returns -32601 if they don't. - - Important: - Task-enabled components must be available at server startup to be - registered with all Docket workers. Components added dynamically after - startup will not be registered for background execution. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.tasks import TaskConfig - - mcp = FastMCP("MyServer") - - # Background execution required - @mcp.tool(task=TaskConfig(mode="required")) - async def long_running_task(): ... - - # Supports both modes (default when task=True) - @mcp.tool(task=TaskConfig(mode="optional")) - async def flexible_task(): ... - ``` - """ - - mode: TaskMode = "optional" - poll_interval: timedelta = DEFAULT_POLL_INTERVAL - - @classmethod - def from_bool(cls, value: bool) -> TaskConfig: - """Convert boolean task flag to TaskConfig. - - Args: - value: True for "optional" mode, False for "forbidden" mode. - - Returns: - TaskConfig with appropriate mode. - """ - return cls(mode="optional" if value else "forbidden") - - def supports_tasks(self) -> bool: - """Check if this component supports task execution. - - Returns: - True if mode is "optional" or "required", False if "forbidden". - """ - return self.mode != "forbidden" - - def validate_function(self, fn: Callable[..., Any], name: str) -> None: - """Validate that function is compatible with this task config. - - Task execution requires: - 1. fastmcp[tasks] to be installed (pydocket) - 2. Async functions - - Raises ImportError if mode is "optional" or "required" but pydocket - is not installed. Raises ValueError if function is synchronous. - - Args: - fn: The function to validate (handles callable classes and staticmethods). - name: Name for error messages. - - Raises: - ImportError: If task execution is enabled but pydocket not installed. - ValueError: If task execution is enabled but function is sync. - """ - if not self.supports_tasks(): - return - - # Check that docket is available for task execution - # Lazy import to avoid circular: dependencies.py → http.py → tasks/__init__.py → config.py - from fastmcp.server.dependencies import require_docket - - require_docket(f"`task=True` on function '{name}'") - - # Unwrap callable classes and staticmethods - fn_to_check = fn - if ( - not inspect.isroutine(fn) - and not isinstance(fn, functools.partial) - and callable(fn) - ): - fn_to_check = fn.__call__ - if isinstance(fn_to_check, staticmethod): - fn_to_check = fn_to_check.__func__ - - if not is_coroutine_function(fn_to_check): - raise ValueError( - f"'{name}' uses a sync function but has task execution enabled. " - "Background tasks require async functions." - ) - - # Note: Context IS now available in background task workers (SEP-1686) - # The wiring in _CurrentContext creates a task-aware Context with task_id - # and session from the registry. No warning needed. +__all__ = [ + "DEFAULT_POLL_INTERVAL", + "DEFAULT_POLL_INTERVAL_MS", + "DEFAULT_TTL_MS", + "TaskConfig", + "TaskMeta", + "TaskMode", +] diff --git a/fastmcp_slim/fastmcp/tools/base.py b/fastmcp_slim/fastmcp/tools/base.py index 1e35d9493..8fc4f9932 100644 --- a/fastmcp_slim/fastmcp/tools/base.py +++ b/fastmcp_slim/fastmcp/tools/base.py @@ -27,10 +27,10 @@ from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig, TaskMeta from fastmcp.utilities.types import ( Audio, File, diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index 50d217ce6..cc7b253a0 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -13,10 +13,6 @@ import mcp.types from pydantic import PydanticSchemaGenerationError from typing_extensions import TypeVar as TypeVarExt -from fastmcp.server.dependencies import ( - transform_context_annotations, - without_injected_parameters, -) from fastmcp.tools.base import ToolResult from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring from fastmcp.utilities.json_schema import compress_schema @@ -188,6 +184,11 @@ class ParsedFunction: ) # Transform Context type annotations to Depends() for unified DI + from fastmcp.server.dependencies import ( + transform_context_annotations, + without_injected_parameters, + ) + fn = transform_context_annotations(fn) # Handle injected parameters (Context, Docket dependencies) diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index 5efafee57..91b9996e0 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -26,9 +26,6 @@ from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import get_fastmcp_meta, resolve_task_config from fastmcp.exceptions import FastMCPDeprecationWarning -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.base import ( Tool, ToolResult, @@ -39,7 +36,9 @@ from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) +from fastmcp.utilities.authorization import AuthCheck from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import ( NotSet, NotSetT, @@ -284,6 +283,8 @@ class FunctionTool(Tool): async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with arguments.""" + from fastmcp.server.dependencies import without_injected_parameters + wrapper_fn = without_injected_parameters( self.fn, run_in_thread=self.run_in_thread ) diff --git a/fastmcp_slim/fastmcp/utilities/authorization.py b/fastmcp_slim/fastmcp/utilities/authorization.py new file mode 100644 index 000000000..4e193c71d --- /dev/null +++ b/fastmcp_slim/fastmcp/utilities/authorization.py @@ -0,0 +1,101 @@ +"""Authorization checks for FastMCP components. + +Auth checks are callables that receive an ``AuthContext`` and return True to +allow access or False to deny it. They can also raise ``AuthorizationError`` to +deny with a custom message; other exceptions are masked and treated as denial. +""" + +from __future__ import annotations + +import inspect +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from fastmcp.exceptions import AuthorizationError + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from fastmcp.server.auth import AccessToken + from fastmcp.tools.base import Tool + from fastmcp.utilities.components import FastMCPComponent + + +@dataclass +class AuthContext: + """Context passed to auth check callables. + + Attributes: + token: The current access token, or None if unauthenticated. + component: The tool, resource, resource template, or prompt being accessed. + tool: Backwards-compatible alias for component when it is a Tool. + """ + + token: AccessToken | None + component: FastMCPComponent + + @property + def tool(self) -> Tool | None: + """Backwards-compatible access to the component as a Tool.""" + from fastmcp.tools.base import Tool + + return self.component if isinstance(self.component, Tool) else None + + +AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] + + +def require_scopes(*scopes: str) -> AuthCheck: + """Require all of the given OAuth scopes.""" + required = set(scopes) + + def check(ctx: AuthContext) -> bool: + if ctx.token is None: + return False + return required.issubset(set(ctx.token.scopes)) + + return check + + +def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: + """Require scopes when the accessed component has a specific tag.""" + required = set(scopes) + + def check(ctx: AuthContext) -> bool: + if tag not in ctx.component.tags: + return True + if ctx.token is None: + return False + return required.issubset(set(ctx.token.scopes)) + + return check + + +async def run_auth_checks( + checks: AuthCheck | list[AuthCheck], + ctx: AuthContext, +) -> bool: + """Run auth checks with AND logic.""" + check_list = [checks] if not isinstance(checks, list) else checks + check_list = cast(list[AuthCheck], check_list) + + for check in check_list: + try: + result = check(ctx) + if inspect.isawaitable(result): + result = await result + if not result: + return False + except AuthorizationError: + raise + except Exception: + logger.warning( + f"Auth check {getattr(check, '__name__', repr(check))} " + "raised an unexpected exception", + exc_info=True, + ) + return False + + return True diff --git a/fastmcp_slim/fastmcp/utilities/components.py b/fastmcp_slim/fastmcp/utilities/components.py index 732569e63..e481aac9e 100644 --- a/fastmcp_slim/fastmcp/utilities/components.py +++ b/fastmcp_slim/fastmcp/utilities/components.py @@ -7,7 +7,7 @@ from mcp.types import Icon from pydantic import BeforeValidator, Field from typing_extensions import Self, TypeVar -from fastmcp.server.tasks.config import TaskConfig +from fastmcp.utilities.tasks import TaskConfig from fastmcp.utilities.types import FastMCPBaseModel if TYPE_CHECKING: diff --git a/fastmcp_slim/fastmcp/utilities/tasks.py b/fastmcp_slim/fastmcp/utilities/tasks.py new file mode 100644 index 000000000..0886dacbb --- /dev/null +++ b/fastmcp_slim/fastmcp/utilities/tasks.py @@ -0,0 +1,80 @@ +"""Task configuration primitives for FastMCP components.""" + +from __future__ import annotations + +import functools +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Literal + +from fastmcp.utilities.async_utils import is_coroutine_function + +TaskMode = Literal["forbidden", "optional", "required"] + +DEFAULT_POLL_INTERVAL = timedelta(seconds=5) +DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000) +DEFAULT_TTL_MS = 60_000 + + +@dataclass +class TaskMeta: + """Metadata for task-augmented execution requests. + + Attributes: + ttl: Client-requested TTL in milliseconds. If None, uses server default. + fn_key: Docket routing key. Auto-derived from component name if None. + """ + + ttl: int | None = None + fn_key: str | None = None + + +@dataclass +class TaskConfig: + """Configuration for MCP background task execution. + + Controls how a component handles task-augmented requests: + + - ``forbidden``: Component does not support task execution. + - ``optional``: Component supports both synchronous and task execution. + - ``required``: Component requires task execution. + """ + + mode: TaskMode = "optional" + poll_interval: timedelta = DEFAULT_POLL_INTERVAL + + @classmethod + def from_bool(cls, value: bool) -> TaskConfig: + """Convert a boolean task flag to a TaskConfig.""" + return cls(mode="optional" if value else "forbidden") + + def supports_tasks(self) -> bool: + """Check if this component supports task execution.""" + return self.mode != "forbidden" + + def validate_function(self, fn: Callable[..., Any], name: str) -> None: + """Validate that a function is compatible with this task config.""" + if not self.supports_tasks(): + return + + from fastmcp.server.dependencies import require_docket + + require_docket(f"`task=True` on function '{name}'") + + fn_to_check = fn + if ( + not inspect.isroutine(fn) + and not isinstance(fn, functools.partial) + and callable(fn) + ): + fn_to_check = fn.__call__ + if isinstance(fn_to_check, staticmethod): + fn_to_check = fn_to_check.__func__ + + if not is_coroutine_function(fn_to_check): + raise ValueError( + f"'{name}' uses a sync function but has task execution enabled. " + "Background tasks require async functions." + ) diff --git a/tests/tools/test_standalone_decorator.py b/tests/tools/test_standalone_decorator.py index e204a0a9c..964fef813 100644 --- a/tests/tools/test_standalone_decorator.py +++ b/tests/tools/test_standalone_decorator.py @@ -5,6 +5,8 @@ to a server. Functions can be added explicitly via server.add_tool() or discovered by FileSystemProvider. """ +import subprocess +import sys from typing import cast import pytest @@ -16,6 +18,27 @@ from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import DecoratedTool, FunctionTool, ToolMeta +@pytest.mark.parametrize( + "statement", + [ + "from fastmcp.tools import tool", + "from fastmcp.resources import Resource, resource", + "from fastmcp.prompts import Prompt, prompt", + "import sys; import fastmcp.apps.config; assert 'fastmcp.tools.function_tool' not in sys.modules", + "from fastmcp.server.auth.authorization import AuthCheck", + "from fastmcp.server import Context, FastMCP, create_proxy", + ], +) +def test_component_import_works_in_fresh_interpreter(statement: str): + result = subprocess.run( + [sys.executable, "-c", statement], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + class TestToolDecorator: """Tests for the @tool decorator."""