mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Decouple component imports from server (#4150)
This commit is contained in:
parent
255e3e4910
commit
d8dcc273ca
18 changed files with 325 additions and 375 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
101
fastmcp_slim/fastmcp/utilities/authorization.py
Normal file
101
fastmcp_slim/fastmcp/utilities/authorization.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
80
fastmcp_slim/fastmcp/utilities/tasks.py
Normal file
80
fastmcp_slim/fastmcp/utilities/tasks.py
Normal file
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue