diff --git a/pyproject.toml b/pyproject.toml index 991203674..d473983b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "mcp>=1.17.0,<2.0.0", "openapi-pydantic>=0.5.1", "platformdirs>=4.0.0", + "pydocket>=0.12.0", "rich>=13.9.4", "cyclopts>=3.0.0", "authlib>=1.5.2", diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py new file mode 100644 index 000000000..12b80e029 --- /dev/null +++ b/src/fastmcp/dependencies.py @@ -0,0 +1,10 @@ +"""Dependency injection exports for FastMCP. + +This module re-exports dependency injection symbols from Docket and FastMCP +to provide a clean, centralized import location for all dependency-related +functionality. +""" + +from docket import Depends + +__all__ = ["Depends"] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index f8498237d..5b623efe3 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -14,13 +14,12 @@ from pydantic import Field, TypeAdapter from fastmcp.exceptions import PromptError -from fastmcp.server.dependencies import get_context +from fastmcp.server.dependencies import get_context, without_injected_parameters from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( FastMCPBaseModel, - find_kwarg_by_type, get_cached_typeadapter, ) @@ -178,7 +177,6 @@ def from_function( - A dict (converted to a message) - A sequence of any of the above """ - from fastmcp.server.context import Context func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ @@ -201,15 +199,10 @@ def from_function( if isinstance(fn, staticmethod): fn = fn.__func__ - type_adapter = get_cached_typeadapter(fn) + wrapper_fn = without_injected_parameters(fn) + type_adapter = get_cached_typeadapter(wrapper_fn) parameters = type_adapter.json_schema() - - # Auto-detect context parameter if not provided - - context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - prune_params = [context_kwarg] if context_kwarg else None - - parameters = compress_schema(parameters, prune_params=prune_params) + parameters = compress_schema(parameters, prune_titles=True) # Convert parameters to PromptArguments arguments: list[PromptArgument] = [] @@ -224,7 +217,6 @@ def from_function( if ( sig_param.annotation != inspect.Parameter.empty and sig_param.annotation is not str - and param_name != context_kwarg ): # Get the JSON schema for this specific parameter type try: @@ -266,23 +258,16 @@ def from_function( def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Convert string arguments to expected types based on function signature.""" - from fastmcp.server.context import Context + from fastmcp.server.dependencies import without_injected_parameters - sig = inspect.signature(self.fn) + wrapper_fn = without_injected_parameters(self.fn) + sig = inspect.signature(wrapper_fn) converted_kwargs = {} - # Find context parameter name if any - context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context) - for param_name, param_value in kwargs.items(): if param_name in sig.parameters: param = sig.parameters[param_name] - # Skip Context parameters - they're handled separately - if param_name == context_param_name: - converted_kwargs[param_name] = param_value - continue - # If parameter has no annotation or annotation is str, pass as-is if ( param.annotation == inspect.Parameter.empty @@ -320,7 +305,7 @@ async def render( arguments: dict[str, Any] | None = None, ) -> list[PromptMessage]: """Render the prompt with arguments.""" - from fastmcp.server.context import Context + from fastmcp.server.dependencies import resolve_dependencies # Validate required arguments if self.arguments: @@ -331,19 +316,16 @@ async def render( raise ValueError(f"Missing required arguments: {missing}") try: - # Prepare arguments with context + # Prepare arguments kwargs = arguments.copy() if arguments else {} - context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) - if context_kwarg and context_kwarg not in kwargs: - kwargs[context_kwarg] = get_context() - # Convert string arguments to expected types when needed + # Convert string arguments to expected types BEFORE validation kwargs = self._convert_string_arguments(kwargs) - # Call function and check if result is a coroutine - result = self.fn(**kwargs) - if inspect.isawaitable(result): - result = await result + async with resolve_dependencies(self.fn, kwargs) as resolved_kwargs: + result = self.fn(**resolved_kwargs) + if inspect.isawaitable(result): + result = await result # Validate messages if not isinstance(result, list | tuple): diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index f27070447..70dd0d987 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -22,7 +22,6 @@ from fastmcp.server.dependencies import get_context from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.types import ( - find_kwarg_by_type, get_fn_name, ) @@ -204,16 +203,12 @@ def from_function( async def read(self) -> str | bytes: """Read the resource by calling the wrapped function.""" - from fastmcp.server.context import Context + from fastmcp.server.dependencies import resolve_dependencies - kwargs = {} - context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) - if context_kwarg is not None: - kwargs[context_kwarg] = get_context() - - result = self.fn(**kwargs) - if inspect.isawaitable(result): - result = await result + async with resolve_dependencies(self.fn, {}) as kwargs: + result = self.fn(**kwargs) + if inspect.isawaitable(result): + result = await result if isinstance(result, Resource): return await result.read() diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index cde1d273c..68ee9470b 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -17,13 +17,10 @@ ) from fastmcp.resources.resource import Resource -from fastmcp.server.dependencies import get_context +from fastmcp.server.dependencies import get_context, without_injected_parameters from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.types import ( - find_kwarg_by_type, - get_cached_typeadapter, -) +from fastmcp.utilities.types import get_cached_typeadapter def extract_query_params(uri_template: str) -> set[str]: @@ -242,42 +239,34 @@ class FunctionResourceTemplate(ResourceTemplate): async def read(self, arguments: dict[str, Any]) -> str | bytes: """Read the resource content.""" - from fastmcp.server.context import Context - - # Add context to parameters if needed - kwargs = arguments.copy() - context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) - if context_kwarg and context_kwarg not in kwargs: - kwargs[context_kwarg] = get_context() + from fastmcp.server.dependencies import resolve_dependencies # Type coercion for query parameters (which arrive as strings) - # Get function signature for type hints + kwargs = arguments.copy() sig = inspect.signature(self.fn) for param_name, param_value in list(kwargs.items()): if param_name in sig.parameters and isinstance(param_value, str): param = sig.parameters[param_name] annotation = param.annotation - # Skip if no annotation or annotation is str if annotation is inspect.Parameter.empty or annotation is str: continue - # Handle common type coercions try: if annotation is int: kwargs[param_name] = int(param_value) elif annotation is float: kwargs[param_name] = float(param_value) elif annotation is bool: - # Handle boolean strings kwargs[param_name] = param_value.lower() in ("true", "1", "yes") except (ValueError, AttributeError): - # Let validate_call handle the error pass - result = self.fn(**kwargs) - if inspect.isawaitable(result): - result = await result + async with resolve_dependencies(self.fn, kwargs) as resolved_kwargs: + result = self.fn(**resolved_kwargs) + if inspect.isawaitable(result): + result = await result + return result @classmethod @@ -296,7 +285,6 @@ def from_function( meta: dict[str, Any] | None = None, ) -> FunctionResourceTemplate: """Create a template from a function.""" - from fastmcp.server.context import Context func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": @@ -311,10 +299,6 @@ def from_function( "Functions with *args are not supported as resource templates" ) - # Auto-detect context parameter if not provided - - context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - # Extract path and query parameters from URI template path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) query_params = extract_query_params(uri_template) @@ -323,24 +307,23 @@ def from_function( if not all_uri_params: raise ValueError("URI template must contain at least one parameter") - func_params = set(sig.parameters.keys()) - if context_kwarg: - func_params.discard(context_kwarg) + # Use wrapper to get user-facing parameters (excludes injected params) + wrapper_fn = without_injected_parameters(fn) + user_sig = inspect.signature(wrapper_fn) + func_params = set(user_sig.parameters.keys()) # Get required and optional function parameters required_params = { p for p in func_params - if sig.parameters[p].default is inspect.Parameter.empty - and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD - and p != context_kwarg + if user_sig.parameters[p].default is inspect.Parameter.empty + and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD } optional_params = { p for p in func_params - if sig.parameters[p].default is not inspect.Parameter.empty - and sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD - and p != context_kwarg + if user_sig.parameters[p].default is not inspect.Parameter.empty + and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD } # Validate RFC 6570 query parameters @@ -377,15 +360,13 @@ def from_function( if isinstance(fn, staticmethod): fn = fn.__func__ - type_adapter = get_cached_typeadapter(fn) + wrapper_fn = without_injected_parameters(fn) + type_adapter = get_cached_typeadapter(wrapper_fn) parameters = type_adapter.json_schema() + parameters = compress_schema(parameters, prune_titles=True) - # compress the schema - prune_params = [context_kwarg] if context_kwarg else None - parameters = compress_schema(parameters, prune_params=prune_params) - - # ensure the arguments are properly cast - fn = validate_call(fn) + # Use validate_call on wrapper for runtime type coercion + fn = validate_call(wrapper_fn) return cls( uri_template=uri_template, diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 4a9481834..d96fc0a77 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -1,8 +1,13 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING +import inspect +from collections.abc import AsyncGenerator, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from functools import lru_cache +from typing import TYPE_CHECKING, Any, get_type_hints +from docket.dependencies import _Depends, get_dependency_parameters from mcp.server.auth.middleware.auth_context import ( get_access_token as _sdk_get_access_token, ) @@ -12,6 +17,7 @@ from starlette.requests import Request from fastmcp.server.auth import AccessToken +from fastmcp.utilities.types import is_class_member_of_type if TYPE_CHECKING: from fastmcp.server.context import Context @@ -22,10 +28,202 @@ "get_context", "get_http_headers", "get_http_request", + "resolve_dependencies", + "without_injected_parameters", ] -# --- Context --- +def _find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None: + """Find the name of the kwarg that is of type kwarg_type. + + This is the legacy dependency injection approach, used specifically for + injecting the Context object when a function parameter is typed as Context. + + Includes union types that contain the kwarg_type, as well as Annotated types. + """ + + if inspect.ismethod(fn) and hasattr(fn, "__func__"): + fn = fn.__func__ + + try: + type_hints = get_type_hints(fn, include_extras=True) + except Exception: + type_hints = getattr(fn, "__annotations__", {}) + + sig = inspect.signature(fn) + for name, param in sig.parameters.items(): + annotation = type_hints.get(name, param.annotation) + if is_class_member_of_type(annotation, kwarg_type): + return name + return None + + +@lru_cache(maxsize=5000) +def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: + """Create a wrapper function without injected parameters. + + Returns a wrapper that excludes Context and Docket dependency parameters, + making it safe to use with Pydantic TypeAdapter for schema generation and + validation. The wrapper internally handles all dependency resolution and + Context injection when called. + + Args: + fn: Original function with Context and/or dependencies + + Returns: + Async wrapper function without injected parameters + """ + from fastmcp.server.context import Context + + # Identify parameters to exclude + context_kwarg = _find_kwarg_by_type(fn, Context) + dependency_params = get_dependency_parameters(fn) + + exclude = set() + if context_kwarg: + exclude.add(context_kwarg) + if dependency_params: + exclude.update(dependency_params.keys()) + + if not exclude: + return fn + + # Build new signature with only user parameters + sig = inspect.signature(fn) + user_params = [ + param for name, param in sig.parameters.items() if name not in exclude + ] + new_sig = inspect.Signature(user_params) + + # Create async wrapper that handles dependency resolution + async def wrapper(**user_kwargs: Any) -> Any: + async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs: + result = fn(**resolved_kwargs) + if inspect.isawaitable(result): + result = await result + return result + + # Set wrapper metadata (only parameter annotations, not return type) + wrapper.__signature__ = new_sig # type: ignore + wrapper.__annotations__ = { + k: v + for k, v in getattr(fn, "__annotations__", {}).items() + if k not in exclude and k != "return" + } + wrapper.__name__ = getattr(fn, "__name__", "wrapper") + wrapper.__doc__ = getattr(fn, "__doc__", None) + + return wrapper + + +@asynccontextmanager +async def _resolve_fastmcp_dependencies( + fn: Callable[..., Any], arguments: dict[str, Any] +) -> AsyncGenerator[dict[str, Any], None]: + """Resolve Docket dependencies for a FastMCP function. + + Sets up the minimal context needed for Docket's Depends() to work: + - A cache for resolved dependencies + - An AsyncExitStack for managing context manager lifetimes + + Note: This does NOT set up Docket's Execution context. If user code needs + Docket-specific dependencies like TaskArgument(), TaskKey(), etc., those + will fail with clear errors about missing context. + + Args: + fn: The function to resolve dependencies for + arguments: The arguments passed to the function + + Yields: + Dictionary of resolved dependencies merged with provided arguments + """ + dependency_params = get_dependency_parameters(fn) + + if not dependency_params: + yield arguments + return + + # Initialize dependency cache and exit stack + cache_token = _Depends.cache.set({}) + try: + async with AsyncExitStack() as stack: + stack_token = _Depends.stack.set(stack) + try: + resolved: dict[str, Any] = {} + + for parameter, dependency in dependency_params.items(): + # If argument was explicitly provided, use that instead + if parameter in arguments: + resolved[parameter] = arguments[parameter] + continue + + # Resolve the dependency + try: + resolved[parameter] = await stack.enter_async_context( + dependency + ) + except Exception as error: + fn_name = getattr(fn, "__name__", repr(fn)) + raise RuntimeError( + f"Failed to resolve dependency '{parameter}' for {fn_name}" + ) from error + + # Merge resolved dependencies with provided arguments + final_arguments = {**arguments, **resolved} + + yield final_arguments + finally: + _Depends.stack.reset(stack_token) + finally: + _Depends.cache.reset(cache_token) + + +@asynccontextmanager +async def resolve_dependencies( + fn: Callable[..., Any], arguments: dict[str, Any] +) -> AsyncGenerator[dict[str, Any], None]: + """Resolve dependencies and inject Context for a FastMCP function. + + This function: + 1. Filters out any dependency parameter names from user arguments (security) + 2. Resolves Docket dependencies + 3. Injects Context if needed + 4. Merges everything together + + The filtering prevents external callers from overriding injected parameters by + providing values for dependency parameter names. This is a security feature. + + Args: + fn: The function to resolve dependencies for + arguments: User arguments (may contain keys that match dependency names, + which will be filtered out) + + Yields: + Dictionary of filtered user args + resolved dependencies + Context + + Example: + ```python + async with resolve_dependencies(my_tool, {"name": "Alice"}) as kwargs: + result = my_tool(**kwargs) + if inspect.isawaitable(result): + result = await result + ``` + """ + from fastmcp.server.context import Context + + # Filter out dependency parameters from user arguments to prevent override + # This is a security measure - external callers should never be able to + # provide values for injected parameters + dependency_params = get_dependency_parameters(fn) + user_args = {k: v for k, v in arguments.items() if k not in dependency_params} + + async with _resolve_fastmcp_dependencies(fn, user_args) as resolved_kwargs: + # Inject Context if needed + context_kwarg = _find_kwarg_by_type(fn, kwarg_type=Context) + if context_kwarg and context_kwarg not in resolved_kwargs: + resolved_kwargs[context_kwarg] = get_context() + + yield resolved_kwargs def get_context() -> Context: @@ -37,9 +235,6 @@ def get_context() -> Context: return context -# --- HTTP Request --- - - def get_http_request() -> Request: from mcp.server.lowlevel.server import request_ctx @@ -98,9 +293,6 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: return {} -# --- Access Token --- - - def get_access_token() -> AccessToken | None: """ Get the FastMCP access token from the current context. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1ce5fbf38..ea6e6b70b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1687,8 +1687,6 @@ async def get_weather(city: str) -> str: ) def decorator(fn: AnyFunction) -> Resource | ResourceTemplate: - from fastmcp.server.context import Context - if isinstance(fn, classmethod): # type: ignore[reportUnnecessaryIsInstance] raise ValueError( inspect.cleandoc( @@ -1703,12 +1701,11 @@ def decorator(fn: AnyFunction) -> Resource | ResourceTemplate: # Check if this should be a template has_uri_params = "{" in uri and "}" in uri - # check if the function has any parameters (other than injected context) - has_func_params = any( - p - for p in inspect.signature(fn).parameters.values() - if p.annotation is not Context - ) + # Use wrapper to check for user-facing parameters + from fastmcp.server.dependencies import without_injected_parameters + + wrapper_fn = without_injected_parameters(fn) + has_func_params = bool(inspect.signature(wrapper_fn).parameters) if has_uri_params or has_func_params: template = ResourceTemplate.from_function( diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b58645579..4d86228c8 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -22,7 +22,7 @@ from typing_extensions import TypeVar import fastmcp -from fastmcp.server.dependencies import get_context +from fastmcp.server.dependencies import get_context, without_injected_parameters from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -32,7 +32,6 @@ Image, NotSet, NotSetT, - find_kwarg_by_type, get_cached_typeadapter, replace_type, ) @@ -307,17 +306,9 @@ def from_function( async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with arguments.""" - from fastmcp.server.context import Context - - arguments = arguments.copy() - - context_kwarg = find_kwarg_by_type(self.fn, kwarg_type=Context) - if context_kwarg and context_kwarg not in arguments: - arguments[context_kwarg] = get_context() - - type_adapter = get_cached_typeadapter(self.fn) + wrapper_fn = without_injected_parameters(self.fn) + type_adapter = get_cached_typeadapter(wrapper_fn) result = type_adapter.validate_python(arguments) - if inspect.isawaitable(result): result = await result @@ -372,8 +363,6 @@ def from_function( validate: bool = True, wrap_non_object_output_schema: bool = True, ) -> ParsedFunction: - from fastmcp.server.context import Context - if validate: sig = inspect.signature(fn) # Reject functions with *args or **kwargs @@ -409,15 +398,12 @@ def from_function( if isinstance(fn, staticmethod): fn = fn.__func__ - prune_params: list[str] = [] - context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - if context_kwarg: - prune_params.append(context_kwarg) - if exclude_args: - prune_params.extend(exclude_args) - - input_type_adapter = get_cached_typeadapter(fn) + wrapper_fn = without_injected_parameters(fn) + input_type_adapter = get_cached_typeadapter(wrapper_fn) input_schema = input_type_adapter.json_schema() + + # Compress and handle exclude_args + prune_params = list(exclude_args) if exclude_args else None input_schema = compress_schema( input_schema, prune_params=prune_params, prune_titles=True ) diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py new file mode 100644 index 000000000..de2ccb790 --- /dev/null +++ b/tests/server/test_dependencies.py @@ -0,0 +1,702 @@ +"""Tests for Docket-style dependency injection in FastMCP.""" + +from contextlib import asynccontextmanager, contextmanager + +import pytest +from mcp.types import TextContent, TextResourceContents + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.dependencies import Depends +from fastmcp.server.context import Context + + +class Connection: + """Test connection that tracks whether it's currently open.""" + + def __init__(self): + self.is_open = False + + async def __aenter__(self): + self.is_open = True + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.is_open = False + + +@asynccontextmanager +async def get_connection(): + """Dependency that provides an open connection.""" + async with Connection() as conn: + yield conn + + +@pytest.fixture +def mcp(): + """Create a FastMCP server for testing.""" + return FastMCP("test-server") + + +async def test_depends_with_sync_function(mcp: FastMCP): + """Test that Depends works with sync dependency functions.""" + + def get_config() -> dict[str, str]: + return {"api_key": "secret123", "endpoint": "https://api.example.com"} + + @mcp.tool() + def fetch_data(query: str, config: dict[str, str] = Depends(get_config)) -> str: + return ( + f"Fetching '{query}' from {config['endpoint']} with key {config['api_key']}" + ) + + async with Client(mcp) as client: + result = await client.call_tool("fetch_data", {"query": "users"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Fetching 'users' from https://api.example.com" in content.text + assert "secret123" in content.text + + +async def test_depends_with_async_function(mcp: FastMCP): + """Test that Depends works with async dependency functions.""" + + async def get_user_id() -> int: + return 42 + + @mcp.tool() + async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: + return f"Hello {name}, your ID is {user_id}" + + async with Client(mcp) as client: + result = await client.call_tool("greet_user", {"name": "Alice"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Hello Alice, your ID is 42" + + +async def test_depends_with_async_context_manager(mcp: FastMCP): + """Test that Depends works with async context managers for resource management.""" + cleanup_called = False + + @asynccontextmanager + async def get_database(): + db = "db_connection" + try: + yield db + finally: + nonlocal cleanup_called + cleanup_called = True + + @mcp.tool() + async def query_db(sql: str, db: str = Depends(get_database)) -> str: + return f"Executing '{sql}' on {db}" + + async with Client(mcp) as client: + result = await client.call_tool("query_db", {"sql": "SELECT * FROM users"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Executing 'SELECT * FROM users' on db_connection" in content.text + assert cleanup_called + + +async def test_nested_dependencies(mcp: FastMCP): + """Test that dependencies can depend on other dependencies.""" + + def get_base_url() -> str: + return "https://api.example.com" + + def get_api_client(base_url: str = Depends(get_base_url)) -> dict[str, str]: + return {"base_url": base_url, "version": "v1"} + + @mcp.tool() + async def call_api( + endpoint: str, client: dict[str, str] = Depends(get_api_client) + ) -> str: + return f"Calling {client['base_url']}/{client['version']}/{endpoint}" + + async with Client(mcp) as client: + result = await client.call_tool("call_api", {"endpoint": "users"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Calling https://api.example.com/v1/users" + + +async def test_dependencies_excluded_from_schema(mcp: FastMCP): + """Test that dependency parameters don't appear in the tool schema.""" + + def get_config() -> dict[str, str]: + return {"key": "value"} + + @mcp.tool() + async def my_tool( + name: str, age: int, config: dict[str, str] = Depends(get_config) + ) -> str: + return f"{name} is {age} years old" + + tools = await mcp._list_tools_mcp() + tool = next(t for t in tools if t.name == "my_tool") + + assert "name" in tool.inputSchema["properties"] + assert "age" in tool.inputSchema["properties"] + assert "config" not in tool.inputSchema["properties"] + assert len(tool.inputSchema["properties"]) == 2 + + +async def test_backward_compat_context_still_works(mcp: FastMCP): + """Test that existing Context injection via type annotation still works.""" + + @mcp.tool() + async def get_request_id(ctx: Context) -> str: + return ctx.request_id + + async with Client(mcp) as client: + result = await client.call_tool("get_request_id", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert len(content.text) > 0 + + +async def test_sync_tool_with_async_dependency(mcp: FastMCP): + """Test that sync tools work with async dependencies.""" + + async def fetch_config() -> str: + return "loaded_config" + + @mcp.tool() + def process_data(value: int, config: str = Depends(fetch_config)) -> str: + return f"Processing {value} with {config}" + + async with Client(mcp) as client: + result = await client.call_tool("process_data", {"value": 100}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "Processing 100 with loaded_config" + + +async def test_dependency_caching(mcp: FastMCP): + """Test that dependencies are cached within a single tool call.""" + call_count = 0 + + def expensive_dependency() -> int: + nonlocal call_count + call_count += 1 + return 42 + + @mcp.tool() + async def tool_with_cached_dep( + dep1: int = Depends(expensive_dependency), + dep2: int = Depends(expensive_dependency), + ) -> str: + return f"{dep1} + {dep2} = {dep1 + dep2}" + + async with Client(mcp) as client: + result = await client.call_tool("tool_with_cached_dep", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "42 + 42 = 84" + assert call_count == 1 + + +async def test_context_and_depends_together(mcp: FastMCP): + """Test that Context type injection and Depends can be used together.""" + + def get_multiplier() -> int: + return 10 + + @mcp.tool() + async def mixed_deps( + value: int, ctx: Context, multiplier: int = Depends(get_multiplier) + ) -> str: + assert isinstance(ctx, Context) + assert ctx.request_id + assert len(ctx.request_id) > 0 + return ( + f"Request {ctx.request_id}: {value} * {multiplier} = {value * multiplier}" + ) + + async with Client(mcp) as client: + result = await client.call_tool("mixed_deps", {"value": 5}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "5 * 10 = 50" in content.text + assert "Request " in content.text + + +async def test_resource_with_dependency(mcp: FastMCP): + """Test that resources support dependency injection.""" + + def get_storage_path() -> str: + return "/data/config" + + @mcp.resource("config://settings") + async def get_settings(storage: str = Depends(get_storage_path)) -> str: + return f"Settings loaded from {storage}" + + async with Client(mcp) as client: + result = await client.read_resource("config://settings") + assert len(result) == 1 + content = result[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Settings loaded from /data/config" + + +async def test_resource_with_context_and_dependency(mcp: FastMCP): + """Test that resources can use both Context and Depends.""" + + def get_prefix() -> str: + return "DATA" + + @mcp.resource("config://info") + async def get_info(ctx: Context, prefix: str = Depends(get_prefix)) -> str: + return f"{prefix}: Request {ctx.request_id}" + + async with Client(mcp) as client: + result = await client.read_resource("config://info") + assert len(result) == 1 + content = result[0] + assert isinstance(content, TextResourceContents) + assert "DATA: Request " in content.text + assert len(content.text.split("Request ")[1]) > 0 + + +async def test_prompt_with_dependency(mcp: FastMCP): + """Test that prompts support dependency injection.""" + + def get_tone() -> str: + return "friendly and helpful" + + @mcp.prompt() + async def custom_prompt(topic: str, tone: str = Depends(get_tone)) -> str: + return f"Write about {topic} in a {tone} tone" + + async with Client(mcp) as client: + result = await client.get_prompt("custom_prompt", {"topic": "Python"}) + assert len(result.messages) == 1 + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert content.text == "Write about Python in a friendly and helpful tone" + + +async def test_prompt_with_context_and_dependency(mcp: FastMCP): + """Test that prompts can use both Context and Depends.""" + + def get_style() -> str: + return "concise" + + @mcp.prompt() + async def styled_prompt( + query: str, ctx: Context, style: str = Depends(get_style) + ) -> str: + assert isinstance(ctx, Context) + assert ctx.request_id + return f"Answer '{query}' in a {style} style" + + async with Client(mcp) as client: + result = await client.get_prompt("styled_prompt", {"query": "What is MCP?"}) + assert len(result.messages) == 1 + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert content.text == "Answer 'What is MCP?' in a concise style" + + +async def test_resource_template_with_dependency(mcp: FastMCP): + """Test that resource templates support dependency injection.""" + + def get_base_path() -> str: + return "/var/data" + + @mcp.resource("data://{filename}") + async def get_file(filename: str, base_path: str = Depends(get_base_path)) -> str: + return f"Reading {base_path}/{filename}" + + async with Client(mcp) as client: + result = await client.read_resource("data://config.txt") + assert len(result) == 1 + content = result[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Reading /var/data/config.txt" + + +async def test_resource_template_with_context_and_dependency(mcp: FastMCP): + """Test that resource templates can use both Context and Depends.""" + + def get_version() -> str: + return "v2" + + @mcp.resource("api://{endpoint}") + async def call_endpoint( + endpoint: str, ctx: Context, version: str = Depends(get_version) + ) -> str: + assert isinstance(ctx, Context) + assert ctx.request_id + return f"Calling {version}/{endpoint}" + + async with Client(mcp) as client: + result = await client.read_resource("api://users") + assert len(result) == 1 + content = result[0] + assert isinstance(content, TextResourceContents) + assert content.text == "Calling v2/users" + + +async def test_async_tool_context_manager_stays_open(mcp: FastMCP): + """Test that context manager dependencies stay open during async tool execution. + + Context managers must remain open while the async function executes, not just + while it's being called (which only returns a coroutine). + """ + + @mcp.tool() + async def query_data( + query: str, connection: Connection = Depends(get_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open}" + + async with Client(mcp) as client: + result = await client.call_tool("query_data", {"query": "test"}) + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "open=True" + + +async def test_async_resource_context_manager_stays_open(mcp: FastMCP): + """Test that context manager dependencies stay open during async resource execution.""" + + @mcp.resource("data://config") + async def load_config(connection: Connection = Depends(get_connection)) -> str: + assert connection.is_open + return f"open={connection.is_open}" + + async with Client(mcp) as client: + result = await client.read_resource("data://config") + content = result[0] + assert isinstance(content, TextResourceContents) + assert content.text == "open=True" + + +async def test_async_resource_template_context_manager_stays_open(mcp: FastMCP): + """Test that context manager dependencies stay open during async resource template execution.""" + + @mcp.resource("user://{user_id}") + async def get_user( + user_id: str, connection: Connection = Depends(get_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open},user={user_id}" + + async with Client(mcp) as client: + result = await client.read_resource("user://123") + content = result[0] + assert isinstance(content, TextResourceContents) + assert "open=True" in content.text + + +async def test_async_prompt_context_manager_stays_open(mcp: FastMCP): + """Test that context manager dependencies stay open during async prompt execution.""" + + @mcp.prompt() + async def research_prompt( + topic: str, connection: Connection = Depends(get_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open},topic={topic}" + + async with Client(mcp) as client: + result = await client.get_prompt("research_prompt", {"topic": "AI"}) + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert "open=True" in content.text + + +async def test_argument_validation_with_dependencies(mcp: FastMCP): + """Test that user arguments are still validated when dependencies are present.""" + + def get_config() -> dict[str, str]: + return {"key": "value"} + + @mcp.tool() + async def validated_tool( + age: int, # Should validate type + config: dict[str, str] = Depends(get_config), + ) -> str: + return f"age={age}" + + async with Client(mcp) as client: + # Valid argument + result = await client.call_tool("validated_tool", {"age": 25}) + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "age=25" + + # Invalid argument type should fail validation + with pytest.raises(Exception): # Will be ToolError wrapping validation error + await client.call_tool("validated_tool", {"age": "not a number"}) + + +async def test_connection_dependency_excluded_from_tool_schema(mcp: FastMCP): + """Test that Connection dependency parameter is excluded from tool schema.""" + + @mcp.tool() + async def with_connection( + name: str, connection: Connection = Depends(get_connection) + ) -> str: + return name + + tools = await mcp._list_tools_mcp() + tool = next(t for t in tools if t.name == "with_connection") + + assert "name" in tool.inputSchema["properties"] + assert "connection" not in tool.inputSchema["properties"] + + +async def test_sync_tool_context_manager_stays_open(mcp: FastMCP): + """Test that sync context manager dependencies work with tools.""" + conn = Connection() + + @contextmanager + def get_sync_connection(): + conn.is_open = True + try: + yield conn + finally: + conn.is_open = False + + @mcp.tool() + async def query_sync( + query: str, connection: Connection = Depends(get_sync_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open}" + + async with Client(mcp) as client: + result = await client.call_tool("query_sync", {"query": "test"}) + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "open=True" + assert not conn.is_open + + +async def test_sync_resource_context_manager_stays_open(mcp: FastMCP): + """Test that sync context manager dependencies work with resources.""" + conn = Connection() + + @contextmanager + def get_sync_connection(): + conn.is_open = True + try: + yield conn + finally: + conn.is_open = False + + @mcp.resource("data://sync") + async def load_sync(connection: Connection = Depends(get_sync_connection)) -> str: + assert connection.is_open + return f"open={connection.is_open}" + + async with Client(mcp) as client: + result = await client.read_resource("data://sync") + content = result[0] + assert isinstance(content, TextResourceContents) + assert content.text == "open=True" + assert not conn.is_open + + +async def test_sync_resource_template_context_manager_stays_open(mcp: FastMCP): + """Test that sync context manager dependencies work with resource templates.""" + conn = Connection() + + @contextmanager + def get_sync_connection(): + conn.is_open = True + try: + yield conn + finally: + conn.is_open = False + + @mcp.resource("item://{item_id}") + async def get_item( + item_id: str, connection: Connection = Depends(get_sync_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open},item={item_id}" + + async with Client(mcp) as client: + result = await client.read_resource("item://456") + content = result[0] + assert isinstance(content, TextResourceContents) + assert "open=True" in content.text + assert not conn.is_open + + +async def test_sync_prompt_context_manager_stays_open(mcp: FastMCP): + """Test that sync context manager dependencies work with prompts.""" + conn = Connection() + + @contextmanager + def get_sync_connection(): + conn.is_open = True + try: + yield conn + finally: + conn.is_open = False + + @mcp.prompt() + async def sync_prompt( + topic: str, connection: Connection = Depends(get_sync_connection) + ) -> str: + assert connection.is_open + return f"open={connection.is_open},topic={topic}" + + async with Client(mcp) as client: + result = await client.get_prompt("sync_prompt", {"topic": "test"}) + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert "open=True" in content.text + assert not conn.is_open + + +async def test_external_user_cannot_override_dependency(mcp: FastMCP): + """Test that external MCP clients cannot override dependency parameters.""" + + def get_admin_status() -> str: + return "not_admin" + + @mcp.tool() + async def check_permission( + action: str, admin: str = Depends(get_admin_status) + ) -> str: + return f"action={action},admin={admin}" + + # Verify dependency is NOT in the schema + tools = await mcp._list_tools_mcp() + tool = next(t for t in tools if t.name == "check_permission") + assert "admin" not in tool.inputSchema["properties"] + + async with Client(mcp) as client: + # Normal call - dependency is resolved + result = await client.call_tool("check_permission", {"action": "read"}) + content = result.content[0] + assert isinstance(content, TextContent) + assert "admin=not_admin" in content.text + + # Try to override dependency - rejected (not in schema) + with pytest.raises(Exception): + await client.call_tool( + "check_permission", {"action": "read", "admin": "hacker"} + ) + + +async def test_prompt_dependency_cannot_be_overridden_externally(mcp: FastMCP): + """Test that external callers cannot override prompt dependencies. + + This is a security test - dependencies should NEVER be overridable from + outside the server, even for prompts which don't validate against strict schemas. + """ + + def get_secret() -> str: + return "real_secret" + + @mcp.prompt() + async def secure_prompt(topic: str, secret: str = Depends(get_secret)) -> str: + return f"Topic: {topic}, Secret: {secret}" + + async with Client(mcp) as client: + # Normal call - should use dependency + result = await client.get_prompt("secure_prompt", {"topic": "test"}) + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + assert "Secret: real_secret" in content.text + + # Try to override dependency - should be ignored/rejected + result = await client.get_prompt( + "secure_prompt", + {"topic": "test", "secret": "HACKED"}, # Attempt override + ) + message = result.messages[0] + content = message.content + assert isinstance(content, TextContent) + # Should still use real dependency, not hacked value + assert "Secret: real_secret" in content.text + assert "HACKED" not in content.text + + +async def test_resource_dependency_cannot_be_overridden_externally(mcp: FastMCP): + """Test that external callers cannot override resource dependencies.""" + + def get_api_key() -> str: + return "real_api_key" + + @mcp.resource("data://config") + async def get_config(api_key: str = Depends(get_api_key)) -> str: + return f"API Key: {api_key}" + + async with Client(mcp) as client: + # Normal call + result = await client.read_resource("data://config") + content = result[0] + assert isinstance(content, TextResourceContents) + assert "API Key: real_api_key" in content.text + + # Resources don't accept arguments from clients (static URI) + # so this scenario is less of a concern, but documenting it + + +async def test_resource_template_dependency_cannot_be_overridden_externally( + mcp: FastMCP, +): + """Test that external callers cannot override resource template dependencies. + + Resource templates extract parameters from the URI path, so there's a risk + that a dependency parameter name could match a URI parameter. + """ + + def get_auth_token() -> str: + return "real_token" + + @mcp.resource("user://{user_id}") + async def get_user(user_id: str, token: str = Depends(get_auth_token)) -> str: + return f"User: {user_id}, Token: {token}" + + async with Client(mcp) as client: + # Normal call + result = await client.read_resource("user://123") + content = result[0] + assert isinstance(content, TextResourceContents) + assert "User: 123, Token: real_token" in content.text + + # Try to inject token via URI (shouldn't be possible with this pattern) + # But if URI was user://{token}, it could extract it + + +async def test_resource_template_uri_cannot_match_dependency_name(mcp: FastMCP): + """Test that URI parameters cannot have the same name as dependencies. + + If a URI template tries to use a parameter name that's also a dependency, + the template creation should fail because the dependency is excluded from + the user-facing signature. + """ + + def get_token() -> str: + return "real_token" + + # This should fail - {token} in URI but token is a dependency parameter + with pytest.raises(ValueError, match="URI parameters.*must be a subset"): + + @mcp.resource("auth://{token}/validate") + async def validate(token: str = Depends(get_token)) -> str: + return f"Validating with: {token}" diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 926848b53..a6e9ddfd2 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -2,7 +2,6 @@ import os import tempfile from pathlib import Path -from types import EllipsisType from typing import Annotated, Any import pytest @@ -13,7 +12,6 @@ Audio, File, Image, - find_kwarg_by_type, get_cached_typeadapter, is_class_member_of_type, issubclass_safe, @@ -471,130 +469,6 @@ def test_to_resource_content_with_override_mime_type(self, tmp_path): assert resource.resource.mimeType == "application/custom" -class TestFindKwargByType: - def test_exact_type_match(self): - """Test finding parameter with exact type match.""" - - def func(a: int, b: str, c: BaseClass): - pass - - assert find_kwarg_by_type(func, BaseClass) == "c" - - def test_no_matching_parameter(self): - """Test finding parameter when no match exists.""" - - def func(a: int, b: str, c: OtherClass): - pass - - assert find_kwarg_by_type(func, BaseClass) is None - - def test_parameter_with_no_annotation(self): - """Test with a parameter that has no type annotation.""" - - def func(a: int, b, c: BaseClass): - pass - - assert find_kwarg_by_type(func, BaseClass) == "c" - - def test_union_type_match_pipe_syntax(self): - """Test finding parameter with union type using pipe syntax.""" - - def func(a: int, b: str | BaseClass, c: str): - pass - - assert find_kwarg_by_type(func, BaseClass) == "b" - - def test_union_type_match_typing_union(self): - """Test finding parameter with union type using Union.""" - - def func(a: int, b: str | BaseClass, c: str): - pass - - assert find_kwarg_by_type(func, BaseClass) == "b" - - def test_annotated_type_match(self): - """Test finding parameter with Annotated type.""" - - def func(a: int, b: Annotated[BaseClass, "metadata"], c: str): - pass - - assert find_kwarg_by_type(func, BaseClass) == "b" - - def test_method_parameter(self): - """Test finding parameter in a class method.""" - - class TestClass: - def method(self, a: int, b: BaseClass): - pass - - instance = TestClass() - assert find_kwarg_by_type(instance.method, BaseClass) == "b" - - def test_static_method_parameter(self): - """Test finding parameter in a static method.""" - - class TestClass: - @staticmethod - def static_method(a: int, b: BaseClass, c: str): - pass - - assert find_kwarg_by_type(TestClass.static_method, BaseClass) == "b" - - def test_class_method_parameter(self): - """Test finding parameter in a class method.""" - - class TestClass: - @classmethod - def class_method(cls, a: int, b: BaseClass, c: str): - pass - - assert find_kwarg_by_type(TestClass.class_method, BaseClass) == "b" - - def test_multiple_matching_parameters(self): - """Test finding first parameter when multiple matches exist.""" - - def func(a: BaseClass, b: str, c: BaseClass): - pass - - # Should return the first match - assert find_kwarg_by_type(func, BaseClass) == "a" - - def test_subclass_match(self): - """Test finding parameter with a subclass of the target type.""" - - def func(a: int, b: ChildClass, c: str): - pass - - assert find_kwarg_by_type(func, BaseClass) == "b" - - def test_nonstandard_annotation(self): - """Test finding parameter with a nonstandard annotation like an - instance. This is irregular.""" - - SENTINEL = object() - - def func(a: int, b: SENTINEL, c: str): # type: ignore - pass - - assert find_kwarg_by_type(func, SENTINEL) is None # type: ignore - - def test_ellipsis_annotation(self): - """Test finding parameter with an ellipsis annotation.""" - - def func(a: int, b: EllipsisType, c: str): # type: ignore # noqa: F821 - pass - - assert find_kwarg_by_type(func, EllipsisType) == "b" # type: ignore - - def test_missing_type_annotation(self): - """Test finding parameter with a missing type annotation.""" - - def func(a: int, b, c: str): - pass - - assert find_kwarg_by_type(func, str) == "c" - - class TestReplaceType: @pytest.mark.parametrize( "input,type_map,expected", diff --git a/uv.lock b/uv.lock index c7889db05..13b521f45 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -272,6 +281,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113, upload-time = "2025-01-14T17:02:05.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992, upload-time = "2025-01-14T17:02:02.417Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -590,6 +608,7 @@ dependencies = [ { name = "platformdirs" }, { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, { name = "pydantic", extra = ["email"] }, + { name = "pydocket" }, { name = "pyperclip" }, { name = "python-dotenv" }, { name = "rich" }, @@ -642,6 +661,7 @@ requires-dist = [ { name = "platformdirs", specifier = ">=4.0.0" }, { name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.2.8,<0.3.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, + { name = "pydocket", specifier = ">=0.12.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, @@ -1146,6 +1166,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, +] + +[[package]] +name = "opentelemetry-exporter-prometheus" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/07/39370ec7eacfca10462121a0e036b66ccea3a616bf6ae6ea5fdb72e5009d/opentelemetry_exporter_prometheus-0.59b0.tar.gz", hash = "sha256:d64f23c49abb5a54e271c2fbc8feacea0c394a30ec29876ab5ef7379f08cf3d7", size = 14972, upload-time = "2025-10-16T08:35:55.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/ea/3005a732002242fd86203989520bdd5a752e1fd30dc225d5d45751ea19fb/opentelemetry_exporter_prometheus-0.59b0-py3-none-any.whl", hash = "sha256:71ced23207abd15b30d1fe4e7e910dcaa7c2ff1f24a6ffccbd4fdded676f541b", size = 13017, upload-time = "2025-10-16T08:35:37.253Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/cb/f0eee1445161faf4c9af3ba7b848cc22a50a3d3e2515051ad8628c35ff80/opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe", size = 171942, upload-time = "2025-10-16T08:36:02.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/2e/e93777a95d7d9c40d270a371392b6d6f1ff170c2a3cb32d6176741b5b723/opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b", size = 132349, upload-time = "2025-10-16T08:35:46.995Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1241,6 +1315,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.51" @@ -1454,6 +1537,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] +[[package]] +name = "pydocket" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-prometheus" }, + { name = "prometheus-client" }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "uuid7" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/41/aee71551254a839bf7f9644137958b2cfbbed98cf1e3f17c04d8dd5845c5/pydocket-0.12.0.tar.gz", hash = "sha256:80379bdb2a02548d95e08adc2c63572bc4bdebfd2bc9db6691684a1edc3f2524", size = 214182, upload-time = "2025-10-28T14:14:43.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f9/f9ac0452f0e33a45ff78fe1c4dfc896778519ecd7e70946d0b6aed7898b0/pydocket-0.12.0-py3-none-any.whl", hash = "sha256:d4bc481ad9cbb229c30f114d403477c6f8527f3e71480121c5bd8e154130c590", size = 40863, upload-time = "2025-10-28T14:14:42.301Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1682,6 +1787,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + [[package]] name = "python-multipart" version = "0.0.20" @@ -1766,6 +1880,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "redis" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322, upload-time = "2025-10-27T14:34:00.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938, upload-time = "2025-10-27T14:33:58.553Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1994,6 +2120,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2127,6 +2262,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/36/5a3a70c5d497d3332f9e63cabc9c6f13484783b832fecc393f4f1c0c4aa8/ty-0.0.1a20-py3-none-win_arm64.whl", hash = "sha256:d8ac1c5a14cda5fad1a8b53959d9a5d979fe16ce1cc2785ea8676fed143ac85f", size = 8269906, upload-time = "2025-09-03T12:35:45.045Z" }, ] +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -2157,6 +2307,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "uuid7" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052, upload-time = "2021-12-29T01:38:21.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477, upload-time = "2021-12-29T01:38:20.418Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0"