From 557e90d2e77e9479a4a2431742ac8c19e4d56b13 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Mon, 9 Dec 2024 16:16:47 +0000 Subject: [PATCH 01/20] Integrate FastMCP This commit integrates FastMCP, a high-level MCP server implementation originally written by Jeremiah Lowin, into the official MCP SDK. It also updates dependencies and adds new dev dependencies. It moves the existing SDK into a .lowlevel . --- pyproject.toml | 35 +- src/mcp/server/__init__.py | 502 +------------ src/mcp/server/fastmcp/__init__.py | 8 + src/mcp/server/fastmcp/exceptions.py | 21 + src/mcp/server/fastmcp/prompts/__init__.py | 4 + src/mcp/server/fastmcp/prompts/base.py | 166 +++++ src/mcp/server/fastmcp/prompts/manager.py | 50 ++ .../server/fastmcp/prompts/prompt_manager.py | 33 + src/mcp/server/fastmcp/resources/__init__.py | 23 + src/mcp/server/fastmcp/resources/base.py | 48 ++ .../fastmcp/resources/resource_manager.py | 95 +++ src/mcp/server/fastmcp/resources/templates.py | 80 +++ src/mcp/server/fastmcp/resources/types.py | 181 +++++ src/mcp/server/fastmcp/server.py | 668 ++++++++++++++++++ src/mcp/server/fastmcp/tools/__init__.py | 4 + src/mcp/server/fastmcp/tools/base.py | 82 +++ src/mcp/server/fastmcp/tools/tool_manager.py | 54 ++ src/mcp/server/fastmcp/utilities/__init__.py | 1 + .../server/fastmcp/utilities/func_metadata.py | 210 ++++++ src/mcp/server/fastmcp/utilities/logging.py | 41 ++ src/mcp/server/fastmcp/utilities/types.py | 54 ++ src/mcp/server/lowlevel/__init__.py | 3 + src/mcp/server/lowlevel/server.py | 500 +++++++++++++ tests/conftest.py | 4 + tests/server/fastmcp/__init__.py | 0 tests/server/fastmcp/prompts/__init__.py | 0 tests/server/fastmcp/prompts/test_base.py | 194 +++++ tests/server/fastmcp/prompts/test_manager.py | 107 +++ tests/server/fastmcp/resources/__init__.py | 0 .../fastmcp/resources/test_file_resources.py | 115 +++ .../resources/test_function_resources.py | 115 +++ .../resources/test_resource_manager.py | 137 ++++ .../resources/test_resource_template.py | 181 +++++ .../fastmcp/resources/test_resources.py | 100 +++ tests/server/fastmcp/servers/__init__.py | 0 .../fastmcp/servers/test_file_server.py | 114 +++ tests/server/fastmcp/test_func_metadata.py | 361 ++++++++++ tests/server/fastmcp/test_server.py | 656 +++++++++++++++++ tests/server/fastmcp/test_tool_manager.py | 306 ++++++++ tests/server/test_session.py | 3 +- uv.lock | 140 +++- 41 files changed, 4875 insertions(+), 521 deletions(-) create mode 100644 src/mcp/server/fastmcp/__init__.py create mode 100644 src/mcp/server/fastmcp/exceptions.py create mode 100644 src/mcp/server/fastmcp/prompts/__init__.py create mode 100644 src/mcp/server/fastmcp/prompts/base.py create mode 100644 src/mcp/server/fastmcp/prompts/manager.py create mode 100644 src/mcp/server/fastmcp/prompts/prompt_manager.py create mode 100644 src/mcp/server/fastmcp/resources/__init__.py create mode 100644 src/mcp/server/fastmcp/resources/base.py create mode 100644 src/mcp/server/fastmcp/resources/resource_manager.py create mode 100644 src/mcp/server/fastmcp/resources/templates.py create mode 100644 src/mcp/server/fastmcp/resources/types.py create mode 100644 src/mcp/server/fastmcp/server.py create mode 100644 src/mcp/server/fastmcp/tools/__init__.py create mode 100644 src/mcp/server/fastmcp/tools/base.py create mode 100644 src/mcp/server/fastmcp/tools/tool_manager.py create mode 100644 src/mcp/server/fastmcp/utilities/__init__.py create mode 100644 src/mcp/server/fastmcp/utilities/func_metadata.py create mode 100644 src/mcp/server/fastmcp/utilities/logging.py create mode 100644 src/mcp/server/fastmcp/utilities/types.py create mode 100644 src/mcp/server/lowlevel/__init__.py create mode 100644 src/mcp/server/lowlevel/server.py create mode 100644 tests/server/fastmcp/__init__.py create mode 100644 tests/server/fastmcp/prompts/__init__.py create mode 100644 tests/server/fastmcp/prompts/test_base.py create mode 100644 tests/server/fastmcp/prompts/test_manager.py create mode 100644 tests/server/fastmcp/resources/__init__.py create mode 100644 tests/server/fastmcp/resources/test_file_resources.py create mode 100644 tests/server/fastmcp/resources/test_function_resources.py create mode 100644 tests/server/fastmcp/resources/test_resource_manager.py create mode 100644 tests/server/fastmcp/resources/test_resource_template.py create mode 100644 tests/server/fastmcp/resources/test_resources.py create mode 100644 tests/server/fastmcp/servers/__init__.py create mode 100644 tests/server/fastmcp/servers/test_file_server.py create mode 100644 tests/server/fastmcp/test_func_metadata.py create mode 100644 tests/server/fastmcp/test_server.py create mode 100644 tests/server/fastmcp/test_tool_manager.py diff --git a/pyproject.toml b/pyproject.toml index efde945bf..9ba6b9aac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,3 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - [project] name = "mcp" version = "1.1.2.dev0" @@ -29,11 +25,31 @@ dependencies = [ "anyio>=4.5", "httpx>=0.27", "httpx-sse>=0.4", - "pydantic>=2.7.2", + "pydantic>=2.7.2,<3.0.0", "starlette>=0.27", "sse-starlette>=1.6.1", + "pydantic-settings>=2.6.1", +] + +[project.optional-dependencies] +rich = ["rich>=13.9.4"] + +[tool.uv] +resolution = "lowest-direct" +dev-dependencies = [ + "pyright>=1.1.378", + "pytest>=8.3.3", + "ruff>=0.6.9", + "trio>=0.26.2", + "pytest-flakefinder>=1.1.0", + "pytest-xdist>=3.6.1", + "pytest-asyncio>=0.24.0", ] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project.urls] Homepage = "https://modelcontextprotocol.io" Repository = "https://github.com/modelcontextprotocol/python-sdk" @@ -58,15 +74,6 @@ target-version = "py310" [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] -[tool.uv] -resolution = "lowest-direct" -dev-dependencies = [ - "pyright>=1.1.378", - "pytest>=8.3.3", - "ruff>=0.6.9", - "trio>=0.26.2", -] - [tool.uv.workspace] members = ["examples/servers/*"] diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index a0dd033d6..4db3e6de1 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,500 +1,4 @@ -""" -MCP Server Module +from .lowlevel import Server, NotificationOptions +from .fastmcp import FastMCP -This module provides a framework for creating an MCP (Model Context Protocol) server. -It allows you to easily define and handle various types of requests and notifications -in an asynchronous manner. - -Usage: -1. Create a Server instance: - server = Server("your_server_name") - -2. Define request handlers using decorators: - @server.list_prompts() - async def handle_list_prompts() -> list[types.Prompt]: - # Implementation - - @server.get_prompt() - async def handle_get_prompt( - name: str, arguments: dict[str, str] | None - ) -> types.GetPromptResult: - # Implementation - - @server.list_tools() - async def handle_list_tools() -> list[types.Tool]: - # Implementation - - @server.call_tool() - async def handle_call_tool( - name: str, arguments: dict | None - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - # Implementation - - @server.list_resource_templates() - async def handle_list_resource_templates() -> list[types.ResourceTemplate]: - # Implementation - -3. Define notification handlers if needed: - @server.progress_notification() - async def handle_progress( - progress_token: str | int, progress: float, total: float | None - ) -> None: - # Implementation - -4. Run the server: - async def main(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="your_server_name", - server_version="your_version", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - asyncio.run(main()) - -The Server class provides methods to register handlers for various MCP requests and -notifications. It automatically manages the request context and handles incoming -messages from the client. -""" - -import contextvars -import logging -import warnings -from collections.abc import Awaitable, Callable -from typing import Any, Sequence - -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.server.stdio import stdio_server as stdio_server -from mcp.shared.context import RequestContext -from mcp.shared.exceptions import McpError -from mcp.shared.session import RequestResponder - -logger = logging.getLogger(__name__) - -request_ctx: contextvars.ContextVar[RequestContext[ServerSession]] = ( - contextvars.ContextVar("request_ctx") -) - - -class NotificationOptions: - def __init__( - self, - prompts_changed: bool = False, - resources_changed: bool = False, - tools_changed: bool = False, - ): - self.prompts_changed = prompts_changed - self.resources_changed = resources_changed - self.tools_changed = tools_changed - - -class Server: - def __init__(self, name: str): - self.name = name - self.request_handlers: dict[ - type, Callable[..., Awaitable[types.ServerResult]] - ] = { - types.PingRequest: _ping_handler, - } - self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} - self.notification_options = NotificationOptions() - logger.debug(f"Initializing server '{name}'") - - def create_initialization_options( - self, - notification_options: NotificationOptions | None = None, - experimental_capabilities: dict[str, dict[str, Any]] | None = None, - ) -> InitializationOptions: - """Create initialization options from this server instance.""" - - def pkg_version(package: str) -> str: - try: - from importlib.metadata import version - - v = version(package) - if v is not None: - return v - except Exception: - pass - - return "unknown" - - return InitializationOptions( - server_name=self.name, - server_version=pkg_version("mcp"), - capabilities=self.get_capabilities( - notification_options or NotificationOptions(), - experimental_capabilities or {}, - ), - ) - - def get_capabilities( - self, - notification_options: NotificationOptions, - experimental_capabilities: dict[str, dict[str, Any]], - ) -> types.ServerCapabilities: - """Convert existing handlers to a ServerCapabilities object.""" - prompts_capability = None - resources_capability = None - tools_capability = None - logging_capability = None - - # Set prompt capabilities if handler exists - if types.ListPromptsRequest in self.request_handlers: - prompts_capability = types.PromptsCapability( - listChanged=notification_options.prompts_changed - ) - - # Set resource capabilities if handler exists - if types.ListResourcesRequest in self.request_handlers: - resources_capability = types.ResourcesCapability( - subscribe=False, listChanged=notification_options.resources_changed - ) - - # Set tool capabilities if handler exists - if types.ListToolsRequest in self.request_handlers: - tools_capability = types.ToolsCapability( - listChanged=notification_options.tools_changed - ) - - # Set logging capabilities if handler exists - if types.SetLevelRequest in self.request_handlers: - logging_capability = types.LoggingCapability() - - return types.ServerCapabilities( - prompts=prompts_capability, - resources=resources_capability, - tools=tools_capability, - logging=logging_capability, - experimental=experimental_capabilities, - ) - - @property - def request_context(self) -> RequestContext[ServerSession]: - """If called outside of a request context, this will raise a LookupError.""" - return request_ctx.get() - - def list_prompts(self): - def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]): - logger.debug("Registering handler for PromptListRequest") - - async def handler(_: Any): - prompts = await func() - return types.ServerResult(types.ListPromptsResult(prompts=prompts)) - - self.request_handlers[types.ListPromptsRequest] = handler - return func - - return decorator - - def get_prompt(self): - def decorator( - func: Callable[ - [str, dict[str, str] | None], Awaitable[types.GetPromptResult] - ], - ): - logger.debug("Registering handler for GetPromptRequest") - - async def handler(req: types.GetPromptRequest): - prompt_get = await func(req.params.name, req.params.arguments) - return types.ServerResult(prompt_get) - - self.request_handlers[types.GetPromptRequest] = handler - return func - - return decorator - - def list_resources(self): - def decorator(func: Callable[[], Awaitable[list[types.Resource]]]): - logger.debug("Registering handler for ListResourcesRequest") - - async def handler(_: Any): - resources = await func() - return types.ServerResult( - types.ListResourcesResult(resources=resources) - ) - - self.request_handlers[types.ListResourcesRequest] = handler - return func - - return decorator - - def list_resource_templates(self): - def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): - logger.debug("Registering handler for ListResourceTemplatesRequest") - - async def handler(_: Any): - templates = await func() - return types.ServerResult( - types.ListResourceTemplatesResult(resourceTemplates=templates) - ) - - self.request_handlers[types.ListResourceTemplatesRequest] = handler - return func - - return decorator - - def read_resource(self): - def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]): - logger.debug("Registering handler for ReadResourceRequest") - - async def handler(req: types.ReadResourceRequest): - result = await func(req.params.uri) - match result: - case str(s): - content = types.TextResourceContents( - uri=req.params.uri, - text=s, - mimeType="text/plain", - ) - case bytes(b): - import base64 - - content = types.BlobResourceContents( - uri=req.params.uri, - blob=base64.urlsafe_b64encode(b).decode(), - mimeType="application/octet-stream", - ) - - return types.ServerResult( - types.ReadResourceResult( - contents=[content], - ) - ) - - self.request_handlers[types.ReadResourceRequest] = handler - return func - - return decorator - - def set_logging_level(self): - def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]): - logger.debug("Registering handler for SetLevelRequest") - - async def handler(req: types.SetLevelRequest): - await func(req.params.level) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SetLevelRequest] = handler - return func - - return decorator - - def subscribe_resource(self): - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for SubscribeRequest") - - async def handler(req: types.SubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.SubscribeRequest] = handler - return func - - return decorator - - def unsubscribe_resource(self): - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): - logger.debug("Registering handler for UnsubscribeRequest") - - async def handler(req: types.UnsubscribeRequest): - await func(req.params.uri) - return types.ServerResult(types.EmptyResult()) - - self.request_handlers[types.UnsubscribeRequest] = handler - return func - - return decorator - - def list_tools(self): - def decorator(func: Callable[[], Awaitable[list[types.Tool]]]): - logger.debug("Registering handler for ListToolsRequest") - - async def handler(_: Any): - tools = await func() - return types.ServerResult(types.ListToolsResult(tools=tools)) - - self.request_handlers[types.ListToolsRequest] = handler - return func - - return decorator - - def call_tool(self): - def decorator( - func: Callable[ - ..., - Awaitable[ - Sequence[ - types.TextContent | types.ImageContent | types.EmbeddedResource - ] - ], - ], - ): - logger.debug("Registering handler for CallToolRequest") - - async def handler(req: types.CallToolRequest): - try: - results = await func(req.params.name, (req.params.arguments or {})) - return types.ServerResult( - types.CallToolResult(content=list(results), isError=False) - ) - except Exception as e: - return types.ServerResult( - types.CallToolResult( - content=[types.TextContent(type="text", text=str(e))], - isError=True, - ) - ) - - self.request_handlers[types.CallToolRequest] = handler - return func - - return decorator - - def progress_notification(self): - def decorator( - func: Callable[[str | int, float, float | None], Awaitable[None]], - ): - logger.debug("Registering handler for ProgressNotification") - - async def handler(req: types.ProgressNotification): - await func( - req.params.progressToken, req.params.progress, req.params.total - ) - - self.notification_handlers[types.ProgressNotification] = handler - return func - - return decorator - - def completion(self): - """Provides completions for prompts and resource templates""" - - def decorator( - func: Callable[ - [ - types.PromptReference | types.ResourceReference, - types.CompletionArgument, - ], - Awaitable[types.Completion | None], - ], - ): - logger.debug("Registering handler for CompleteRequest") - - async def handler(req: types.CompleteRequest): - completion = await func(req.params.ref, req.params.argument) - return types.ServerResult( - types.CompleteResult( - completion=completion - if completion is not None - else types.Completion(values=[], total=None, hasMore=None), - ) - ) - - self.request_handlers[types.CompleteRequest] = handler - return func - - return decorator - - async def run( - self, - read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], - write_stream: MemoryObjectSendStream[types.JSONRPCMessage], - initialization_options: InitializationOptions, - # When False, exceptions are returned as messages to the client. - # When True, exceptions are raised, which will cause the server to shut down - # but also make tracing exceptions much easier during testing and when using - # in-process servers. - raise_exceptions: bool = False, - ): - with warnings.catch_warnings(record=True) as w: - async with ServerSession( - read_stream, write_stream, initialization_options - ) as session: - async for message in session.incoming_messages: - logger.debug(f"Received message: {message}") - - match message: - case RequestResponder(request=types.ClientRequest(root=req)): - logger.info( - f"Processing request of type {type(req).__name__}" - ) - if type(req) in self.request_handlers: - handler = self.request_handlers[type(req)] - logger.debug( - f"Dispatching request of type {type(req).__name__}" - ) - - token = None - try: - # Set our global state that can be retrieved via - # app.get_request_context() - token = request_ctx.set( - RequestContext( - message.request_id, - message.request_meta, - session, - ) - ) - response = await handler(req) - except McpError as err: - response = err.error - except Exception as err: - if raise_exceptions: - raise err - response = types.ErrorData( - code=0, message=str(err), data=None - ) - finally: - # Reset the global state after we are done - if token is not None: - request_ctx.reset(token) - - await message.respond(response) - else: - await message.respond( - types.ErrorData( - code=types.METHOD_NOT_FOUND, - message="Method not found", - ) - ) - - logger.debug("Response sent") - case types.ClientNotification(root=notify): - if type(notify) in self.notification_handlers: - assert type(notify) in self.notification_handlers - - handler = self.notification_handlers[type(notify)] - logger.debug( - f"Dispatching notification of type " - f"{type(notify).__name__}" - ) - - try: - await handler(notify) - except Exception as err: - logger.error( - f"Uncaught exception in notification handler: " - f"{err}" - ) - - for warning in w: - logger.info( - f"Warning: {warning.category.__name__}: {warning.message}" - ) - - -async def _ping_handler(request: types.PingRequest) -> types.ServerResult: - return types.ServerResult(types.EmptyResult()) +__all__ = ["Server", "FastMCP", "NotificationOptions"] diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py new file mode 100644 index 000000000..4ff1a05df --- /dev/null +++ b/src/mcp/server/fastmcp/__init__.py @@ -0,0 +1,8 @@ +"""FastMCP - A more ergonomic interface for MCP servers.""" + +from importlib.metadata import version +from .server import FastMCP, Context +from .utilities.types import Image + +__version__ = version("mcp") +__all__ = ["FastMCP", "Context", "Image"] diff --git a/src/mcp/server/fastmcp/exceptions.py b/src/mcp/server/fastmcp/exceptions.py new file mode 100644 index 000000000..fb5bda106 --- /dev/null +++ b/src/mcp/server/fastmcp/exceptions.py @@ -0,0 +1,21 @@ +"""Custom exceptions for FastMCP.""" + + +class FastMCPError(Exception): + """Base error for FastMCP.""" + + +class ValidationError(FastMCPError): + """Error in validating parameters or return values.""" + + +class ResourceError(FastMCPError): + """Error in resource operations.""" + + +class ToolError(FastMCPError): + """Error in tool operations.""" + + +class InvalidSignature(Exception): + """Invalid signature for use with FastMCP.""" diff --git a/src/mcp/server/fastmcp/prompts/__init__.py b/src/mcp/server/fastmcp/prompts/__init__.py new file mode 100644 index 000000000..763726964 --- /dev/null +++ b/src/mcp/server/fastmcp/prompts/__init__.py @@ -0,0 +1,4 @@ +from .base import Prompt +from .manager import PromptManager + +__all__ = ["Prompt", "PromptManager"] diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py new file mode 100644 index 000000000..8358f4b4f --- /dev/null +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -0,0 +1,166 @@ +"""Base classes for FastMCP prompts.""" + +import json +from typing import Any, Literal, Sequence, Awaitable +import inspect +from collections.abc import Callable + +from pydantic import BaseModel, Field, TypeAdapter, validate_call +from mcp.types import TextContent, ImageContent, EmbeddedResource +import pydantic_core + +CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource + + +class Message(BaseModel): + """Base class for all prompt messages.""" + + role: Literal["user", "assistant"] + content: CONTENT_TYPES + + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + if isinstance(content, str): + content = TextContent(type="text", text=content) + super().__init__(content=content, **kwargs) + + +class UserMessage(Message): + """A message from the user.""" + + role: Literal["user", "assistant"] = "user" + + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) + + +class AssistantMessage(Message): + """A message from the assistant.""" + + role: Literal["user", "assistant"] = "assistant" + + def __init__(self, content: str | CONTENT_TYPES, **kwargs): + super().__init__(content=content, **kwargs) + + +message_validator = TypeAdapter(UserMessage | AssistantMessage) + +SyncPromptResult = ( + str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] +) +PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] + + +class PromptArgument(BaseModel): + """An argument that can be passed to a prompt.""" + + name: str = Field(description="Name of the argument") + description: str | None = Field( + None, description="Description of what the argument does" + ) + required: bool = Field( + default=False, description="Whether the argument is required" + ) + + +class Prompt(BaseModel): + """A prompt template that can be rendered with parameters.""" + + name: str = Field(description="Name of the prompt") + description: str | None = Field( + None, description="Description of what the prompt does" + ) + arguments: list[PromptArgument] | None = Field( + None, description="Arguments that can be passed to the prompt" + ) + fn: Callable = Field(exclude=True) + + @classmethod + def from_function( + cls, + fn: Callable[..., PromptResult], + name: str | None = None, + description: str | None = None, + ) -> "Prompt": + """Create a Prompt from a function. + + The function can return: + - A string (converted to a message) + - A Message object + - A dict (converted to a message) + - A sequence of any of the above + """ + func_name = name or fn.__name__ + + if func_name == "": + raise ValueError("You must provide a name for lambda functions") + + # Get schema from TypeAdapter - will fail if function isn't properly typed + parameters = TypeAdapter(fn).json_schema() + + # Convert parameters to PromptArguments + arguments = [] + if "properties" in parameters: + for param_name, param in parameters["properties"].items(): + required = param_name in parameters.get("required", []) + arguments.append( + PromptArgument( + name=param_name, + description=param.get("description"), + required=required, + ) + ) + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + name=func_name, + description=description or fn.__doc__ or "", + arguments=arguments, + fn=fn, + ) + + async def render(self, arguments: dict[str, Any] | None = None) -> list[Message]: + """Render the prompt with arguments.""" + # Validate required arguments + if self.arguments: + required = {arg.name for arg in self.arguments if arg.required} + provided = set(arguments or {}) + missing = required - provided + if missing: + raise ValueError(f"Missing required arguments: {missing}") + + try: + # Call function and check if result is a coroutine + result = self.fn(**(arguments or {})) + if inspect.iscoroutine(result): + result = await result + + # Validate messages + if not isinstance(result, (list, tuple)): + result = [result] + + # Convert result to messages + messages = [] + for msg in result: + try: + if isinstance(msg, Message): + messages.append(msg) + elif isinstance(msg, dict): + msg = message_validator.validate_python(msg) + messages.append(msg) + elif isinstance(msg, str): + messages.append( + UserMessage(content=TextContent(type="text", text=msg)) + ) + else: + msg = json.dumps(pydantic_core.to_jsonable_python(msg)) + messages.append(Message(role="user", content=msg)) + except Exception: + raise ValueError( + f"Could not convert prompt result to message: {msg}" + ) + + return messages + except Exception as e: + raise ValueError(f"Error rendering prompt {self.name}: {e}") diff --git a/src/mcp/server/fastmcp/prompts/manager.py b/src/mcp/server/fastmcp/prompts/manager.py new file mode 100644 index 000000000..7ccbdef36 --- /dev/null +++ b/src/mcp/server/fastmcp/prompts/manager.py @@ -0,0 +1,50 @@ +"""Prompt management functionality.""" + +from typing import Any + +from mcp.server.fastmcp.prompts.base import Message, Prompt +from mcp.server.fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class PromptManager: + """Manages FastMCP prompts.""" + + def __init__(self, warn_on_duplicate_prompts: bool = True): + self._prompts: dict[str, Prompt] = {} + self.warn_on_duplicate_prompts = warn_on_duplicate_prompts + + def get_prompt(self, name: str) -> Prompt | None: + """Get prompt by name.""" + return self._prompts.get(name) + + def list_prompts(self) -> list[Prompt]: + """List all registered prompts.""" + return list(self._prompts.values()) + + def add_prompt( + self, + prompt: Prompt, + ) -> Prompt: + """Add a prompt to the manager.""" + + # Check for duplicates + existing = self._prompts.get(prompt.name) + if existing: + if self.warn_on_duplicate_prompts: + logger.warning(f"Prompt already exists: {prompt.name}") + return existing + + self._prompts[prompt.name] = prompt + return prompt + + async def render_prompt( + self, name: str, arguments: dict[str, Any] | None = None + ) -> list[Message]: + """Render a prompt by name with arguments.""" + prompt = self.get_prompt(name) + if not prompt: + raise ValueError(f"Unknown prompt: {name}") + + return await prompt.render(arguments) diff --git a/src/mcp/server/fastmcp/prompts/prompt_manager.py b/src/mcp/server/fastmcp/prompts/prompt_manager.py new file mode 100644 index 000000000..389e89624 --- /dev/null +++ b/src/mcp/server/fastmcp/prompts/prompt_manager.py @@ -0,0 +1,33 @@ +"""Prompt management functionality.""" + +from mcp.server.fastmcp.prompts.base import Prompt +from mcp.server.fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class PromptManager: + """Manages FastMCP prompts.""" + + def __init__(self, warn_on_duplicate_prompts: bool = True): + self._prompts: dict[str, Prompt] = {} + self.warn_on_duplicate_prompts = warn_on_duplicate_prompts + + def add_prompt(self, prompt: Prompt) -> Prompt: + """Add a prompt to the manager.""" + logger.debug(f"Adding prompt: {prompt.name}") + existing = self._prompts.get(prompt.name) + if existing: + if self.warn_on_duplicate_prompts: + logger.warning(f"Prompt already exists: {prompt.name}") + return existing + self._prompts[prompt.name] = prompt + return prompt + + def get_prompt(self, name: str) -> Prompt | None: + """Get prompt by name.""" + return self._prompts.get(name) + + def list_prompts(self) -> list[Prompt]: + """List all registered prompts.""" + return list(self._prompts.values()) diff --git a/src/mcp/server/fastmcp/resources/__init__.py b/src/mcp/server/fastmcp/resources/__init__.py new file mode 100644 index 000000000..92deb8735 --- /dev/null +++ b/src/mcp/server/fastmcp/resources/__init__.py @@ -0,0 +1,23 @@ +from .base import Resource +from .types import ( + TextResource, + BinaryResource, + FunctionResource, + FileResource, + HttpResource, + DirectoryResource, +) +from .templates import ResourceTemplate +from .resource_manager import ResourceManager + +__all__ = [ + "Resource", + "TextResource", + "BinaryResource", + "FunctionResource", + "FileResource", + "HttpResource", + "DirectoryResource", + "ResourceTemplate", + "ResourceManager", +] diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py new file mode 100644 index 000000000..b2050e7f8 --- /dev/null +++ b/src/mcp/server/fastmcp/resources/base.py @@ -0,0 +1,48 @@ +"""Base classes and interfaces for FastMCP resources.""" + +import abc +from typing import Annotated + +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + UrlConstraints, + ValidationInfo, + field_validator, +) + + +class Resource(BaseModel, abc.ABC): + """Base class for all resources.""" + + model_config = ConfigDict(validate_default=True) + + uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( + default=..., description="URI of the resource" + ) + name: str | None = Field(description="Name of the resource", default=None) + description: str | None = Field( + description="Description of the resource", default=None + ) + mime_type: str = Field( + default="text/plain", + description="MIME type of the resource content", + pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$", + ) + + @field_validator("name", mode="before") + @classmethod + def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: + """Set default name from URI if not provided.""" + if name: + return name + if uri := info.data.get("uri"): + return str(uri) + raise ValueError("Either name or uri must be provided") + + @abc.abstractmethod + async def read(self) -> str | bytes: + """Read the resource content.""" + pass diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py new file mode 100644 index 000000000..1f9561e69 --- /dev/null +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -0,0 +1,95 @@ +"""Resource manager functionality.""" + +from typing import Callable +from collections.abc import Iterable + +from pydantic import AnyUrl + +from mcp.server.fastmcp.resources.base import Resource +from mcp.server.fastmcp.resources.templates import ResourceTemplate +from mcp.server.fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class ResourceManager: + """Manages FastMCP resources.""" + + def __init__(self, warn_on_duplicate_resources: bool = True): + self._resources: dict[str, Resource] = {} + self._templates: dict[str, ResourceTemplate] = {} + self.warn_on_duplicate_resources = warn_on_duplicate_resources + + def add_resource(self, resource: Resource) -> Resource: + """Add a resource to the manager. + + Args: + resource: A Resource instance to add + + Returns: + The added resource. If a resource with the same URI already exists, + returns the existing resource. + """ + logger.debug( + "Adding resource", + extra={ + "uri": resource.uri, + "type": type(resource).__name__, + "name": resource.name, + }, + ) + existing = self._resources.get(str(resource.uri)) + if existing: + if self.warn_on_duplicate_resources: + logger.warning(f"Resource already exists: {resource.uri}") + return existing + self._resources[str(resource.uri)] = resource + return resource + + def add_template( + self, + fn: Callable, + uri_template: str, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + ) -> ResourceTemplate: + """Add a template from a function.""" + template = ResourceTemplate.from_function( + fn, + uri_template=uri_template, + name=name, + description=description, + mime_type=mime_type, + ) + self._templates[template.uri_template] = template + return template + + async def get_resource(self, uri: AnyUrl | str) -> Resource | None: + """Get resource by URI, checking concrete resources first, then templates.""" + uri_str = str(uri) + logger.debug("Getting resource", extra={"uri": uri_str}) + + # First check concrete resources + if resource := self._resources.get(uri_str): + return resource + + # Then check templates + for template in self._templates.values(): + if params := template.matches(uri_str): + try: + return await template.create_resource(uri_str, params) + except Exception as e: + raise ValueError(f"Error creating resource from template: {e}") + + raise ValueError(f"Unknown resource: {uri}") + + def list_resources(self) -> list[Resource]: + """List all registered resources.""" + logger.debug("Listing resources", extra={"count": len(self._resources)}) + return list(self._resources.values()) + + def list_templates(self) -> list[ResourceTemplate]: + """List all registered templates.""" + logger.debug("Listing templates", extra={"count": len(self._templates)}) + return list(self._templates.values()) diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py new file mode 100644 index 000000000..40afaf801 --- /dev/null +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -0,0 +1,80 @@ +"""Resource template functionality.""" + +import inspect +import re +from typing import Any, Callable + +from pydantic import BaseModel, Field, TypeAdapter, validate_call + +from mcp.server.fastmcp.resources.types import FunctionResource, Resource + + +class ResourceTemplate(BaseModel): + """A template for dynamically creating resources.""" + + uri_template: str = Field( + description="URI template with parameters (e.g. weather://{city}/current)" + ) + name: str = Field(description="Name of the resource") + description: str | None = Field(description="Description of what the resource does") + mime_type: str = Field( + default="text/plain", description="MIME type of the resource content" + ) + fn: Callable = Field(exclude=True) + parameters: dict = Field(description="JSON schema for function parameters") + + @classmethod + def from_function( + cls, + fn: Callable, + uri_template: str, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + ) -> "ResourceTemplate": + """Create a template from a function.""" + func_name = name or fn.__name__ + if func_name == "": + raise ValueError("You must provide a name for lambda functions") + + # Get schema from TypeAdapter - will fail if function isn't properly typed + parameters = TypeAdapter(fn).json_schema() + + # ensure the arguments are properly cast + fn = validate_call(fn) + + return cls( + uri_template=uri_template, + name=func_name, + description=description or fn.__doc__ or "", + mime_type=mime_type or "text/plain", + fn=fn, + parameters=parameters, + ) + + def matches(self, uri: str) -> dict[str, Any] | None: + """Check if URI matches template and extract parameters.""" + # Convert template to regex pattern + pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") + match = re.match(f"^{pattern}$", uri) + if match: + return match.groupdict() + return None + + async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: + """Create a resource from the template with the given parameters.""" + try: + # Call function and check if result is a coroutine + result = self.fn(**params) + if inspect.iscoroutine(result): + result = await result + + return FunctionResource( + uri=uri, # type: ignore + name=self.name, + description=self.description, + mime_type=self.mime_type, + fn=lambda: result, # Capture result in closure + ) + except Exception as e: + raise ValueError(f"Error creating resource from template: {e}") diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py new file mode 100644 index 000000000..b1a8088d4 --- /dev/null +++ b/src/mcp/server/fastmcp/resources/types.py @@ -0,0 +1,181 @@ +"""Concrete resource implementations.""" + +import anyio +import json +from pathlib import Path +from typing import Any, Callable +from collections.abc import Callable + +import httpx +import pydantic.json +import pydantic_core +from pydantic import Field, ValidationInfo + +from mcp.server.fastmcp.resources.base import Resource + + +class TextResource(Resource): + """A resource that reads from a string.""" + + text: str = Field(description="Text content of the resource") + + async def read(self) -> str: + """Read the text content.""" + return self.text + + +class BinaryResource(Resource): + """A resource that reads from bytes.""" + + data: bytes = Field(description="Binary content of the resource") + + async def read(self) -> bytes: + """Read the binary content.""" + return self.data + + +class FunctionResource(Resource): + """A resource that defers data loading by wrapping a function. + + The function is only called when the resource is read, allowing for lazy loading + of potentially expensive data. This is particularly useful when listing resources, + as the function won't be called until the resource is actually accessed. + + The function can return: + - str for text content (default) + - bytes for binary content + - other types will be converted to JSON + """ + + fn: Callable[[], Any] = Field(exclude=True) + + async def read(self) -> str | bytes: + """Read the resource by calling the wrapped function.""" + try: + result = self.fn() + if isinstance(result, Resource): + return await result.read() + if isinstance(result, bytes): + return result + if isinstance(result, str): + return result + try: + return json.dumps(pydantic_core.to_jsonable_python(result)) + except (TypeError, pydantic_core.PydanticSerializationError): + # If JSON serialization fails, try str() + return str(result) + except Exception as e: + raise ValueError(f"Error reading resource {self.uri}: {e}") + + +class FileResource(Resource): + """A resource that reads from a file. + + Set is_binary=True to read file as binary data instead of text. + """ + + path: Path = Field(description="Path to the file") + is_binary: bool = Field( + default=False, + description="Whether to read the file as binary data", + ) + mime_type: str = Field( + default="text/plain", + description="MIME type of the resource content", + ) + + @pydantic.field_validator("path") + @classmethod + def validate_absolute_path(cls, path: Path) -> Path: + """Ensure path is absolute.""" + if not path.is_absolute(): + raise ValueError("Path must be absolute") + return path + + @pydantic.field_validator("is_binary") + @classmethod + def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: + """Set is_binary based on mime_type if not explicitly set.""" + if is_binary: + return True + mime_type = info.data.get("mime_type", "text/plain") + return not mime_type.startswith("text/") + + async def read(self) -> str | bytes: + """Read the file content.""" + try: + if self.is_binary: + return await anyio.to_thread.run_sync(self.path.read_bytes) + return await anyio.to_thread.run_sync(self.path.read_text) + except Exception as e: + raise ValueError(f"Error reading file {self.path}: {e}") + + +class HttpResource(Resource): + """A resource that reads from an HTTP endpoint.""" + + url: str = Field(description="URL to fetch content from") + mime_type: str = Field( + default="application/json", description="MIME type of the resource content" + ) + + async def read(self) -> str | bytes: + """Read the HTTP content.""" + async with httpx.AsyncClient() as client: + response = await client.get(self.url) + response.raise_for_status() + return response.text + + +class DirectoryResource(Resource): + """A resource that lists files in a directory.""" + + path: Path = Field(description="Path to the directory") + recursive: bool = Field( + default=False, description="Whether to list files recursively" + ) + pattern: str | None = Field( + default=None, description="Optional glob pattern to filter files" + ) + mime_type: str = Field( + default="application/json", description="MIME type of the resource content" + ) + + @pydantic.field_validator("path") + @classmethod + def validate_absolute_path(cls, path: Path) -> Path: + """Ensure path is absolute.""" + if not path.is_absolute(): + raise ValueError("Path must be absolute") + return path + + def list_files(self) -> list[Path]: + """List files in the directory.""" + if not self.path.exists(): + raise FileNotFoundError(f"Directory not found: {self.path}") + if not self.path.is_dir(): + raise NotADirectoryError(f"Not a directory: {self.path}") + + try: + if self.pattern: + return ( + list(self.path.glob(self.pattern)) + if not self.recursive + else list(self.path.rglob(self.pattern)) + ) + return ( + list(self.path.glob("*")) + if not self.recursive + else list(self.path.rglob("*")) + ) + except Exception as e: + raise ValueError(f"Error listing directory {self.path}: {e}") + + async def read(self) -> str: # Always returns JSON string + """Read the directory listing.""" + try: + files = await anyio.to_thread.run_sync(self.list_files) + file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] + return json.dumps({"files": file_list}, indent=2) + except Exception as e: + raise ValueError(f"Error reading directory {self.path}: {e}") diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py new file mode 100644 index 000000000..b00627e8f --- /dev/null +++ b/src/mcp/server/fastmcp/server.py @@ -0,0 +1,668 @@ +"""FastMCP - A more ergonomic interface for MCP servers.""" + +import anyio +import functools +import inspect +import json +import re +from itertools import chain +from typing import Any, Callable, Literal, Sequence +from collections.abc import Iterable + +import pydantic_core +from pydantic import Field +import uvicorn +from mcp.server.lowlevel import Server as MCPServer +from mcp.server.sse import SseServerTransport +from mcp.server.stdio import stdio_server +from mcp.shared.context import RequestContext +from mcp.types import ( + EmbeddedResource, + GetPromptResult, + ImageContent, + TextContent, +) +from mcp.types import ( + Prompt as MCPPrompt, + PromptArgument as MCPPromptArgument, +) +from mcp.types import ( + Resource as MCPResource, +) +from mcp.types import ( + ResourceTemplate as MCPResourceTemplate, +) +from mcp.types import ( + Tool as MCPTool, +) +from pydantic import BaseModel +from pydantic.networks import AnyUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.fastmcp.exceptions import ResourceError +from mcp.server.fastmcp.prompts import Prompt, PromptManager +from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager +from mcp.server.fastmcp.tools import ToolManager +from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger +from mcp.server.fastmcp.utilities.types import Image + +logger = get_logger(__name__) + + +class Settings(BaseSettings): + """FastMCP server settings. + + All settings can be configured via environment variables with the prefix FASTMCP_. + For example, FASTMCP_DEBUG=true will set debug=True. + """ + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_", + env_file=".env", + extra="ignore", + ) + + # Server settings + debug: bool = False + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" + + # HTTP settings + host: str = "0.0.0.0" + port: int = 8000 + + # resource settings + warn_on_duplicate_resources: bool = True + + # tool settings + warn_on_duplicate_tools: bool = True + + # prompt settings + warn_on_duplicate_prompts: bool = True + + dependencies: list[str] = Field( + default_factory=list, + description="List of dependencies to install in the server environment", + ) + + +class FastMCP: + def __init__(self, name: str | None = None, **settings: Any): + self.settings = Settings(**settings) + self._mcp_server = MCPServer(name=name or "FastMCP") + self._tool_manager = ToolManager( + warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools + ) + self._resource_manager = ResourceManager( + warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources + ) + self._prompt_manager = PromptManager( + warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts + ) + self.dependencies = self.settings.dependencies + + # Set up MCP protocol handlers + self._setup_handlers() + + # Configure logging + configure_logging(self.settings.log_level) + + @property + def name(self) -> str: + return self._mcp_server.name + + def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None: + """Run the FastMCP server. Note this is a synchronous function. + + Args: + transport: Transport protocol to use ("stdio" or "sse") + """ + TRANSPORTS = Literal["stdio", "sse"] + if transport not in TRANSPORTS.__args__: # type: ignore + raise ValueError(f"Unknown transport: {transport}") + + if transport == "stdio": + anyio.run(self.run_stdio_async) + else: # transport == "sse" + anyio.run(self.run_sse_async) + + def _setup_handlers(self) -> None: + """Set up core MCP protocol handlers.""" + self._mcp_server.list_tools()(self.list_tools) + self._mcp_server.call_tool()(self.call_tool) + self._mcp_server.list_resources()(self.list_resources) + self._mcp_server.read_resource()(self.read_resource) + self._mcp_server.list_prompts()(self.list_prompts) + self._mcp_server.get_prompt()(self.get_prompt) + # TODO: This has not been added to MCP yet, see https://github.com/jlowin/fastmcp/issues/10 + # self._mcp_server.list_resource_templates()(self.list_resource_templates) + + async def list_tools(self) -> list[MCPTool]: + """List all available tools.""" + tools = self._tool_manager.list_tools() + return [ + MCPTool( + name=info.name, + description=info.description, + inputSchema=info.parameters, + ) + for info in tools + ] + + def get_context(self) -> "Context": + """ + Returns a Context object. Note that the context will only be valid + during a request; outside a request, most methods will error. + """ + try: + request_context = self._mcp_server.request_context + except LookupError: + request_context = None + return Context(request_context=request_context, fastmcp=self) + + async def call_tool( + self, name: str, arguments: dict + ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Call a tool by name with arguments.""" + context = self.get_context() + result = await self._tool_manager.call_tool(name, arguments, context=context) + converted_result = _convert_to_content(result) + return converted_result + + async def list_resources(self) -> list[MCPResource]: + """List all available resources.""" + + resources = self._resource_manager.list_resources() + return [ + MCPResource( + uri=resource.uri, + name=resource.name or "", + description=resource.description, + mimeType=resource.mime_type, + ) + for resource in resources + ] + + async def list_resource_templates(self) -> list[MCPResourceTemplate]: + templates = self._resource_manager.list_templates() + return [ + MCPResourceTemplate( + uriTemplate=template.uri_template, + name=template.name, + description=template.description, + ) + for template in templates + ] + + async def read_resource(self, uri: AnyUrl | str) -> str | bytes: + """Read a resource by URI.""" + resource = await self._resource_manager.get_resource(uri) + if not resource: + raise ResourceError(f"Unknown resource: {uri}") + + try: + return await resource.read() + except Exception as e: + logger.error(f"Error reading resource {uri}: {e}") + raise ResourceError(str(e)) + + def add_tool( + self, + fn: Callable, + name: str | None = None, + description: str | None = None, + ) -> None: + """Add a tool to the server. + + The tool function can optionally request a Context object by adding a parameter + with the Context type annotation. See the @tool decorator for examples. + + Args: + fn: The function to register as a tool + name: Optional name for the tool (defaults to function name) + description: Optional description of what the tool does + """ + self._tool_manager.add_tool(fn, name=name, description=description) + + def tool(self, name: str | None = None, description: str | None = None) -> Callable: + """Decorator to register a tool. + + Tools can optionally request a Context object by adding a parameter with the Context type annotation. + The context provides access to MCP capabilities like logging, progress reporting, and resource access. + + Args: + name: Optional name for the tool (defaults to function name) + description: Optional description of what the tool does + + Example: + @server.tool() + def my_tool(x: int) -> str: + return str(x) + + @server.tool() + def tool_with_context(x: int, ctx: Context) -> str: + ctx.info(f"Processing {x}") + return str(x) + + @server.tool() + async def async_tool(x: int, context: Context) -> str: + await context.report_progress(50, 100) + return str(x) + """ + # Check if user passed function directly instead of calling decorator + if callable(name): + raise TypeError( + "The @tool decorator was used incorrectly. " + "Did you forget to call it? Use @tool() instead of @tool" + ) + + def decorator(fn: Callable) -> Callable: + self.add_tool(fn, name=name, description=description) + return fn + + return decorator + + def add_resource(self, resource: Resource) -> None: + """Add a resource to the server. + + Args: + resource: A Resource instance to add + """ + self._resource_manager.add_resource(resource) + + def resource( + self, + uri: str, + *, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + ) -> Callable: + """Decorator to register a function as a resource. + + The function will be called when the resource is read to generate its content. + The function can return: + - str for text content + - bytes for binary content + - other types will be converted to JSON + + If the URI contains parameters (e.g. "resource://{param}") or the function + has parameters, it will be registered as a template resource. + + Args: + uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") + name: Optional name for the resource + description: Optional description of the resource + mime_type: Optional MIME type for the resource + + Example: + @server.resource("resource://my-resource") + def get_data() -> str: + return "Hello, world!" + + @server.resource("resource://{city}/weather") + def get_weather(city: str) -> str: + return f"Weather for {city}" + """ + # Check if user passed function directly instead of calling decorator + if callable(uri): + raise TypeError( + "The @resource decorator was used incorrectly. " + "Did you forget to call it? Use @resource('uri') instead of @resource" + ) + + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + # Check if this should be a template + has_uri_params = "{" in uri and "}" in uri + has_func_params = bool(inspect.signature(fn).parameters) + + if has_uri_params or has_func_params: + # Validate that URI params match function params + uri_params = set(re.findall(r"{(\w+)}", uri)) + func_params = set(inspect.signature(fn).parameters.keys()) + + if uri_params != func_params: + raise ValueError( + f"Mismatch between URI parameters {uri_params} " + f"and function parameters {func_params}" + ) + + # Register as template + self._resource_manager.add_template( + wrapper, + uri_template=uri, + name=name, + description=description, + mime_type=mime_type or "text/plain", + ) + else: + # Register as regular resource + resource = FunctionResource( + uri=AnyUrl(uri), + name=name, + description=description, + mime_type=mime_type or "text/plain", + fn=wrapper, + ) + self.add_resource(resource) + return wrapper + + return decorator + + def add_prompt(self, prompt: Prompt) -> None: + """Add a prompt to the server. + + Args: + prompt: A Prompt instance to add + """ + self._prompt_manager.add_prompt(prompt) + + def prompt( + self, name: str | None = None, description: str | None = None + ) -> Callable: + """Decorator to register a prompt. + + Args: + name: Optional name for the prompt (defaults to function name) + description: Optional description of what the prompt does + + Example: + @server.prompt() + def analyze_table(table_name: str) -> list[Message]: + schema = read_table_schema(table_name) + return [ + { + "role": "user", + "content": f"Analyze this schema:\n{schema}" + } + ] + + @server.prompt() + async def analyze_file(path: str) -> list[Message]: + content = await read_file(path) + return [ + { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": f"file://{path}", + "text": content + } + } + } + ] + """ + # Check if user passed function directly instead of calling decorator + if callable(name): + raise TypeError( + "The @prompt decorator was used incorrectly. " + "Did you forget to call it? Use @prompt() instead of @prompt" + ) + + def decorator(func: Callable) -> Callable: + prompt = Prompt.from_function(func, name=name, description=description) + self.add_prompt(prompt) + return func + + return decorator + + async def run_stdio_async(self) -> None: + """Run the server using stdio transport.""" + async with stdio_server() as (read_stream, write_stream): + await self._mcp_server.run( + read_stream, + write_stream, + self._mcp_server.create_initialization_options(), + ) + + async def run_sse_async(self) -> None: + """Run the server using SSE transport.""" + from starlette.applications import Starlette + from starlette.routing import Route + + sse = SseServerTransport("/messages") + + async def handle_sse(request): + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await self._mcp_server.run( + streams[0], + streams[1], + self._mcp_server.create_initialization_options(), + ) + + async def handle_messages(request): + await sse.handle_post_message(request.scope, request.receive, request._send) + + starlette_app = Starlette( + debug=self.settings.debug, + routes=[ + Route("/sse", endpoint=handle_sse), + Route("/messages", endpoint=handle_messages, methods=["POST"]), + ], + ) + + config = uvicorn.Config( + starlette_app, + host=self.settings.host, + port=self.settings.port, + log_level=self.settings.log_level.lower(), + ) + server = uvicorn.Server(config) + await server.serve() + + async def list_prompts(self) -> list[MCPPrompt]: + """List all available prompts.""" + prompts = self._prompt_manager.list_prompts() + return [ + MCPPrompt( + name=prompt.name, + description=prompt.description, + arguments=[ + MCPPromptArgument( + name=arg.name, + description=arg.description, + required=arg.required, + ) + for arg in (prompt.arguments or []) + ], + ) + for prompt in prompts + ] + + async def get_prompt( + self, name: str, arguments: dict[str, Any] | None = None + ) -> GetPromptResult: + """Get a prompt by name with arguments.""" + try: + messages = await self._prompt_manager.render_prompt(name, arguments) + + return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) + except Exception as e: + logger.error(f"Error getting prompt {name}: {e}") + raise ValueError(str(e)) + + +def _convert_to_content( + result: Any, +) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + """Convert a result to a sequence of content objects.""" + if result is None: + return [] + + if isinstance(result, (TextContent, ImageContent, EmbeddedResource)): + return [result] + + if isinstance(result, Image): + return [result.to_image_content()] + + if isinstance(result, (list, tuple)): + return list(chain.from_iterable(_convert_to_content(item) for item in result)) + + if not isinstance(result, str): + try: + result = json.dumps(pydantic_core.to_jsonable_python(result)) + except Exception: + result = str(result) + + return [TextContent(type="text", text=result)] + + +class Context(BaseModel): + """Context object providing access to MCP capabilities. + + This provides a cleaner interface to MCP's RequestContext functionality. + It gets injected into tool and resource functions that request it via type hints. + + To use context in a tool function, add a parameter with the Context type annotation: + + ```python + @server.tool() + def my_tool(x: int, ctx: Context) -> str: + # Log messages to the client + ctx.info(f"Processing {x}") + ctx.debug("Debug info") + ctx.warning("Warning message") + ctx.error("Error message") + + # Report progress + ctx.report_progress(50, 100) + + # Access resources + data = ctx.read_resource("resource://data") + + # Get request info + request_id = ctx.request_id + client_id = ctx.client_id + + return str(x) + ``` + + The context parameter name can be anything as long as it's annotated with Context. + The context is optional - tools that don't need it can omit the parameter. + """ + + _request_context: RequestContext | None + _fastmcp: FastMCP | None + + def __init__( + self, + *, + request_context: RequestContext | None = None, + fastmcp: FastMCP | None = None, + **kwargs: Any, + ): + super().__init__(**kwargs) + self._request_context = request_context + self._fastmcp = fastmcp + + @property + def fastmcp(self) -> FastMCP: + """Access to the FastMCP server.""" + if self._fastmcp is None: + raise ValueError("Context is not available outside of a request") + return self._fastmcp + + @property + def request_context(self) -> RequestContext: + """Access to the underlying request context.""" + if self._request_context is None: + raise ValueError("Context is not available outside of a request") + return self._request_context + + async def report_progress( + self, progress: float, total: float | None = None + ) -> None: + """Report progress for the current operation. + + Args: + progress: Current progress value e.g. 24 + total: Optional total value e.g. 100 + """ + + progress_token = ( + self.request_context.meta.progressToken + if self.request_context.meta + else None + ) + + if not progress_token: + return + + await self.request_context.session.send_progress_notification( + progress_token=progress_token, progress=progress, total=total + ) + + async def read_resource(self, uri: str | AnyUrl) -> str | bytes: + """Read a resource by URI. + + Args: + uri: Resource URI to read + + Returns: + The resource content as either text or bytes + """ + assert ( + self._fastmcp is not None + ), "Context is not available outside of a request" + return await self._fastmcp.read_resource(uri) + + def log( + self, + level: Literal["debug", "info", "warning", "error"], + message: str, + *, + logger_name: str | None = None, + ) -> None: + """Send a log message to the client. + + Args: + level: Log level (debug, info, warning, error) + message: Log message + logger_name: Optional logger name + **extra: Additional structured data to include + """ + self.request_context.session.send_log_message( + level=level, data=message, logger=logger_name + ) + + @property + def client_id(self) -> str | None: + """Get the client ID if available.""" + return ( + getattr(self.request_context.meta, "client_id", None) + if self.request_context.meta + else None + ) + + @property + def request_id(self) -> str: + """Get the unique ID for this request.""" + return str(self.request_context.request_id) + + @property + def session(self): + """Access to the underlying session for advanced usage.""" + return self.request_context.session + + # Convenience methods for common log levels + def debug(self, message: str, **extra: Any) -> None: + """Send a debug log message.""" + self.log("debug", message, **extra) + + def info(self, message: str, **extra: Any) -> None: + """Send an info log message.""" + self.log("info", message, **extra) + + def warning(self, message: str, **extra: Any) -> None: + """Send a warning log message.""" + self.log("warning", message, **extra) + + def error(self, message: str, **extra: Any) -> None: + """Send an error log message.""" + self.log("error", message, **extra) diff --git a/src/mcp/server/fastmcp/tools/__init__.py b/src/mcp/server/fastmcp/tools/__init__.py new file mode 100644 index 000000000..ae9c65619 --- /dev/null +++ b/src/mcp/server/fastmcp/tools/__init__.py @@ -0,0 +1,4 @@ +from .base import Tool +from .tool_manager import ToolManager + +__all__ = ["Tool", "ToolManager"] diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py new file mode 100644 index 000000000..8f2ea482e --- /dev/null +++ b/src/mcp/server/fastmcp/tools/base.py @@ -0,0 +1,82 @@ +import mcp.server.fastmcp +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.utilities.func_metadata import func_metadata, FuncMetadata +from pydantic import BaseModel, Field + + +import inspect +from typing import TYPE_CHECKING, Any, Callable + +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + + +class Tool(BaseModel): + """Internal tool registration info.""" + + fn: Callable = Field(exclude=True) + name: str = Field(description="Name of the tool") + description: str = Field(description="Description of what the tool does") + parameters: dict = Field(description="JSON schema for tool parameters") + fn_metadata: FuncMetadata = Field( + description="Metadata about the function including a pydantic model for tool arguments" + ) + is_async: bool = Field(description="Whether the tool is async") + context_kwarg: str | None = Field( + None, description="Name of the kwarg that should receive context" + ) + + @classmethod + def from_function( + cls, + fn: Callable, + name: str | None = None, + description: str | None = None, + context_kwarg: str | None = None, + ) -> "Tool": + """Create a Tool from a function.""" + func_name = name or fn.__name__ + + if func_name == "": + raise ValueError("You must provide a name for lambda functions") + + func_doc = description or fn.__doc__ or "" + is_async = inspect.iscoroutinefunction(fn) + + # Find context parameter if it exists + if context_kwarg is None: + sig = inspect.signature(fn) + for param_name, param in sig.parameters.items(): + if param.annotation is mcp.server.fastmcp.Context: + context_kwarg = param_name + break + + func_arg_metadata = func_metadata( + fn, + skip_names=[context_kwarg] if context_kwarg is not None else [], + ) + parameters = func_arg_metadata.arg_model.model_json_schema() + + return cls( + fn=fn, + name=func_name, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + context_kwarg=context_kwarg, + ) + + async def run(self, arguments: dict, context: "Context | None" = None) -> Any: + """Run the tool with arguments.""" + try: + return await self.fn_metadata.call_fn_with_arg_validation( + self.fn, + self.is_async, + arguments, + {self.context_kwarg: context} + if self.context_kwarg is not None + else None, + ) + except Exception as e: + raise ToolError(f"Error executing tool {self.name}: {e}") from e diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py new file mode 100644 index 000000000..52b45d1e1 --- /dev/null +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -0,0 +1,54 @@ +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.tools.base import Tool + +from typing import Any, Callable, TYPE_CHECKING +from collections.abc import Callable + +from mcp.server.fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from mcp.server.fastmcp.server import Context + +logger = get_logger(__name__) + + +class ToolManager: + """Manages FastMCP tools.""" + + def __init__(self, warn_on_duplicate_tools: bool = True): + self._tools: dict[str, Tool] = {} + self.warn_on_duplicate_tools = warn_on_duplicate_tools + + def get_tool(self, name: str) -> Tool | None: + """Get tool by name.""" + return self._tools.get(name) + + def list_tools(self) -> list[Tool]: + """List all registered tools.""" + return list(self._tools.values()) + + def add_tool( + self, + fn: Callable, + name: str | None = None, + description: str | None = None, + ) -> Tool: + """Add a tool to the server.""" + tool = Tool.from_function(fn, name=name, description=description) + existing = self._tools.get(tool.name) + if existing: + if self.warn_on_duplicate_tools: + logger.warning(f"Tool already exists: {tool.name}") + return existing + self._tools[tool.name] = tool + return tool + + async def call_tool( + self, name: str, arguments: dict, context: "Context | None" = None + ) -> Any: + """Call a tool by name with arguments.""" + tool = self.get_tool(name) + if not tool: + raise ToolError(f"Unknown tool: {name}") + + return await tool.run(arguments, context=context) diff --git a/src/mcp/server/fastmcp/utilities/__init__.py b/src/mcp/server/fastmcp/utilities/__init__.py new file mode 100644 index 000000000..be448f97a --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/__init__.py @@ -0,0 +1 @@ +"""FastMCP utility modules.""" diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py new file mode 100644 index 000000000..b1f13854e --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -0,0 +1,210 @@ +import inspect +from collections.abc import Callable, Sequence, Awaitable +from typing import ( + Annotated, + Any, + ForwardRef, +) +from pydantic import Field +from mcp.server.fastmcp.exceptions import InvalidSignature +from pydantic._internal._typing_extra import eval_type_backport +import json +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from pydantic import ConfigDict, create_model +from pydantic import WithJsonSchema +from pydantic_core import PydanticUndefined +from mcp.server.fastmcp.utilities.logging import get_logger + + +logger = get_logger(__name__) + + +class ArgModelBase(BaseModel): + """A model representing the arguments to a function.""" + + def model_dump_one_level(self) -> dict[str, Any]: + """Return a dict of the model's fields, one level deep. + + That is, sub-models etc are not dumped - they are kept as pydantic models. + """ + kwargs: dict[str, Any] = {} + for field_name in self.model_fields.keys(): + kwargs[field_name] = getattr(self, field_name) + return kwargs + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + +class FuncMetadata(BaseModel): + arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] + # We can add things in the future like + # - Maybe some args are excluded from attempting to parse from JSON + # - Maybe some args are special (like context) for dependency injection + + async def call_fn_with_arg_validation( + self, + fn: Callable[..., Any] | Awaitable[Any], + fn_is_async: bool, + arguments_to_validate: dict[str, Any], + arguments_to_pass_directly: dict[str, Any] | None, + ) -> Any: + """Call the given function with arguments validated and injected. + + Arguments are first attempted to be parsed from JSON, then validated against + the argument model, before being passed to the function. + """ + arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) + arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) + arguments_parsed_dict = arguments_parsed_model.model_dump_one_level() + + arguments_parsed_dict |= arguments_to_pass_directly or {} + + if fn_is_async: + if isinstance(fn, Awaitable): + return await fn + return await fn(**arguments_parsed_dict) + if isinstance(fn, Callable): + return fn(**arguments_parsed_dict) + raise TypeError("fn must be either Callable or Awaitable") + + def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: + """Pre-parse data from JSON. + + Return a dict with same keys as input but with values parsed from JSON + if appropriate. + + This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside + a string rather than an actual list. Claude desktop is prone to this - in fact + it seems incapable of NOT doing this. For sub-models, it tends to pass + dicts (JSON objects) as JSON strings, which can be pre-parsed here. + """ + new_data = data.copy() # Shallow copy + for field_name, field_info in self.arg_model.model_fields.items(): + if field_name not in data.keys(): + continue + if isinstance(data[field_name], str): + try: + pre_parsed = json.loads(data[field_name]) + except json.JSONDecodeError: + continue # Not JSON - skip + if isinstance(pre_parsed, str): + # This is likely that the raw value is e.g. `"hello"` which we + # Should really be parsed as '"hello"' in Python - but if we parse + # it as JSON it'll turn into just 'hello'. So we skip it. + continue + new_data[field_name] = pre_parsed + assert new_data.keys() == data.keys() + return new_data + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + +def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata: + """Given a function, return metadata including a pydantic model representing its signature. + + The use case for this is + ``` + meta = func_to_pyd(func) + validated_args = meta.arg_model.model_validate(some_raw_data_dict) + return func(**validated_args.model_dump_one_level()) + ``` + + **critically** it also provides pre-parse helper to attempt to parse things from JSON. + + Args: + func: The function to convert to a pydantic model + skip_names: A list of parameter names to skip. These will not be included in + the model. + Returns: + A pydantic model representing the function's signature. + """ + sig = _get_typed_signature(func) + params = sig.parameters + dynamic_pydantic_model_params: dict[str, Any] = {} + globalns = getattr(func, "__globals__", {}) + for param in params.values(): + if param.name.startswith("_"): + raise InvalidSignature( + f"Parameter {param.name} of {func.__name__} may not start with an underscore" + ) + if param.name in skip_names: + continue + annotation = param.annotation + + # `x: None` / `x: None = None` + if annotation is None: + annotation = Annotated[ + None, + Field( + default=param.default + if param.default is not inspect.Parameter.empty + else PydanticUndefined + ), + ] + + # Untyped field + if annotation is inspect.Parameter.empty: + annotation = Annotated[ + Any, + Field(), + # 🤷 + WithJsonSchema({"title": param.name, "type": "string"}), + ] + + field_info = FieldInfo.from_annotated_attribute( + _get_typed_annotation(annotation, globalns), + param.default + if param.default is not inspect.Parameter.empty + else PydanticUndefined, + ) + dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info) + continue + + arguments_model = create_model( + f"{func.__name__}Arguments", + **dynamic_pydantic_model_params, + __base__=ArgModelBase, + ) + resp = FuncMetadata(arg_model=arguments_model) + return resp + + +def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: + def try_eval_type(value, globalns, localns): + try: + return eval_type_backport(value, globalns, localns), True + except NameError: + return value, False + + if isinstance(annotation, str): + annotation = ForwardRef(annotation) + annotation, status = try_eval_type(annotation, globalns, globalns) + + # This check and raise could perhaps be skipped, and we (FastMCP) just call + # model_rebuild right before using it 🤷 + if status is False: + raise InvalidSignature(f"Unable to evaluate type annotation {annotation}") + + return annotation + + +def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: + """Get function signature while evaluating forward references""" + signature = inspect.signature(call) + globalns = getattr(call, "__globals__", {}) + typed_params = [ + inspect.Parameter( + name=param.name, + kind=param.kind, + default=param.default, + annotation=_get_typed_annotation(param.annotation, globalns), + ) + for param in signature.parameters.values() + ] + typed_signature = inspect.Signature(typed_params) + return typed_signature diff --git a/src/mcp/server/fastmcp/utilities/logging.py b/src/mcp/server/fastmcp/utilities/logging.py new file mode 100644 index 000000000..60738f8e2 --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/logging.py @@ -0,0 +1,41 @@ +"""Logging utilities for FastMCP.""" + +import logging +from typing import Literal + +def get_logger(name: str) -> logging.Logger: + """Get a logger nested under MCPnamespace. + + Args: + name: the name of the logger, which will be prefixed with 'FastMCP.' + + Returns: + a configured logger instance + """ + return logging.getLogger(name) + + +def configure_logging( + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", +) -> None: + """Configure logging for MCP. + + Args: + level: the log level to use + """ + handlers = [] + try: + from rich.console import Console + from rich.logging import RichHandler + handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True)) + except ImportError: + pass + + if not handlers: + handlers.append(logging.StreamHandler()) + + logging.basicConfig( + level=level, + format="%(message)s", + handlers=handlers, + ) diff --git a/src/mcp/server/fastmcp/utilities/types.py b/src/mcp/server/fastmcp/utilities/types.py new file mode 100644 index 000000000..ccaa3d69a --- /dev/null +++ b/src/mcp/server/fastmcp/utilities/types.py @@ -0,0 +1,54 @@ +"""Common types used across FastMCP.""" + +import base64 +from pathlib import Path + +from mcp.types import ImageContent + + +class Image: + """Helper class for returning images from tools.""" + + def __init__( + self, + path: str | Path | None = None, + data: bytes | None = None, + format: str | None = None, + ): + if path is None and data is None: + raise ValueError("Either path or data must be provided") + if path is not None and data is not None: + raise ValueError("Only one of path or data can be provided") + + self.path = Path(path) if path else None + self.data = data + self._format = format + self._mime_type = self._get_mime_type() + + def _get_mime_type(self) -> str: + """Get MIME type from format or guess from file extension.""" + if self._format: + return f"image/{self._format.lower()}" + + if self.path: + suffix = self.path.suffix.lower() + return { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(suffix, "application/octet-stream") + return "image/png" # default for raw binary data + + def to_image_content(self) -> ImageContent: + """Convert to MCP ImageContent.""" + if self.path: + with open(self.path, "rb") as f: + data = base64.b64encode(f.read()).decode() + elif self.data is not None: + data = base64.b64encode(self.data).decode() + else: + raise ValueError("No image data available") + + return ImageContent(type="image", data=data, mimeType=self._mime_type) diff --git a/src/mcp/server/lowlevel/__init__.py b/src/mcp/server/lowlevel/__init__.py new file mode 100644 index 000000000..a6dff439e --- /dev/null +++ b/src/mcp/server/lowlevel/__init__.py @@ -0,0 +1,3 @@ +from .server import Server, NotificationOptions + +__all__ = ["Server", "NotificationOptions"] diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py new file mode 100644 index 000000000..a0dd033d6 --- /dev/null +++ b/src/mcp/server/lowlevel/server.py @@ -0,0 +1,500 @@ +""" +MCP Server Module + +This module provides a framework for creating an MCP (Model Context Protocol) server. +It allows you to easily define and handle various types of requests and notifications +in an asynchronous manner. + +Usage: +1. Create a Server instance: + server = Server("your_server_name") + +2. Define request handlers using decorators: + @server.list_prompts() + async def handle_list_prompts() -> list[types.Prompt]: + # Implementation + + @server.get_prompt() + async def handle_get_prompt( + name: str, arguments: dict[str, str] | None + ) -> types.GetPromptResult: + # Implementation + + @server.list_tools() + async def handle_list_tools() -> list[types.Tool]: + # Implementation + + @server.call_tool() + async def handle_call_tool( + name: str, arguments: dict | None + ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + # Implementation + + @server.list_resource_templates() + async def handle_list_resource_templates() -> list[types.ResourceTemplate]: + # Implementation + +3. Define notification handlers if needed: + @server.progress_notification() + async def handle_progress( + progress_token: str | int, progress: float, total: float | None + ) -> None: + # Implementation + +4. Run the server: + async def main(): + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="your_server_name", + server_version="your_version", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + asyncio.run(main()) + +The Server class provides methods to register handlers for various MCP requests and +notifications. It automatically manages the request context and handles incoming +messages from the client. +""" + +import contextvars +import logging +import warnings +from collections.abc import Awaitable, Callable +from typing import Any, Sequence + +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.server.stdio import stdio_server as stdio_server +from mcp.shared.context import RequestContext +from mcp.shared.exceptions import McpError +from mcp.shared.session import RequestResponder + +logger = logging.getLogger(__name__) + +request_ctx: contextvars.ContextVar[RequestContext[ServerSession]] = ( + contextvars.ContextVar("request_ctx") +) + + +class NotificationOptions: + def __init__( + self, + prompts_changed: bool = False, + resources_changed: bool = False, + tools_changed: bool = False, + ): + self.prompts_changed = prompts_changed + self.resources_changed = resources_changed + self.tools_changed = tools_changed + + +class Server: + def __init__(self, name: str): + self.name = name + self.request_handlers: dict[ + type, Callable[..., Awaitable[types.ServerResult]] + ] = { + types.PingRequest: _ping_handler, + } + self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} + self.notification_options = NotificationOptions() + logger.debug(f"Initializing server '{name}'") + + def create_initialization_options( + self, + notification_options: NotificationOptions | None = None, + experimental_capabilities: dict[str, dict[str, Any]] | None = None, + ) -> InitializationOptions: + """Create initialization options from this server instance.""" + + def pkg_version(package: str) -> str: + try: + from importlib.metadata import version + + v = version(package) + if v is not None: + return v + except Exception: + pass + + return "unknown" + + return InitializationOptions( + server_name=self.name, + server_version=pkg_version("mcp"), + capabilities=self.get_capabilities( + notification_options or NotificationOptions(), + experimental_capabilities or {}, + ), + ) + + def get_capabilities( + self, + notification_options: NotificationOptions, + experimental_capabilities: dict[str, dict[str, Any]], + ) -> types.ServerCapabilities: + """Convert existing handlers to a ServerCapabilities object.""" + prompts_capability = None + resources_capability = None + tools_capability = None + logging_capability = None + + # Set prompt capabilities if handler exists + if types.ListPromptsRequest in self.request_handlers: + prompts_capability = types.PromptsCapability( + listChanged=notification_options.prompts_changed + ) + + # Set resource capabilities if handler exists + if types.ListResourcesRequest in self.request_handlers: + resources_capability = types.ResourcesCapability( + subscribe=False, listChanged=notification_options.resources_changed + ) + + # Set tool capabilities if handler exists + if types.ListToolsRequest in self.request_handlers: + tools_capability = types.ToolsCapability( + listChanged=notification_options.tools_changed + ) + + # Set logging capabilities if handler exists + if types.SetLevelRequest in self.request_handlers: + logging_capability = types.LoggingCapability() + + return types.ServerCapabilities( + prompts=prompts_capability, + resources=resources_capability, + tools=tools_capability, + logging=logging_capability, + experimental=experimental_capabilities, + ) + + @property + def request_context(self) -> RequestContext[ServerSession]: + """If called outside of a request context, this will raise a LookupError.""" + return request_ctx.get() + + def list_prompts(self): + def decorator(func: Callable[[], Awaitable[list[types.Prompt]]]): + logger.debug("Registering handler for PromptListRequest") + + async def handler(_: Any): + prompts = await func() + return types.ServerResult(types.ListPromptsResult(prompts=prompts)) + + self.request_handlers[types.ListPromptsRequest] = handler + return func + + return decorator + + def get_prompt(self): + def decorator( + func: Callable[ + [str, dict[str, str] | None], Awaitable[types.GetPromptResult] + ], + ): + logger.debug("Registering handler for GetPromptRequest") + + async def handler(req: types.GetPromptRequest): + prompt_get = await func(req.params.name, req.params.arguments) + return types.ServerResult(prompt_get) + + self.request_handlers[types.GetPromptRequest] = handler + return func + + return decorator + + def list_resources(self): + def decorator(func: Callable[[], Awaitable[list[types.Resource]]]): + logger.debug("Registering handler for ListResourcesRequest") + + async def handler(_: Any): + resources = await func() + return types.ServerResult( + types.ListResourcesResult(resources=resources) + ) + + self.request_handlers[types.ListResourcesRequest] = handler + return func + + return decorator + + def list_resource_templates(self): + def decorator(func: Callable[[], Awaitable[list[types.ResourceTemplate]]]): + logger.debug("Registering handler for ListResourceTemplatesRequest") + + async def handler(_: Any): + templates = await func() + return types.ServerResult( + types.ListResourceTemplatesResult(resourceTemplates=templates) + ) + + self.request_handlers[types.ListResourceTemplatesRequest] = handler + return func + + return decorator + + def read_resource(self): + def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]): + logger.debug("Registering handler for ReadResourceRequest") + + async def handler(req: types.ReadResourceRequest): + result = await func(req.params.uri) + match result: + case str(s): + content = types.TextResourceContents( + uri=req.params.uri, + text=s, + mimeType="text/plain", + ) + case bytes(b): + import base64 + + content = types.BlobResourceContents( + uri=req.params.uri, + blob=base64.urlsafe_b64encode(b).decode(), + mimeType="application/octet-stream", + ) + + return types.ServerResult( + types.ReadResourceResult( + contents=[content], + ) + ) + + self.request_handlers[types.ReadResourceRequest] = handler + return func + + return decorator + + def set_logging_level(self): + def decorator(func: Callable[[types.LoggingLevel], Awaitable[None]]): + logger.debug("Registering handler for SetLevelRequest") + + async def handler(req: types.SetLevelRequest): + await func(req.params.level) + return types.ServerResult(types.EmptyResult()) + + self.request_handlers[types.SetLevelRequest] = handler + return func + + return decorator + + def subscribe_resource(self): + def decorator(func: Callable[[AnyUrl], Awaitable[None]]): + logger.debug("Registering handler for SubscribeRequest") + + async def handler(req: types.SubscribeRequest): + await func(req.params.uri) + return types.ServerResult(types.EmptyResult()) + + self.request_handlers[types.SubscribeRequest] = handler + return func + + return decorator + + def unsubscribe_resource(self): + def decorator(func: Callable[[AnyUrl], Awaitable[None]]): + logger.debug("Registering handler for UnsubscribeRequest") + + async def handler(req: types.UnsubscribeRequest): + await func(req.params.uri) + return types.ServerResult(types.EmptyResult()) + + self.request_handlers[types.UnsubscribeRequest] = handler + return func + + return decorator + + def list_tools(self): + def decorator(func: Callable[[], Awaitable[list[types.Tool]]]): + logger.debug("Registering handler for ListToolsRequest") + + async def handler(_: Any): + tools = await func() + return types.ServerResult(types.ListToolsResult(tools=tools)) + + self.request_handlers[types.ListToolsRequest] = handler + return func + + return decorator + + def call_tool(self): + def decorator( + func: Callable[ + ..., + Awaitable[ + Sequence[ + types.TextContent | types.ImageContent | types.EmbeddedResource + ] + ], + ], + ): + logger.debug("Registering handler for CallToolRequest") + + async def handler(req: types.CallToolRequest): + try: + results = await func(req.params.name, (req.params.arguments or {})) + return types.ServerResult( + types.CallToolResult(content=list(results), isError=False) + ) + except Exception as e: + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=str(e))], + isError=True, + ) + ) + + self.request_handlers[types.CallToolRequest] = handler + return func + + return decorator + + def progress_notification(self): + def decorator( + func: Callable[[str | int, float, float | None], Awaitable[None]], + ): + logger.debug("Registering handler for ProgressNotification") + + async def handler(req: types.ProgressNotification): + await func( + req.params.progressToken, req.params.progress, req.params.total + ) + + self.notification_handlers[types.ProgressNotification] = handler + return func + + return decorator + + def completion(self): + """Provides completions for prompts and resource templates""" + + def decorator( + func: Callable[ + [ + types.PromptReference | types.ResourceReference, + types.CompletionArgument, + ], + Awaitable[types.Completion | None], + ], + ): + logger.debug("Registering handler for CompleteRequest") + + async def handler(req: types.CompleteRequest): + completion = await func(req.params.ref, req.params.argument) + return types.ServerResult( + types.CompleteResult( + completion=completion + if completion is not None + else types.Completion(values=[], total=None, hasMore=None), + ) + ) + + self.request_handlers[types.CompleteRequest] = handler + return func + + return decorator + + async def run( + self, + read_stream: MemoryObjectReceiveStream[types.JSONRPCMessage | Exception], + write_stream: MemoryObjectSendStream[types.JSONRPCMessage], + initialization_options: InitializationOptions, + # When False, exceptions are returned as messages to the client. + # When True, exceptions are raised, which will cause the server to shut down + # but also make tracing exceptions much easier during testing and when using + # in-process servers. + raise_exceptions: bool = False, + ): + with warnings.catch_warnings(record=True) as w: + async with ServerSession( + read_stream, write_stream, initialization_options + ) as session: + async for message in session.incoming_messages: + logger.debug(f"Received message: {message}") + + match message: + case RequestResponder(request=types.ClientRequest(root=req)): + logger.info( + f"Processing request of type {type(req).__name__}" + ) + if type(req) in self.request_handlers: + handler = self.request_handlers[type(req)] + logger.debug( + f"Dispatching request of type {type(req).__name__}" + ) + + token = None + try: + # Set our global state that can be retrieved via + # app.get_request_context() + token = request_ctx.set( + RequestContext( + message.request_id, + message.request_meta, + session, + ) + ) + response = await handler(req) + except McpError as err: + response = err.error + except Exception as err: + if raise_exceptions: + raise err + response = types.ErrorData( + code=0, message=str(err), data=None + ) + finally: + # Reset the global state after we are done + if token is not None: + request_ctx.reset(token) + + await message.respond(response) + else: + await message.respond( + types.ErrorData( + code=types.METHOD_NOT_FOUND, + message="Method not found", + ) + ) + + logger.debug("Response sent") + case types.ClientNotification(root=notify): + if type(notify) in self.notification_handlers: + assert type(notify) in self.notification_handlers + + handler = self.notification_handlers[type(notify)] + logger.debug( + f"Dispatching notification of type " + f"{type(notify).__name__}" + ) + + try: + await handler(notify) + except Exception as err: + logger.error( + f"Uncaught exception in notification handler: " + f"{err}" + ) + + for warning in w: + logger.info( + f"Warning: {warning.category.__name__}: {warning.message}" + ) + + +async def _ping_handler(request: types.PingRequest) -> types.ServerResult: + return types.ServerResult(types.EmptyResult()) diff --git a/tests/conftest.py b/tests/conftest.py index 8d792aa29..381e5db5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,3 +27,7 @@ async def handle_list_resources(): ] return server + +@pytest.fixture +def anyio_backend(): + return 'asyncio' diff --git a/tests/server/fastmcp/__init__.py b/tests/server/fastmcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/prompts/__init__.py b/tests/server/fastmcp/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/fastmcp/prompts/test_base.py new file mode 100644 index 000000000..63dc230ea --- /dev/null +++ b/tests/server/fastmcp/prompts/test_base.py @@ -0,0 +1,194 @@ +from pydantic import FileUrl +import pytest +from mcp.server.fastmcp.prompts.base import ( + Prompt, + UserMessage, + TextContent, + AssistantMessage, + Message, +) +from mcp.types import EmbeddedResource, TextResourceContents + + +class TestRenderPrompt: + async def test_basic_fn(self): + def fn() -> str: + return "Hello, world!" + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage(content=TextContent(type="text", text="Hello, world!")) + ] + + async def test_async_fn(self): + async def fn() -> str: + return "Hello, world!" + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage(content=TextContent(type="text", text="Hello, world!")) + ] + + async def test_fn_with_args(self): + async def fn(name: str, age: int = 30) -> str: + return f"Hello, {name}! You're {age} years old." + + prompt = Prompt.from_function(fn) + assert await prompt.render(arguments=dict(name="World")) == [ + UserMessage( + content=TextContent( + type="text", text="Hello, World! You're 30 years old." + ) + ) + ] + + async def test_fn_with_invalid_kwargs(self): + async def fn(name: str, age: int = 30) -> str: + return f"Hello, {name}! You're {age} years old." + + prompt = Prompt.from_function(fn) + with pytest.raises(ValueError): + await prompt.render(arguments=dict(age=40)) + + async def test_fn_returns_message(self): + async def fn() -> UserMessage: + return UserMessage(content="Hello, world!") + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage(content=TextContent(type="text", text="Hello, world!")) + ] + + async def test_fn_returns_assistant_message(self): + async def fn() -> AssistantMessage: + return AssistantMessage( + content=TextContent(type="text", text="Hello, world!") + ) + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + AssistantMessage(content=TextContent(type="text", text="Hello, world!")) + ] + + async def test_fn_returns_multiple_messages(self): + expected = [ + UserMessage("Hello, world!"), + AssistantMessage("How can I help you today?"), + UserMessage("I'm looking for a restaurant in the center of town."), + ] + + async def fn() -> list[Message]: + return expected + + prompt = Prompt.from_function(fn) + assert await prompt.render() == expected + + async def test_fn_returns_list_of_strings(self): + expected = [ + "Hello, world!", + "I'm looking for a restaurant in the center of town.", + ] + + async def fn() -> list[str]: + return expected + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [UserMessage(t) for t in expected] + + async def test_fn_returns_resource_content(self): + """Test returning a message with resource content.""" + + async def fn() -> UserMessage: + return UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=FileUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ) + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=FileUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ) + ] + + async def test_fn_returns_mixed_content(self): + """Test returning messages with mixed content types.""" + + async def fn() -> list[Message]: + return [ + UserMessage(content="Please analyze this file:"), + UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=FileUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ), + AssistantMessage(content="I'll help analyze that file."), + ] + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage( + content=TextContent(type="text", text="Please analyze this file:") + ), + UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=FileUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ), + AssistantMessage( + content=TextContent(type="text", text="I'll help analyze that file.") + ), + ] + + async def test_fn_returns_dict_with_resource(self): + """Test returning a dict with resource content.""" + + async def fn() -> dict: + return { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": FileUrl("file://file.txt"), + "text": "File contents", + "mimeType": "text/plain", + }, + }, + } + + prompt = Prompt.from_function(fn) + assert await prompt.render() == [ + UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=FileUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ) + ] diff --git a/tests/server/fastmcp/prompts/test_manager.py b/tests/server/fastmcp/prompts/test_manager.py new file mode 100644 index 000000000..7b97b302a --- /dev/null +++ b/tests/server/fastmcp/prompts/test_manager.py @@ -0,0 +1,107 @@ +import pytest +from mcp.server.fastmcp.prompts.base import UserMessage, TextContent, Prompt +from mcp.server.fastmcp.prompts.manager import PromptManager + + +class TestPromptManager: + def test_add_prompt(self): + """Test adding a prompt to the manager.""" + + def fn() -> str: + return "Hello, world!" + + manager = PromptManager() + prompt = Prompt.from_function(fn) + added = manager.add_prompt(prompt) + assert added == prompt + assert manager.get_prompt("fn") == prompt + + def test_add_duplicate_prompt(self, caplog): + """Test adding the same prompt twice.""" + + def fn() -> str: + return "Hello, world!" + + manager = PromptManager() + prompt = Prompt.from_function(fn) + first = manager.add_prompt(prompt) + second = manager.add_prompt(prompt) + assert first == second + assert "Prompt already exists" in caplog.text + + def test_disable_warn_on_duplicate_prompts(self, caplog): + """Test disabling warning on duplicate prompts.""" + + def fn() -> str: + return "Hello, world!" + + manager = PromptManager(warn_on_duplicate_prompts=False) + prompt = Prompt.from_function(fn) + first = manager.add_prompt(prompt) + second = manager.add_prompt(prompt) + assert first == second + assert "Prompt already exists" not in caplog.text + + def test_list_prompts(self): + """Test listing all prompts.""" + + def fn1() -> str: + return "Hello, world!" + + def fn2() -> str: + return "Goodbye, world!" + + manager = PromptManager() + prompt1 = Prompt.from_function(fn1) + prompt2 = Prompt.from_function(fn2) + manager.add_prompt(prompt1) + manager.add_prompt(prompt2) + prompts = manager.list_prompts() + assert len(prompts) == 2 + assert prompts == [prompt1, prompt2] + + async def test_render_prompt(self): + """Test rendering a prompt.""" + + def fn() -> str: + return "Hello, world!" + + manager = PromptManager() + prompt = Prompt.from_function(fn) + manager.add_prompt(prompt) + messages = await manager.render_prompt("fn") + assert messages == [ + UserMessage(content=TextContent(type="text", text="Hello, world!")) + ] + + async def test_render_prompt_with_args(self): + """Test rendering a prompt with arguments.""" + + def fn(name: str) -> str: + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(fn) + manager.add_prompt(prompt) + messages = await manager.render_prompt("fn", arguments={"name": "World"}) + assert messages == [ + UserMessage(content=TextContent(type="text", text="Hello, World!")) + ] + + async def test_render_unknown_prompt(self): + """Test rendering a non-existent prompt.""" + manager = PromptManager() + with pytest.raises(ValueError, match="Unknown prompt: unknown"): + await manager.render_prompt("unknown") + + async def test_render_prompt_with_missing_args(self): + """Test rendering a prompt with missing required arguments.""" + + def fn(name: str) -> str: + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(fn) + manager.add_prompt(prompt) + with pytest.raises(ValueError, match="Missing required arguments"): + await manager.render_prompt("fn") diff --git a/tests/server/fastmcp/resources/__init__.py b/tests/server/fastmcp/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/fastmcp/resources/test_file_resources.py new file mode 100644 index 000000000..f9ec15920 --- /dev/null +++ b/tests/server/fastmcp/resources/test_file_resources.py @@ -0,0 +1,115 @@ +import os + +import pytest +from pathlib import Path +from tempfile import NamedTemporaryFile +from pydantic import FileUrl + +from mcp.server.fastmcp.resources import FileResource + + +@pytest.fixture +def temp_file(): + """Create a temporary file for testing. + + File is automatically cleaned up after the test if it still exists. + """ + content = "test content" + with NamedTemporaryFile(mode="w", delete=False) as f: + f.write(content) + path = Path(f.name).resolve() + yield path + try: + path.unlink() + except FileNotFoundError: + pass # File was already deleted by the test + + +class TestFileResource: + """Test FileResource functionality.""" + + def test_file_resource_creation(self, temp_file: Path): + """Test creating a FileResource.""" + resource = FileResource( + uri=FileUrl(temp_file.as_uri()), + name="test", + description="test file", + path=temp_file, + ) + assert str(resource.uri) == temp_file.as_uri() + assert resource.name == "test" + assert resource.description == "test file" + assert resource.mime_type == "text/plain" # default + assert resource.path == temp_file + assert resource.is_binary is False # default + + def test_file_resource_str_path_conversion(self, temp_file: Path): + """Test FileResource handles string paths.""" + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=Path(str(temp_file)), + ) + assert isinstance(resource.path, Path) + assert resource.path.is_absolute() + + async def test_read_text_file(self, temp_file: Path): + """Test reading a text file.""" + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + content = await resource.read() + assert content == "test content" + assert resource.mime_type == "text/plain" + + async def test_read_binary_file(self, temp_file: Path): + """Test reading a file as binary.""" + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + is_binary=True, + ) + content = await resource.read() + assert isinstance(content, bytes) + assert content == b"test content" + + def test_relative_path_error(self): + """Test error on relative path.""" + with pytest.raises(ValueError, match="Path must be absolute"): + FileResource( + uri=FileUrl("file:///test.txt"), + name="test", + path=Path("test.txt"), + ) + + async def test_missing_file_error(self, temp_file: Path): + """Test error when file doesn't exist.""" + # Create path to non-existent file + missing = temp_file.parent / "missing.txt" + resource = FileResource( + uri=FileUrl("file:///missing.txt"), + name="test", + path=missing, + ) + with pytest.raises(ValueError, match="Error reading file"): + await resource.read() + + @pytest.mark.skipif( + os.name == "nt", reason="File permissions behave differently on Windows" + ) + async def test_permission_error(self, temp_file: Path): + """Test reading a file without permissions.""" + temp_file.chmod(0o000) # Remove all permissions + try: + resource = FileResource( + uri=FileUrl(temp_file.as_uri()), + name="test", + path=temp_file, + ) + with pytest.raises(ValueError, match="Error reading file"): + await resource.read() + finally: + temp_file.chmod(0o644) # Restore permissions diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py new file mode 100644 index 000000000..e132e5f15 --- /dev/null +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -0,0 +1,115 @@ +from pydantic import BaseModel, AnyUrl +import pytest +from mcp.server.fastmcp.resources import FunctionResource + + +class TestFunctionResource: + """Test FunctionResource functionality.""" + + def test_function_resource_creation(self): + """Test creating a FunctionResource.""" + + def my_func() -> str: + return "test content" + + resource = FunctionResource( + uri=AnyUrl("fn://test"), + name="test", + description="test function", + fn=my_func, + ) + assert str(resource.uri) == "fn://test" + assert resource.name == "test" + assert resource.description == "test function" + assert resource.mime_type == "text/plain" # default + assert resource.fn == my_func + + async def test_read_text(self): + """Test reading text from a FunctionResource.""" + + def get_data() -> str: + return "Hello, world!" + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=get_data, + ) + content = await resource.read() + assert content == "Hello, world!" + assert resource.mime_type == "text/plain" + + async def test_read_binary(self): + """Test reading binary data from a FunctionResource.""" + + def get_data() -> bytes: + return b"Hello, world!" + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=get_data, + ) + content = await resource.read() + assert content == b"Hello, world!" + + async def test_json_conversion(self): + """Test automatic JSON conversion of non-string results.""" + + def get_data() -> dict: + return {"key": "value"} + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=get_data, + ) + content = await resource.read() + assert isinstance(content, str) + assert '"key": "value"' in content + + async def test_error_handling(self): + """Test error handling in FunctionResource.""" + + def failing_func() -> str: + raise ValueError("Test error") + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=failing_func, + ) + with pytest.raises(ValueError, match="Error reading resource function://test"): + await resource.read() + + async def test_basemodel_conversion(self): + """Test handling of BaseModel types.""" + + class MyModel(BaseModel): + name: str + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=lambda: MyModel(name="test"), + ) + content = await resource.read() + assert content == '{"name": "test"}' + + async def test_custom_type_conversion(self): + """Test handling of custom types.""" + + class CustomData: + def __str__(self) -> str: + return "custom data" + + def get_data() -> CustomData: + return CustomData() + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=get_data, + ) + content = await resource.read() + assert isinstance(content, str) diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py new file mode 100644 index 000000000..d8d04e569 --- /dev/null +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -0,0 +1,137 @@ +import pytest +from pathlib import Path +from tempfile import NamedTemporaryFile +from pydantic import AnyUrl, FileUrl + +from mcp.server.fastmcp.resources import ( + FileResource, + FunctionResource, + ResourceManager, + ResourceTemplate, +) + + +@pytest.fixture +def temp_file(): + """Create a temporary file for testing. + + File is automatically cleaned up after the test if it still exists. + """ + content = "test content" + with NamedTemporaryFile(mode="w", delete=False) as f: + f.write(content) + path = Path(f.name).resolve() + yield path + try: + path.unlink() + except FileNotFoundError: + pass # File was already deleted by the test + + +class TestResourceManager: + """Test ResourceManager functionality.""" + + def test_add_resource(self, temp_file: Path): + """Test adding a resource.""" + manager = ResourceManager() + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + added = manager.add_resource(resource) + assert added == resource + assert manager.list_resources() == [resource] + + def test_add_duplicate_resource(self, temp_file: Path): + """Test adding the same resource twice.""" + manager = ResourceManager() + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + first = manager.add_resource(resource) + second = manager.add_resource(resource) + assert first == second + assert manager.list_resources() == [resource] + + def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): + """Test warning on duplicate resources.""" + manager = ResourceManager() + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + manager.add_resource(resource) + manager.add_resource(resource) + assert "Resource already exists" in caplog.text + + def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): + """Test disabling warning on duplicate resources.""" + manager = ResourceManager(warn_on_duplicate_resources=False) + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + manager.add_resource(resource) + manager.add_resource(resource) + assert "Resource already exists" not in caplog.text + + async def test_get_resource(self, temp_file: Path): + """Test getting a resource by URI.""" + manager = ResourceManager() + resource = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test", + path=temp_file, + ) + manager.add_resource(resource) + retrieved = await manager.get_resource(resource.uri) + assert retrieved == resource + + async def test_get_resource_from_template(self): + """Test getting a resource through a template.""" + manager = ResourceManager() + + def greet(name: str) -> str: + return f"Hello, {name}!" + + template = ResourceTemplate.from_function( + fn=greet, + uri_template="greet://{name}", + name="greeter", + ) + manager._templates[template.uri_template] = template + + resource = await manager.get_resource(AnyUrl("greet://world")) + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "Hello, world!" + + async def test_get_unknown_resource(self): + """Test getting a non-existent resource.""" + manager = ResourceManager() + with pytest.raises(ValueError, match="Unknown resource"): + await manager.get_resource(AnyUrl("unknown://test")) + + def test_list_resources(self, temp_file: Path): + """Test listing all resources.""" + manager = ResourceManager() + resource1 = FileResource( + uri=FileUrl(f"file://{temp_file}"), + name="test1", + path=temp_file, + ) + resource2 = FileResource( + uri=FileUrl(f"file://{temp_file}2"), + name="test2", + path=temp_file, + ) + manager.add_resource(resource1) + manager.add_resource(resource2) + resources = manager.list_resources() + assert len(resources) == 2 + assert resources == [resource1, resource2] diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py new file mode 100644 index 000000000..95d058501 --- /dev/null +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -0,0 +1,181 @@ +import json +import pytest +from pydantic import BaseModel + +from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate + + +class TestResourceTemplate: + """Test ResourceTemplate functionality.""" + + def test_template_creation(self): + """Test creating a template from a function.""" + + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="test://{key}/{value}", + name="test", + ) + assert template.uri_template == "test://{key}/{value}" + assert template.name == "test" + assert template.mime_type == "text/plain" # default + test_input = {"key": "test", "value": 42} + assert template.fn(**test_input) == my_func(**test_input) + + def test_template_matches(self): + """Test matching URIs against a template.""" + + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="test://{key}/{value}", + name="test", + ) + + # Valid match + params = template.matches("test://foo/123") + assert params == {"key": "foo", "value": "123"} + + # No match + assert template.matches("test://foo") is None + assert template.matches("other://foo/123") is None + + async def test_create_resource(self): + """Test creating a resource from a template.""" + + def my_func(key: str, value: int) -> dict: + return {"key": key, "value": value} + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="test://{key}/{value}", + name="test", + ) + + resource = await template.create_resource( + "test://foo/123", + {"key": "foo", "value": 123}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert isinstance(content, str) + data = json.loads(content) + assert data == {"key": "foo", "value": 123} + + async def test_template_error(self): + """Test error handling in template resource creation.""" + + def failing_func(x: str) -> str: + raise ValueError("Test error") + + template = ResourceTemplate.from_function( + fn=failing_func, + uri_template="fail://{x}", + name="fail", + ) + + with pytest.raises(ValueError, match="Error creating resource from template"): + await template.create_resource("fail://test", {"x": "test"}) + + async def test_async_text_resource(self): + """Test creating a text resource from async function.""" + + async def greet(name: str) -> str: + return f"Hello, {name}!" + + template = ResourceTemplate.from_function( + fn=greet, + uri_template="greet://{name}", + name="greeter", + ) + + resource = await template.create_resource( + "greet://world", + {"name": "world"}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "Hello, world!" + + async def test_async_binary_resource(self): + """Test creating a binary resource from async function.""" + + async def get_bytes(value: str) -> bytes: + return value.encode() + + template = ResourceTemplate.from_function( + fn=get_bytes, + uri_template="bytes://{value}", + name="bytes", + ) + + resource = await template.create_resource( + "bytes://test", + {"value": "test"}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == b"test" + + async def test_basemodel_conversion(self): + """Test handling of BaseModel types.""" + + class MyModel(BaseModel): + key: str + value: int + + def get_data(key: str, value: int) -> MyModel: + return MyModel(key=key, value=value) + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="test://{key}/{value}", + name="test", + ) + + resource = await template.create_resource( + "test://foo/123", + {"key": "foo", "value": 123}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert isinstance(content, str) + data = json.loads(content) + assert data == {"key": "foo", "value": 123} + + async def test_custom_type_conversion(self): + """Test handling of custom types.""" + + class CustomData: + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return self.value + + def get_data(value: str) -> CustomData: + return CustomData(value) + + template = ResourceTemplate.from_function( + fn=get_data, + uri_template="test://{value}", + name="test", + ) + + resource = await template.create_resource( + "test://hello", + {"value": "hello"}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "hello" diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py new file mode 100644 index 000000000..dddcd561f --- /dev/null +++ b/tests/server/fastmcp/resources/test_resources.py @@ -0,0 +1,100 @@ +import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp.resources import FunctionResource, Resource + + +class TestResourceValidation: + """Test base Resource validation.""" + + def test_resource_uri_validation(self): + """Test URI validation.""" + + def dummy_func() -> str: + return "data" + + # Valid URI + resource = FunctionResource( + uri=AnyUrl("http://example.com/data"), + name="test", + fn=dummy_func, + ) + assert str(resource.uri) == "http://example.com/data" + + # Missing protocol + with pytest.raises(ValueError, match="Input should be a valid URL"): + FunctionResource( + uri=AnyUrl("invalid"), + name="test", + fn=dummy_func, + ) + + # Missing host + with pytest.raises(ValueError, match="Input should be a valid URL"): + FunctionResource( + uri=AnyUrl("http://"), + name="test", + fn=dummy_func, + ) + + def test_resource_name_from_uri(self): + """Test name is extracted from URI if not provided.""" + + def dummy_func() -> str: + return "data" + + resource = FunctionResource( + uri=AnyUrl("resource://my-resource"), + fn=dummy_func, + ) + assert resource.name == "resource://my-resource" + + def test_resource_name_validation(self): + """Test name validation.""" + + def dummy_func() -> str: + return "data" + + # Must provide either name or URI + with pytest.raises(ValueError, match="Either name or uri must be provided"): + FunctionResource( + fn=dummy_func, + ) + + # Explicit name takes precedence over URI + resource = FunctionResource( + uri=AnyUrl("resource://uri-name"), + name="explicit-name", + fn=dummy_func, + ) + assert resource.name == "explicit-name" + + def test_resource_mime_type(self): + """Test mime type handling.""" + + def dummy_func() -> str: + return "data" + + # Default mime type + resource = FunctionResource( + uri=AnyUrl("resource://test"), + fn=dummy_func, + ) + assert resource.mime_type == "text/plain" + + # Custom mime type + resource = FunctionResource( + uri=AnyUrl("resource://test"), + fn=dummy_func, + mime_type="application/json", + ) + assert resource.mime_type == "application/json" + + async def test_resource_read_abstract(self): + """Test that Resource.read() is abstract.""" + + class ConcreteResource(Resource): + pass + + with pytest.raises(TypeError, match="abstract method"): + ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore diff --git a/tests/server/fastmcp/servers/__init__.py b/tests/server/fastmcp/servers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py new file mode 100644 index 000000000..d9b34d667 --- /dev/null +++ b/tests/server/fastmcp/servers/test_file_server.py @@ -0,0 +1,114 @@ +import json +from mcp.server.fastmcp import FastMCP +import pytest +from pathlib import Path + + +@pytest.fixture() +def test_dir(tmp_path_factory) -> Path: + """Create a temporary directory with test files.""" + tmp = tmp_path_factory.mktemp("test_files") + + # Create test files + (tmp / "example.py").write_text("print('hello world')") + (tmp / "readme.md").write_text("# Test Directory\nThis is a test.") + (tmp / "config.json").write_text('{"test": true}') + + return tmp + + +@pytest.fixture +def mcp() -> FastMCP: + mcp = FastMCP() + + return mcp + + +@pytest.fixture(autouse=True) +def resources(mcp: FastMCP, test_dir: Path) -> FastMCP: + @mcp.resource("dir://test_dir") + def list_test_dir() -> list[str]: + """List the files in the test directory""" + return [str(f) for f in test_dir.iterdir()] + + @mcp.resource("file://test_dir/example.py") + def read_example_py() -> str: + """Read the example.py file""" + try: + return (test_dir / "example.py").read_text() + except FileNotFoundError: + return "File not found" + + @mcp.resource("file://test_dir/readme.md") + def read_readme_md() -> str: + """Read the readme.md file""" + try: + return (test_dir / "readme.md").read_text() + except FileNotFoundError: + return "File not found" + + @mcp.resource("file://test_dir/config.json") + def read_config_json() -> str: + """Read the config.json file""" + try: + return (test_dir / "config.json").read_text() + except FileNotFoundError: + return "File not found" + + return mcp + + +@pytest.fixture(autouse=True) +def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: + @mcp.tool() + def delete_file(path: str) -> bool: + # ensure path is in test_dir + if Path(path).resolve().parent != test_dir: + raise ValueError(f"Path must be in test_dir: {path}") + Path(path).unlink() + return True + + return mcp + + +async def test_list_resources(mcp: FastMCP): + resources = await mcp.list_resources() + assert len(resources) == 4 + + assert [str(r.uri) for r in resources] == [ + "dir://test_dir", + "file://test_dir/example.py", + "file://test_dir/readme.md", + "file://test_dir/config.json", + ] + + +async def test_read_resource_dir(mcp: FastMCP): + files = await mcp.read_resource("dir://test_dir") + files = json.loads(files) + + assert sorted([Path(f).name for f in files]) == [ + "config.json", + "example.py", + "readme.md", + ] + + +async def test_read_resource_file(mcp: FastMCP): + result = await mcp.read_resource("file://test_dir/example.py") + assert result == "print('hello world')" + + +async def test_delete_file(mcp: FastMCP, test_dir: Path): + await mcp.call_tool( + "delete_file", arguments=dict(path=str(test_dir / "example.py")) + ) + assert not (test_dir / "example.py").exists() + + +async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): + await mcp.call_tool( + "delete_file", arguments=dict(path=str(test_dir / "example.py")) + ) + result = await mcp.read_resource("file://test_dir/example.py") + assert result == "File not found" diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py new file mode 100644 index 000000000..b329c900f --- /dev/null +++ b/tests/server/fastmcp/test_func_metadata.py @@ -0,0 +1,361 @@ +from typing import Annotated + +import annotated_types +import pytest +from pydantic import BaseModel, Field + +from mcp.server.fastmcp.utilities.func_metadata import func_metadata + + +class SomeInputModelA(BaseModel): + pass + + +class SomeInputModelB(BaseModel): + class InnerModel(BaseModel): + x: int + + how_many_shrimp: Annotated[int, Field(description="How many shrimp in the tank???")] + ok: InnerModel + y: None + + +def complex_arguments_fn( + an_int: int, + must_be_none: None, + must_be_none_dumb_annotation: Annotated[None, "blah"], + list_of_ints: list[int], + # list[str] | str is an interesting case because if it comes in as JSON like + # "[\"a\", \"b\"]" then it will be naively parsed as a string. + list_str_or_str: list[str] | str, + an_int_annotated_with_field: Annotated[ + int, Field(description="An int with a field") + ], + an_int_annotated_with_field_and_others: Annotated[ + int, + str, # Should be ignored, really + Field(description="An int with a field"), + annotated_types.Gt(1), + ], + an_int_annotated_with_junk: Annotated[ + int, + "123", + 456, + ], + field_with_default_via_field_annotation_before_nondefault_arg: Annotated[ + int, Field(1) + ], + unannotated, + my_model_a: SomeInputModelA, + my_model_a_forward_ref: "SomeInputModelA", + my_model_b: SomeInputModelB, + an_int_annotated_with_field_default: Annotated[ + int, + Field(1, description="An int with a field"), + ], + unannotated_with_default=5, + my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008 + an_int_with_default: int = 1, + must_be_none_with_default: None = None, + an_int_with_equals_field: int = Field(1, ge=0), + int_annotated_with_default: Annotated[int, Field(description="hey")] = 5, +) -> str: + _ = ( + an_int, + must_be_none, + must_be_none_dumb_annotation, + list_of_ints, + list_str_or_str, + an_int_annotated_with_field, + an_int_annotated_with_field_and_others, + an_int_annotated_with_junk, + field_with_default_via_field_annotation_before_nondefault_arg, + unannotated, + an_int_annotated_with_field_default, + unannotated_with_default, + my_model_a, + my_model_a_forward_ref, + my_model_b, + my_model_a_with_default, + an_int_with_default, + must_be_none_with_default, + an_int_with_equals_field, + int_annotated_with_default, + ) + return "ok!" + + +async def test_complex_function_runtime_arg_validation_non_json(): + """Test that basic non-JSON arguments are validated correctly""" + meta = func_metadata(complex_arguments_fn) + + # Test with minimum required arguments + result = await meta.call_fn_with_arg_validation( + complex_arguments_fn, + fn_is_async=False, + arguments_to_validate={ + "an_int": 1, + "must_be_none": None, + "must_be_none_dumb_annotation": None, + "list_of_ints": [1, 2, 3], + "list_str_or_str": "hello", + "an_int_annotated_with_field": 42, + "an_int_annotated_with_field_and_others": 5, + "an_int_annotated_with_junk": 100, + "unannotated": "test", + "my_model_a": {}, + "my_model_a_forward_ref": {}, + "my_model_b": {"how_many_shrimp": 5, "ok": {"x": 1}, "y": None}, + }, + arguments_to_pass_directly=None, + ) + assert result == "ok!" + + # Test with invalid types + with pytest.raises(ValueError): + await meta.call_fn_with_arg_validation( + complex_arguments_fn, + fn_is_async=False, + arguments_to_validate={"an_int": "not an int"}, + arguments_to_pass_directly=None, + ) + + +async def test_complex_function_runtime_arg_validation_with_json(): + """Test that JSON string arguments are parsed and validated correctly""" + meta = func_metadata(complex_arguments_fn) + + result = await meta.call_fn_with_arg_validation( + complex_arguments_fn, + fn_is_async=False, + arguments_to_validate={ + "an_int": 1, + "must_be_none": None, + "must_be_none_dumb_annotation": None, + "list_of_ints": "[1, 2, 3]", # JSON string + "list_str_or_str": '["a", "b", "c"]', # JSON string + "an_int_annotated_with_field": 42, + "an_int_annotated_with_field_and_others": "5", # JSON string + "an_int_annotated_with_junk": 100, + "unannotated": "test", + "my_model_a": "{}", # JSON string + "my_model_a_forward_ref": "{}", # JSON string + "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', # JSON string + }, + arguments_to_pass_directly=None, + ) + assert result == "ok!" + + +def test_str_vs_list_str(): + """Test handling of string vs list[str] type annotations. + + This is tricky as '"hello"' can be parsed as a JSON string or a Python string. + We want to make sure it's kept as a python string. + """ + + def func_with_str_types(str_or_list: str | list[str]): + return str_or_list + + meta = func_metadata(func_with_str_types) + + # Test string input for union type + result = meta.pre_parse_json({"str_or_list": "hello"}) + assert result["str_or_list"] == "hello" + + # Test string input that contains valid JSON for union type + # We want to see here that the JSON-vali string is NOT parsed as JSON, but rather + # kept as a raw string + result = meta.pre_parse_json({"str_or_list": '"hello"'}) + assert result["str_or_list"] == '"hello"' + + # Test list input for union type + result = meta.pre_parse_json({"str_or_list": '["hello", "world"]'}) + assert result["str_or_list"] == ["hello", "world"] + + +def test_skip_names(): + """Test that skipped parameters are not included in the model""" + + def func_with_many_params( + keep_this: int, skip_this: str, also_keep: float, also_skip: bool + ): + return keep_this, skip_this, also_keep, also_skip + + # Skip some parameters + meta = func_metadata(func_with_many_params, skip_names=["skip_this", "also_skip"]) + + # Check model fields + assert "keep_this" in meta.arg_model.model_fields + assert "also_keep" in meta.arg_model.model_fields + assert "skip_this" not in meta.arg_model.model_fields + assert "also_skip" not in meta.arg_model.model_fields + + # Validate that we can call with only non-skipped parameters + model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore + assert model.keep_this == 1 # type: ignore + assert model.also_keep == 2.5 # type: ignore + + +async def test_lambda_function(): + """Test lambda function schema and validation""" + fn = lambda x, y=5: x # noqa: E731 + meta = func_metadata(lambda x, y=5: x) + + # Test schema + assert meta.arg_model.model_json_schema() == { + "properties": { + "x": {"title": "x", "type": "string"}, + "y": {"default": 5, "title": "y", "type": "string"}, + }, + "required": ["x"], + "title": "Arguments", + "type": "object", + } + + async def check_call(args): + return await meta.call_fn_with_arg_validation( + fn, + fn_is_async=False, + arguments_to_validate=args, + arguments_to_pass_directly=None, + ) + + # Basic calls + assert await check_call({"x": "hello"}) == "hello" + assert await check_call({"x": "hello", "y": "world"}) == "hello" + assert await check_call({"x": '"hello"'}) == '"hello"' + + # Missing required arg + with pytest.raises(ValueError): + await check_call({"y": "world"}) + + +def test_complex_function_json_schema(): + meta = func_metadata(complex_arguments_fn) + assert meta.arg_model.model_json_schema() == { + "$defs": { + "InnerModel": { + "properties": {"x": {"title": "X", "type": "integer"}}, + "required": ["x"], + "title": "InnerModel", + "type": "object", + }, + "SomeInputModelA": { + "properties": {}, + "title": "SomeInputModelA", + "type": "object", + }, + "SomeInputModelB": { + "properties": { + "how_many_shrimp": { + "description": "How many shrimp in the tank???", + "title": "How Many Shrimp", + "type": "integer", + }, + "ok": {"$ref": "#/$defs/InnerModel"}, + "y": {"title": "Y", "type": "null"}, + }, + "required": ["how_many_shrimp", "ok", "y"], + "title": "SomeInputModelB", + "type": "object", + }, + }, + "properties": { + "an_int": {"title": "An Int", "type": "integer"}, + "must_be_none": {"title": "Must Be None", "type": "null"}, + "must_be_none_dumb_annotation": { + "title": "Must Be None Dumb Annotation", + "type": "null", + }, + "list_of_ints": { + "items": {"type": "integer"}, + "title": "List Of Ints", + "type": "array", + }, + "list_str_or_str": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "string"}, + ], + "title": "List Str Or Str", + }, + "an_int_annotated_with_field": { + "description": "An int with a field", + "title": "An Int Annotated With Field", + "type": "integer", + }, + "an_int_annotated_with_field_and_others": { + "description": "An int with a field", + "exclusiveMinimum": 1, + "title": "An Int Annotated With Field And Others", + "type": "integer", + }, + "an_int_annotated_with_junk": { + "title": "An Int Annotated With Junk", + "type": "integer", + }, + "field_with_default_via_field_annotation_before_nondefault_arg": { + "default": 1, + "title": "Field With Default Via Field Annotation Before Nondefault Arg", + "type": "integer", + }, + "unannotated": {"title": "unannotated", "type": "string"}, + "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, + "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, + "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, + "an_int_annotated_with_field_default": { + "default": 1, + "description": "An int with a field", + "title": "An Int Annotated With Field Default", + "type": "integer", + }, + "unannotated_with_default": { + "default": 5, + "title": "unannotated_with_default", + "type": "string", + }, + "my_model_a_with_default": { + "$ref": "#/$defs/SomeInputModelA", + "default": {}, + }, + "an_int_with_default": { + "default": 1, + "title": "An Int With Default", + "type": "integer", + }, + "must_be_none_with_default": { + "default": None, + "title": "Must Be None With Default", + "type": "null", + }, + "an_int_with_equals_field": { + "default": 1, + "minimum": 0, + "title": "An Int With Equals Field", + "type": "integer", + }, + "int_annotated_with_default": { + "default": 5, + "description": "hey", + "title": "Int Annotated With Default", + "type": "integer", + }, + }, + "required": [ + "an_int", + "must_be_none", + "must_be_none_dumb_annotation", + "list_of_ints", + "list_str_or_str", + "an_int_annotated_with_field", + "an_int_annotated_with_field_and_others", + "an_int_annotated_with_junk", + "unannotated", + "my_model_a", + "my_model_a_forward_ref", + "my_model_b", + ], + "title": "complex_arguments_fnArguments", + "type": "object", + } diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py new file mode 100644 index 000000000..27e13f28d --- /dev/null +++ b/tests/server/fastmcp/test_server.py @@ -0,0 +1,656 @@ +import base64 +from pathlib import Path +from typing import TYPE_CHECKING, Union + +import pytest +from mcp.shared.exceptions import McpError +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) +from mcp.types import ( + ImageContent, + TextContent, + TextResourceContents, + BlobResourceContents, +) +from pydantic import AnyUrl + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage +from mcp.server.fastmcp.resources import FileResource, FunctionResource +from mcp.server.fastmcp.utilities.types import Image + +if TYPE_CHECKING: + from mcp.server.fastmcp import Context + + +class TestServer: + async def test_create_server(self): + mcp = FastMCP() + assert mcp.name == "FastMCP" + + async def test_add_tool_decorator(self): + mcp = FastMCP() + + @mcp.tool() + def add(x: int, y: int) -> int: + return x + y + + assert len(mcp._tool_manager.list_tools()) == 1 + + async def test_add_tool_decorator_incorrect_usage(self): + mcp = FastMCP() + + with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"): + + @mcp.tool # Missing parentheses #type: ignore + def add(x: int, y: int) -> int: + return x + y + + async def test_add_resource_decorator(self): + mcp = FastMCP() + + @mcp.resource("r://{x}") + def get_data(x: str) -> str: + return f"Data: {x}" + + assert len(mcp._resource_manager._templates) == 1 + + async def test_add_resource_decorator_incorrect_usage(self): + mcp = FastMCP() + + with pytest.raises( + TypeError, match="The @resource decorator was used incorrectly" + ): + + @mcp.resource # Missing parentheses #type: ignore + def get_data(x: str) -> str: + return f"Data: {x}" + + +def tool_fn(x: int, y: int) -> int: + return x + y + + +def error_tool_fn() -> None: + raise ValueError("Test error") + + +def image_tool_fn(path: str) -> Image: + return Image(path) + + +def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]: + return [ + TextContent(type="text", text="Hello"), + ImageContent(type="image", data="abc", mimeType="image/png"), + ] + + +class TestServerTools: + async def test_add_tool(self): + mcp = FastMCP() + mcp.add_tool(tool_fn) + mcp.add_tool(tool_fn) + assert len(mcp._tool_manager.list_tools()) == 1 + + async def test_list_tools(self): + mcp = FastMCP() + mcp.add_tool(tool_fn) + async with client_session(mcp._mcp_server) as client: + tools = await client.list_tools() + assert len(tools.tools) == 1 + + async def test_call_tool(self): + mcp = FastMCP() + mcp.add_tool(tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("my_tool", {"arg1": "value"}) + assert not hasattr(result, "error") + assert len(result.content) > 0 + + async def test_tool_exception_handling(self): + mcp = FastMCP() + mcp.add_tool(error_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("error_tool_fn", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Test error" in content.text + assert result.isError is True + + async def test_tool_error_handling(self): + mcp = FastMCP() + mcp.add_tool(error_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("error_tool_fn", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Test error" in content.text + assert result.isError is True + + async def test_tool_error_details(self): + """Test that exception details are properly formatted in the response""" + mcp = FastMCP() + mcp.add_tool(error_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("error_tool_fn", {}) + content = result.content[0] + assert isinstance(content, TextContent) + assert isinstance(content.text, str) + assert "Test error" in content.text + assert result.isError is True + + async def test_tool_return_value_conversion(self): + mcp = FastMCP() + mcp.add_tool(tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "3" + + async def test_tool_image_helper(self, tmp_path: Path): + # Create a test image + image_path = tmp_path / "test.png" + image_path.write_bytes(b"fake png data") + + mcp = FastMCP() + mcp.add_tool(image_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("image_tool_fn", {"path": str(image_path)}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, ImageContent) + assert content.type == "image" + assert content.mimeType == "image/png" + # Verify base64 encoding + decoded = base64.b64decode(content.data) + assert decoded == b"fake png data" + + async def test_tool_mixed_content(self): + mcp = FastMCP() + mcp.add_tool(mixed_content_tool_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("mixed_content_tool_fn", {}) + assert len(result.content) == 2 + content1 = result.content[0] + content2 = result.content[1] + assert isinstance(content1, TextContent) + assert content1.text == "Hello" + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert content2.data == "abc" + + async def test_tool_mixed_list_with_image(self, tmp_path: Path): + """Test that lists containing Image objects and other types are handled correctly""" + # Create a test image + image_path = tmp_path / "test.png" + image_path.write_bytes(b"test image data") + + def mixed_list_fn() -> list: + return [ + "text message", + Image(image_path), + {"key": "value"}, + TextContent(type="text", text="direct content"), + ] + + mcp = FastMCP() + mcp.add_tool(mixed_list_fn) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("mixed_list_fn", {}) + assert len(result.content) == 4 + # Check text conversion + content1 = result.content[0] + assert isinstance(content1, TextContent) + assert content1.text == "text message" + # Check image conversion + content2 = result.content[1] + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert base64.b64decode(content2.data) == b"test image data" + # Check dict conversion + content3 = result.content[2] + assert isinstance(content3, TextContent) + assert '"key": "value"' in content3.text + # Check direct TextContent + content4 = result.content[3] + assert isinstance(content4, TextContent) + assert content4.text == "direct content" + + +class TestServerResources: + async def test_text_resource(self): + mcp = FastMCP() + + def get_text(): + return "Hello, world!" + + resource = FunctionResource( + uri=AnyUrl("resource://test"), name="test", fn=get_text + ) + mcp.add_resource(resource) + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://test")) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Hello, world!" + + async def test_binary_resource(self): + mcp = FastMCP() + + def get_binary(): + return b"Binary data" + + resource = FunctionResource( + uri=AnyUrl("resource://binary"), + name="binary", + fn=get_binary, + mime_type="application/octet-stream", + ) + mcp.add_resource(resource) + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://binary")) + assert isinstance(result.contents[0], BlobResourceContents) + assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() + + async def test_file_resource_text(self, tmp_path: Path): + mcp = FastMCP() + + # Create a text file + text_file = tmp_path / "test.txt" + text_file.write_text("Hello from file!") + + resource = FileResource( + uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file + ) + mcp.add_resource(resource) + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("file://test.txt")) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Hello from file!" + + async def test_file_resource_binary(self, tmp_path: Path): + mcp = FastMCP() + + # Create a binary file + binary_file = tmp_path / "test.bin" + binary_file.write_bytes(b"Binary file data") + + resource = FileResource( + uri=AnyUrl("file://test.bin"), + name="test.bin", + path=binary_file, + mime_type="application/octet-stream", + ) + mcp.add_resource(resource) + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("file://test.bin")) + assert isinstance(result.contents[0], BlobResourceContents) + assert ( + result.contents[0].blob + == base64.b64encode(b"Binary file data").decode() + ) + + +class TestServerResourceTemplates: + async def test_resource_with_params(self): + """Test that a resource with function parameters raises an error if the URI + parameters don't match""" + mcp = FastMCP() + + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://data") + def get_data_fn(param: str) -> str: + return f"Data: {param}" + + async def test_resource_with_uri_params(self): + """Test that a resource with URI parameters is automatically a template""" + mcp = FastMCP() + + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://{param}") + def get_data() -> str: + return "Data" + + async def test_resource_with_untyped_params(self): + """Test that a resource with untyped parameters raises an error""" + mcp = FastMCP() + + @mcp.resource("resource://{param}") + def get_data(param) -> str: + return "Data" + + async def test_resource_matching_params(self): + """Test that a resource with matching URI and function parameters works""" + mcp = FastMCP() + + @mcp.resource("resource://{name}/data") + def get_data(name: str) -> str: + return f"Data for {name}" + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://test/data")) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Data for test" + + async def test_resource_mismatched_params(self): + """Test that mismatched parameters raise an error""" + mcp = FastMCP() + + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://{name}/data") + def get_data(user: str) -> str: + return f"Data for {user}" + + async def test_resource_multiple_params(self): + """Test that multiple parameters work correctly""" + mcp = FastMCP() + + @mcp.resource("resource://{org}/{repo}/data") + def get_data(org: str, repo: str) -> str: + return f"Data for {org}/{repo}" + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource( + AnyUrl("resource://cursor/fastmcp/data") + ) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Data for cursor/fastmcp" + + async def test_resource_multiple_mismatched_params(self): + """Test that mismatched parameters raise an error""" + mcp = FastMCP() + + with pytest.raises(ValueError, match="Mismatch between URI parameters"): + + @mcp.resource("resource://{org}/{repo}/data") + def get_data_mismatched(org: str, repo_2: str) -> str: + return f"Data for {org}" + + """Test that a resource with no parameters works as a regular resource""" + mcp = FastMCP() + + @mcp.resource("resource://static") + def get_static_data() -> str: + return "Static data" + + async with client_session(mcp._mcp_server) as client: + result = await client.read_resource(AnyUrl("resource://static")) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "Static data" + + async def test_template_to_resource_conversion(self): + """Test that templates are properly converted to resources when accessed""" + mcp = FastMCP() + + @mcp.resource("resource://{name}/data") + def get_data(name: str) -> str: + return f"Data for {name}" + + # Should be registered as a template + assert len(mcp._resource_manager._templates) == 1 + assert len(await mcp.list_resources()) == 0 + + # When accessed, should create a concrete resource + resource = await mcp._resource_manager.get_resource("resource://test/data") + assert isinstance(resource, FunctionResource) + result = await resource.read() + assert result == "Data for test" + + +class TestContextInjection: + """Test context injection in tools.""" + + async def test_context_detection(self): + """Test that context parameters are properly detected.""" + mcp = FastMCP() + + def tool_with_context(x: int, ctx: Context) -> str: + return f"Request {ctx.request_id}: {x}" + + tool = mcp._tool_manager.add_tool(tool_with_context) + assert tool.context_kwarg == "ctx" + + async def test_context_injection(self): + """Test that context is properly injected into tool calls.""" + mcp = FastMCP() + + def tool_with_context(x: int, ctx: Context) -> str: + assert ctx.request_id is not None + return f"Request {ctx.request_id}: {x}" + + mcp.add_tool(tool_with_context) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_with_context", {"x": 42}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Request" in content.text + assert "42" in content.text + + async def test_async_context(self): + """Test that context works in async functions.""" + mcp = FastMCP() + + async def async_tool(x: int, ctx: Context) -> str: + assert ctx.request_id is not None + return f"Async request {ctx.request_id}: {x}" + + mcp.add_tool(async_tool) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("async_tool", {"x": 42}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Async request" in content.text + assert "42" in content.text + + async def test_context_logging(self): + """Test that context logging methods work.""" + mcp = FastMCP() + + def logging_tool(msg: str, ctx: Context) -> str: + ctx.debug("Debug message") + ctx.info("Info message") + ctx.warning("Warning message") + ctx.error("Error message") + return f"Logged messages for {msg}" + + mcp.add_tool(logging_tool) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("logging_tool", {"msg": "test"}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Logged messages for test" in content.text + + async def test_optional_context(self): + """Test that context is optional.""" + mcp = FastMCP() + + def no_context(x: int) -> int: + return x * 2 + + mcp.add_tool(no_context) + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("no_context", {"x": 21}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == "42" + + async def test_context_resource_access(self): + """Test that context can access resources.""" + mcp = FastMCP() + + @mcp.resource("test://data") + def test_resource() -> str: + return "resource data" + + @mcp.tool() + async def tool_with_resource(ctx: Context) -> str: + data = await ctx.read_resource("test://data") + return f"Read resource: {data}" + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("tool_with_resource", {}) + assert len(result.content) == 1 + content = result.content[0] + assert isinstance(content, TextContent) + assert "Read resource: resource data" in content.text + + +class TestServerPrompts: + """Test prompt functionality in FastMCP server.""" + + async def test_prompt_decorator(self): + """Test that the prompt decorator registers prompts correctly.""" + mcp = FastMCP() + + @mcp.prompt() + def fn() -> str: + return "Hello, world!" + + prompts = mcp._prompt_manager.list_prompts() + assert len(prompts) == 1 + assert prompts[0].name == "fn" + # Don't compare functions directly since validate_call wraps them + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" + + async def test_prompt_decorator_with_name(self): + """Test prompt decorator with custom name.""" + mcp = FastMCP() + + @mcp.prompt(name="custom_name") + def fn() -> str: + return "Hello, world!" + + prompts = mcp._prompt_manager.list_prompts() + assert len(prompts) == 1 + assert prompts[0].name == "custom_name" + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" + + async def test_prompt_decorator_with_description(self): + """Test prompt decorator with custom description.""" + mcp = FastMCP() + + @mcp.prompt(description="A custom description") + def fn() -> str: + return "Hello, world!" + + prompts = mcp._prompt_manager.list_prompts() + assert len(prompts) == 1 + assert prompts[0].description == "A custom description" + content = await prompts[0].render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" + + def test_prompt_decorator_error(self): + """Test error when decorator is used incorrectly.""" + mcp = FastMCP() + with pytest.raises(TypeError, match="decorator was used incorrectly"): + + @mcp.prompt # type: ignore + def fn() -> str: + return "Hello, world!" + + async def test_list_prompts(self): + """Test listing prompts through MCP protocol.""" + mcp = FastMCP() + + @mcp.prompt() + def fn(name: str, optional: str = "default") -> str: + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + result = await client.list_prompts() + assert result.prompts is not None + assert len(result.prompts) == 1 + prompt = result.prompts[0] + assert prompt.name == "fn" + assert prompt.arguments is not None + assert len(prompt.arguments) == 2 + assert prompt.arguments[0].name == "name" + assert prompt.arguments[0].required is True + assert prompt.arguments[1].name == "optional" + assert prompt.arguments[1].required is False + + async def test_get_prompt(self): + """Test getting a prompt through MCP protocol.""" + mcp = FastMCP() + + @mcp.prompt() + def fn(name: str) -> str: + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("fn", {"name": "World"}) + assert len(result.messages) == 1 + message = result.messages[0] + assert message.role == "user" + content = message.content + assert isinstance(content, TextContent) + assert content.text == "Hello, World!" + + async def test_get_prompt_with_resource(self): + """Test getting a prompt that returns resource content.""" + mcp = FastMCP() + + @mcp.prompt() + def fn() -> Message: + return UserMessage( + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=AnyUrl("file://file.txt"), + text="File contents", + mimeType="text/plain", + ), + ) + ) + + async with client_session(mcp._mcp_server) as client: + result = await client.get_prompt("fn") + assert len(result.messages) == 1 + message = result.messages[0] + assert message.role == "user" + content = message.content + assert isinstance(content, EmbeddedResource) + resource = content.resource + assert isinstance(resource, TextResourceContents) + assert resource.text == "File contents" + assert resource.mimeType == "text/plain" + + async def test_get_unknown_prompt(self): + """Test error when getting unknown prompt.""" + mcp = FastMCP() + async with client_session(mcp._mcp_server) as client: + with pytest.raises(McpError, match="Unknown prompt"): + await client.get_prompt("unknown") + + async def test_get_prompt_missing_args(self): + """Test error when required arguments are missing.""" + mcp = FastMCP() + + @mcp.prompt() + def prompt_fn(name: str) -> str: + return f"Hello, {name}!" + + async with client_session(mcp._mcp_server) as client: + with pytest.raises(McpError, match="Missing required arguments"): + await client.get_prompt("prompt_fn") diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py new file mode 100644 index 000000000..884059a63 --- /dev/null +++ b/tests/server/fastmcp/test_tool_manager.py @@ -0,0 +1,306 @@ +import logging +from typing import Optional + +import pytest +from pydantic import BaseModel +import json +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.tools import ToolManager + + +class TestAddTools: + def test_basic_function(self): + """Test registering and running a basic function.""" + + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + + tool = manager.get_tool("add") + assert tool is not None + assert tool.name == "add" + assert tool.description == "Add two numbers." + assert tool.is_async is False + assert tool.parameters["properties"]["a"]["type"] == "integer" + assert tool.parameters["properties"]["b"]["type"] == "integer" + + async def test_async_function(self): + """Test registering and running an async function.""" + + async def fetch_data(url: str) -> str: + """Fetch data from URL.""" + return f"Data from {url}" + + manager = ToolManager() + manager.add_tool(fetch_data) + + tool = manager.get_tool("fetch_data") + assert tool is not None + assert tool.name == "fetch_data" + assert tool.description == "Fetch data from URL." + assert tool.is_async is True + assert tool.parameters["properties"]["url"]["type"] == "string" + + def test_pydantic_model_function(self): + """Test registering a function that takes a Pydantic model.""" + + class UserInput(BaseModel): + name: str + age: int + + def create_user(user: UserInput, flag: bool) -> dict: + """Create a new user.""" + return {"id": 1, **user.model_dump()} + + manager = ToolManager() + manager.add_tool(create_user) + + tool = manager.get_tool("create_user") + assert tool is not None + assert tool.name == "create_user" + assert tool.description == "Create a new user." + assert tool.is_async is False + assert "name" in tool.parameters["$defs"]["UserInput"]["properties"] + assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] + assert "flag" in tool.parameters["properties"] + + def test_add_invalid_tool(self): + manager = ToolManager() + with pytest.raises(AttributeError): + manager.add_tool(1) # type: ignore + + def test_add_lambda(self): + manager = ToolManager() + tool = manager.add_tool(lambda x: x, name="my_tool") + assert tool.name == "my_tool" + + def test_add_lambda_with_no_name(self): + manager = ToolManager() + with pytest.raises( + ValueError, match="You must provide a name for lambda functions" + ): + manager.add_tool(lambda x: x) + + def test_warn_on_duplicate_tools(self, caplog): + """Test warning on duplicate tools.""" + + def f(x: int) -> int: + return x + + manager = ToolManager() + manager.add_tool(f) + with caplog.at_level(logging.WARNING): + manager.add_tool(f) + assert "Tool already exists: f" in caplog.text + + def test_disable_warn_on_duplicate_tools(self, caplog): + """Test disabling warning on duplicate tools.""" + + def f(x: int) -> int: + return x + + manager = ToolManager() + manager.add_tool(f) + manager.warn_on_duplicate_tools = False + with caplog.at_level(logging.WARNING): + manager.add_tool(f) + assert "Tool already exists: f" not in caplog.text + + +class TestCallTools: + async def test_call_tool(self): + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + result = await manager.call_tool("add", {"a": 1, "b": 2}) + assert result == 3 + + async def test_call_async_tool(self): + async def double(n: int) -> int: + """Double a number.""" + return n * 2 + + manager = ToolManager() + manager.add_tool(double) + result = await manager.call_tool("double", {"n": 5}) + assert result == 10 + + async def test_call_tool_with_default_args(self): + def add(a: int, b: int = 1) -> int: + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + result = await manager.call_tool("add", {"a": 1}) + assert result == 2 + + async def test_call_tool_with_missing_args(self): + def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + manager = ToolManager() + manager.add_tool(add) + with pytest.raises(ToolError): + await manager.call_tool("add", {"a": 1}) + + async def test_call_unknown_tool(self): + manager = ToolManager() + with pytest.raises(ToolError): + await manager.call_tool("unknown", {"a": 1}) + + async def test_call_tool_with_list_int_input(self): + def sum_vals(vals: list[int]) -> int: + return sum(vals) + + manager = ToolManager() + manager.add_tool(sum_vals) + # Try both with plain list and with JSON list + result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}) + assert result == 6 + result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) + assert result == 6 + + async def test_call_tool_with_list_str_or_str_input(self): + def concat_strs(vals: list[str] | str) -> str: + return vals if isinstance(vals, str) else "".join(vals) + + manager = ToolManager() + manager.add_tool(concat_strs) + # Try both with plain python object and with JSON list + result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) + assert result == "abc" + result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}) + assert result == "abc" + result = await manager.call_tool("concat_strs", {"vals": "a"}) + assert result == "a" + result = await manager.call_tool("concat_strs", {"vals": '"a"'}) + assert result == '"a"' + + async def test_call_tool_with_complex_model(self): + from mcp.server.fastmcp import Context + + class MyShrimpTank(BaseModel): + class Shrimp(BaseModel): + name: str + + shrimp: list[Shrimp] + x: None + + def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]: + return [x.name for x in tank.shrimp] + + manager = ToolManager() + manager.add_tool(name_shrimp) + result = await manager.call_tool( + "name_shrimp", + {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}}, + ) + assert result == ["rex", "gertrude"] + result = await manager.call_tool( + "name_shrimp", + {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'}, + ) + assert result == ["rex", "gertrude"] + + +class TestToolSchema: + async def test_context_arg_excluded_from_schema(self): + from mcp.server.fastmcp import Context + + def something(a: int, ctx: Context) -> int: + return a + + manager = ToolManager() + tool = manager.add_tool(something) + assert "ctx" not in json.dumps(tool.parameters) + assert "Context" not in json.dumps(tool.parameters) + assert "ctx" not in tool.fn_metadata.arg_model.model_fields + + +class TestContextHandling: + """Test context handling in the tool manager.""" + + def test_context_parameter_detection(self): + """Test that context parameters are properly detected in Tool.from_function().""" + from mcp.server.fastmcp import Context + + def tool_with_context(x: int, ctx: Context) -> str: + return str(x) + + manager = ToolManager() + tool = manager.add_tool(tool_with_context) + assert tool.context_kwarg == "ctx" + + def tool_without_context(x: int) -> str: + return str(x) + + tool = manager.add_tool(tool_without_context) + assert tool.context_kwarg is None + + async def test_context_injection(self): + """Test that context is properly injected during tool execution.""" + from mcp.server.fastmcp import Context, FastMCP + + def tool_with_context(x: int, ctx: Context) -> str: + assert isinstance(ctx, Context) + return str(x) + + manager = ToolManager() + manager.add_tool(tool_with_context) + + mcp = FastMCP() + ctx = mcp.get_context() + result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) + assert result == "42" + + async def test_context_injection_async(self): + """Test that context is properly injected in async tools.""" + from mcp.server.fastmcp import Context, FastMCP + + async def async_tool(x: int, ctx: Context) -> str: + assert isinstance(ctx, Context) + return str(x) + + manager = ToolManager() + manager.add_tool(async_tool) + + mcp = FastMCP() + ctx = mcp.get_context() + result = await manager.call_tool("async_tool", {"x": 42}, context=ctx) + assert result == "42" + + async def test_context_optional(self): + """Test that context is optional when calling tools.""" + from mcp.server.fastmcp import Context + + def tool_with_context(x: int, ctx: Optional[Context] = None) -> str: + return str(x) + + manager = ToolManager() + manager.add_tool(tool_with_context) + # Should not raise an error when context is not provided + result = await manager.call_tool("tool_with_context", {"x": 42}) + assert result == "42" + + async def test_context_error_handling(self): + """Test error handling when context injection fails.""" + from mcp.server.fastmcp import Context, FastMCP + + def tool_with_context(x: int, ctx: Context) -> str: + raise ValueError("Test error") + + manager = ToolManager() + manager.add_tool(tool_with_context) + + mcp = FastMCP() + ctx = mcp.get_context() + with pytest.raises(ToolError, match="Error executing tool tool_with_context"): + await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) diff --git a/tests/server/test_session.py b/tests/server/test_session.py index a78ca90ef..ead18f7ae 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -2,7 +2,8 @@ import pytest from mcp.client.session import ClientSession -from mcp.server import NotificationOptions, Server +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.types import ( diff --git a/uv.lock b/uv.lock index db4dbc7f6..18009ba8f 100644 --- a/uv.lock +++ b/uv.lock @@ -38,20 +38,20 @@ wheels = [ [[package]] name = "attrs" -version = "24.2.0" +version = "24.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, + { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, ] [[package]] name = "certifi" -version = "2024.8.30" +version = "2024.12.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, ] [[package]] @@ -103,6 +103,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -168,6 +177,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + [[package]] name = "mcp" version = "1.1.2.dev0" @@ -177,14 +198,23 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "sse-starlette" }, { name = "starlette" }, ] +[package.optional-dependencies] +rich = [ + { name = "rich" }, +] + [package.dev-dependencies] dev = [ { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-flakefinder" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "trio" }, ] @@ -194,7 +224,9 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, - { name = "pydantic", specifier = ">=2.7.2" }, + { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, ] @@ -203,6 +235,9 @@ requires-dist = [ dev = [ { name = "pyright", specifier = ">=1.1.378" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.6.9" }, { name = "trio", specifier = ">=0.26.2" }, ] @@ -306,6 +341,15 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -425,6 +469,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/d1/1c18f8e215930665e65597dd677937595355057f631bf4b9110aa6f88f79/pydantic_core-2.18.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0037a92cf0c580ed14e10953cdd26528e8796307bb8bb312dc65f71547df04d", size = 1898163 }, ] +[[package]] +name = "pydantic-settings" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + [[package]] name = "pyright" version = "1.1.378" @@ -454,6 +520,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + +[[package]] +name = "pytest-flakefinder" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + [[package]] name = "ruff" version = "0.6.9" From fe75f43ee6a6dfcb25f4f8b3c35c2745b491f4ac Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 18 Dec 2024 14:02:51 +0000 Subject: [PATCH 02/20] fix: Update test to match Pydantic's JSON schema output for model references with defaults Pydantic now uses allOf to combine model references with default values in JSON schema output. --- tests/server/fastmcp/test_func_metadata.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index b329c900f..044798d2e 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -316,7 +316,11 @@ def test_complex_function_json_schema(): "type": "string", }, "my_model_a_with_default": { - "$ref": "#/$defs/SomeInputModelA", + "allOf": [ + { + "$ref": "#/$defs/SomeInputModelA" + } + ], "default": {}, }, "an_int_with_default": { From 15cd7df87fb94a7919b568d5db0d99e16b324885 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 18 Dec 2024 14:31:08 +0000 Subject: [PATCH 03/20] refactor: Update FastMCP examples to use new import path Update all FastMCP examples to use mcp.server.fastmcp instead of fastmcp. Add tests to verify example servers work correctly. Changes: - Update import paths in all example files - Create test_examples.py to verify functionality - Fix test assertions to match actual response format --- examples/fastmcp/complex_inputs.py | 28 +++ examples/fastmcp/desktop.py | 25 ++ examples/fastmcp/echo.py | 30 +++ examples/fastmcp/memory.py | 346 ++++++++++++++++++++++++++ examples/fastmcp/readme-quickstart.py | 19 ++ examples/fastmcp/screenshot.py | 29 +++ examples/fastmcp/simple_echo.py | 15 ++ examples/fastmcp/text_me.py | 71 ++++++ tests/test_examples.py | 50 ++++ 9 files changed, 613 insertions(+) create mode 100644 examples/fastmcp/complex_inputs.py create mode 100644 examples/fastmcp/desktop.py create mode 100644 examples/fastmcp/echo.py create mode 100644 examples/fastmcp/memory.py create mode 100644 examples/fastmcp/readme-quickstart.py create mode 100644 examples/fastmcp/screenshot.py create mode 100644 examples/fastmcp/simple_echo.py create mode 100644 examples/fastmcp/text_me.py create mode 100644 tests/test_examples.py diff --git a/examples/fastmcp/complex_inputs.py b/examples/fastmcp/complex_inputs.py new file mode 100644 index 000000000..48aacbe95 --- /dev/null +++ b/examples/fastmcp/complex_inputs.py @@ -0,0 +1,28 @@ +""" +FastMCP Complex inputs Example + +Demonstrates validation via pydantic with complex models. +""" + +from pydantic import BaseModel, Field +from typing import Annotated +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Shrimp Tank") + + +class ShrimpTank(BaseModel): + class Shrimp(BaseModel): + name: Annotated[str, Field(max_length=10)] + + shrimp: list[Shrimp] + + +@mcp.tool() +def name_shrimp( + tank: ShrimpTank, + # You can use pydantic Field in function signatures for validation. + extra_names: Annotated[list[str], Field(max_length=10)], +) -> list[str]: + """List all shrimp names in the tank""" + return [shrimp.name for shrimp in tank.shrimp] + extra_names diff --git a/examples/fastmcp/desktop.py b/examples/fastmcp/desktop.py new file mode 100644 index 000000000..8fd71b263 --- /dev/null +++ b/examples/fastmcp/desktop.py @@ -0,0 +1,25 @@ +""" +FastMCP Desktop Example + +A simple example that exposes the desktop directory as a resource. +""" + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Demo") + + +@mcp.resource("dir://desktop") +def desktop() -> list[str]: + """List the files in the user's desktop""" + desktop = Path.home() / "Desktop" + return [str(f) for f in desktop.iterdir()] + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b diff --git a/examples/fastmcp/echo.py b/examples/fastmcp/echo.py new file mode 100644 index 000000000..7bdbcdce6 --- /dev/null +++ b/examples/fastmcp/echo.py @@ -0,0 +1,30 @@ +""" +FastMCP Echo Server +""" + +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Echo Server") + + +@mcp.tool() +def echo_tool(text: str) -> str: + """Echo the input text""" + return text + + +@mcp.resource("echo://static") +def echo_resource() -> str: + return "Echo!" + + +@mcp.resource("echo://{text}") +def echo_template(text: str) -> str: + """Echo the input text""" + return f"Echo: {text}" + + +@mcp.prompt("echo") +def echo_prompt(text: str) -> str: + return text diff --git a/examples/fastmcp/memory.py b/examples/fastmcp/memory.py new file mode 100644 index 000000000..6e24da21f --- /dev/null +++ b/examples/fastmcp/memory.py @@ -0,0 +1,346 @@ +# /// script +# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"] +# /// + +# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp + +""" +Recursive memory system inspired by the human brain's clustering of memories. +Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search. +""" + +import asyncio +import math +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Self + +import asyncpg +import numpy as np +from openai import AsyncOpenAI +from pgvector.asyncpg import register_vector # Import register_vector +from pydantic import BaseModel, Field +from pydantic_ai import Agent + +from mcp.server.fastmcp import FastMCP + +MAX_DEPTH = 5 +SIMILARITY_THRESHOLD = 0.7 +DECAY_FACTOR = 0.99 +REINFORCEMENT_FACTOR = 1.1 + +DEFAULT_LLM_MODEL = "openai:gpt-4o" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" + +mcp = FastMCP( + "memory", + dependencies=[ + "pydantic-ai-slim[openai]", + "asyncpg", + "numpy", + "pgvector", + ], +) + +DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" +# reset memory with rm ~/.fastmcp/{USER}/memory/* +PROFILE_DIR = ( + Path.home() / ".fastmcp" / os.environ.get("USER", "anon") / "memory" +).resolve() +PROFILE_DIR.mkdir(parents=True, exist_ok=True) + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + a_array = np.array(a, dtype=np.float64) + b_array = np.array(b, dtype=np.float64) + return np.dot(a_array, b_array) / ( + np.linalg.norm(a_array) * np.linalg.norm(b_array) + ) + + +async def do_ai[T]( + user_prompt: str, + system_prompt: str, + result_type: type[T] | Annotated, + deps=None, +) -> T: + agent = Agent( + DEFAULT_LLM_MODEL, + system_prompt=system_prompt, + result_type=result_type, + ) + result = await agent.run(user_prompt, deps=deps) + return result.data + + +@dataclass +class Deps: + openai: AsyncOpenAI + pool: asyncpg.Pool + + +async def get_db_pool() -> asyncpg.Pool: + async def init(conn): + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + await register_vector(conn) + + pool = await asyncpg.create_pool(DB_DSN, init=init) + return pool + + +class MemoryNode(BaseModel): + id: int | None = None + content: str + summary: str = "" + importance: float = 1.0 + access_count: int = 0 + timestamp: float = Field( + default_factory=lambda: datetime.now(timezone.utc).timestamp() + ) + embedding: list[float] + + @classmethod + async def from_content(cls, content: str, deps: Deps): + embedding = await get_embedding(content, deps) + return cls(content=content, embedding=embedding) + + async def save(self, deps: Deps): + async with deps.pool.acquire() as conn: + if self.id is None: + result = await conn.fetchrow( + """ + INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + ) + self.id = result["id"] + else: + await conn.execute( + """ + UPDATE memories + SET content = $1, summary = $2, importance = $3, + access_count = $4, timestamp = $5, embedding = $6 + WHERE id = $7 + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + self.id, + ) + + async def merge_with(self, other: Self, deps: Deps): + self.content = await do_ai( + f"{self.content}\n\n{other.content}", + "Combine the following two texts into a single, coherent text.", + str, + deps, + ) + self.importance += other.importance + self.access_count += other.access_count + self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] + self.summary = await do_ai( + self.content, "Summarize the following text concisely.", str, deps + ) + await self.save(deps) + # Delete the merged node from the database + if other.id is not None: + await delete_memory(other.id, deps) + + def get_effective_importance(self): + return self.importance * (1 + math.log(self.access_count + 1)) + + +async def get_embedding(text: str, deps: Deps) -> list[float]: + embedding_response = await deps.openai.embeddings.create( + input=text, + model=DEFAULT_EMBEDDING_MODEL, + ) + return embedding_response.data[0].embedding + + +async def delete_memory(memory_id: int, deps: Deps): + async with deps.pool.acquire() as conn: + await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) + + +async def add_memory(content: str, deps: Deps): + new_memory = await MemoryNode.from_content(content, deps) + await new_memory.save(deps) + + similar_memories = await find_similar_memories(new_memory.embedding, deps) + for memory in similar_memories: + if memory.id != new_memory.id: + await new_memory.merge_with(memory, deps) + + await update_importance(new_memory.embedding, deps) + + await prune_memories(deps) + + return f"Remembered: {content}" + + +async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, content, summary, importance, access_count, timestamp, embedding + FROM memories + ORDER BY embedding <-> $1 + LIMIT 5 + """, + embedding, + ) + memories = [ + MemoryNode( + id=row["id"], + content=row["content"], + summary=row["summary"], + importance=row["importance"], + access_count=row["access_count"], + timestamp=row["timestamp"], + embedding=row["embedding"], + ) + for row in rows + ] + return memories + + +async def update_importance(user_embedding: list[float], deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, importance, access_count, embedding FROM memories" + ) + for row in rows: + memory_embedding = row["embedding"] + similarity = cosine_similarity(user_embedding, memory_embedding) + if similarity > SIMILARITY_THRESHOLD: + new_importance = row["importance"] * REINFORCEMENT_FACTOR + new_access_count = row["access_count"] + 1 + else: + new_importance = row["importance"] * DECAY_FACTOR + new_access_count = row["access_count"] + await conn.execute( + """ + UPDATE memories + SET importance = $1, access_count = $2 + WHERE id = $3 + """, + new_importance, + new_access_count, + row["id"], + ) + + +async def prune_memories(deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, importance, access_count + FROM memories + ORDER BY importance DESC + OFFSET $1 + """, + MAX_DEPTH, + ) + for row in rows: + await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) + + +async def display_memory_tree(deps: Deps) -> str: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT content, summary, importance, access_count + FROM memories + ORDER BY importance DESC + LIMIT $1 + """, + MAX_DEPTH, + ) + result = "" + for row in rows: + effective_importance = row["importance"] * ( + 1 + math.log(row["access_count"] + 1) + ) + summary = row["summary"] or row["content"] + result += f"- {summary} (Importance: {effective_importance:.2f})\n" + return result + + +@mcp.tool() +async def remember( + contents: list[str] = Field( + description="List of observations or memories to store" + ), +): + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + try: + return "\n".join( + await asyncio.gather(*[add_memory(content, deps) for content in contents]) + ) + finally: + await deps.pool.close() + + +@mcp.tool() +async def read_profile() -> str: + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + profile = await display_memory_tree(deps) + await deps.pool.close() + return profile + + +async def initialize_database(): + pool = await asyncpg.create_pool( + "postgresql://postgres:postgres@localhost:54320/postgres" + ) + try: + async with pool.acquire() as conn: + await conn.execute(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = 'memory_db' + AND pid <> pg_backend_pid(); + """) + await conn.execute("DROP DATABASE IF EXISTS memory_db;") + await conn.execute("CREATE DATABASE memory_db;") + finally: + await pool.close() + + pool = await asyncpg.create_pool(DB_DSN) + try: + async with pool.acquire() as conn: + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + + await register_vector(conn) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS memories ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + summary TEXT, + importance REAL NOT NULL, + access_count INT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + embedding vector(1536) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops); + """) + finally: + await pool.close() + + +if __name__ == "__main__": + asyncio.run(initialize_database()) diff --git a/examples/fastmcp/readme-quickstart.py b/examples/fastmcp/readme-quickstart.py new file mode 100644 index 000000000..6c8e27529 --- /dev/null +++ b/examples/fastmcp/readme-quickstart.py @@ -0,0 +1,19 @@ +from mcp.server.fastmcp import FastMCP + + +# Create an MCP server +mcp = FastMCP("Demo") + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" diff --git a/examples/fastmcp/screenshot.py b/examples/fastmcp/screenshot.py new file mode 100644 index 000000000..34c9a6155 --- /dev/null +++ b/examples/fastmcp/screenshot.py @@ -0,0 +1,29 @@ +""" +FastMCP Screenshot Example + +Give Claude a tool to capture and view screenshots. +""" + +import io +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.utilities.types import Image + + +# Create server +mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"]) + + +@mcp.tool() +def take_screenshot() -> Image: + """ + Take a screenshot of the user's screen and return it as an image. Use + this tool anytime the user wants you to look at something they're doing. + """ + import pyautogui + + buffer = io.BytesIO() + + # if the file exceeds ~1MB, it will be rejected by Claude + screenshot = pyautogui.screenshot() + screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) + return Image(data=buffer.getvalue(), format="jpeg") diff --git a/examples/fastmcp/simple_echo.py b/examples/fastmcp/simple_echo.py new file mode 100644 index 000000000..a9aa7d241 --- /dev/null +++ b/examples/fastmcp/simple_echo.py @@ -0,0 +1,15 @@ +""" +FastMCP Echo Server +""" + +from mcp.server.fastmcp import FastMCP + + +# Create server +mcp = FastMCP("Echo Server") + + +@mcp.tool() +def echo(text: str) -> str: + """Echo the input text""" + return text diff --git a/examples/fastmcp/text_me.py b/examples/fastmcp/text_me.py new file mode 100644 index 000000000..f28682e43 --- /dev/null +++ b/examples/fastmcp/text_me.py @@ -0,0 +1,71 @@ +# /// script +# dependencies = ["fastmcp"] +# /// + +""" +FastMCP Text Me Server +-------------------------------- +This defines a simple FastMCP server that sends a text message to a phone number via https://surgemsg.com/. + +To run this example, create a `.env` file with the following values: + +SURGE_API_KEY=... +SURGE_ACCOUNT_ID=... +SURGE_MY_PHONE_NUMBER=... +SURGE_MY_FIRST_NAME=... +SURGE_MY_LAST_NAME=... + +Visit https://surgemsg.com/ and click "Get Started" to obtain these values. +""" + +from typing import Annotated +import httpx +from pydantic import BeforeValidator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.fastmcp import FastMCP + + +class SurgeSettings(BaseSettings): + model_config: SettingsConfigDict = SettingsConfigDict( + env_prefix="SURGE_", env_file=".env" + ) + + api_key: str + account_id: str + my_phone_number: Annotated[ + str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v) + ] + my_first_name: str + my_last_name: str + + +# Create server +mcp = FastMCP("Text me") +surge_settings = SurgeSettings() # type: ignore + + +@mcp.tool(name="textme", description="Send a text message to me") +def text_me(text_content: str) -> str: + """Send a text message to a phone number via https://surgemsg.com/""" + with httpx.Client() as client: + response = client.post( + "https://api.surgemsg.com/messages", + headers={ + "Authorization": f"Bearer {surge_settings.api_key}", + "Surge-Account": surge_settings.account_id, + "Content-Type": "application/json", + }, + json={ + "body": text_content, + "conversation": { + "contact": { + "first_name": surge_settings.my_first_name, + "last_name": surge_settings.my_last_name, + "phone_number": surge_settings.my_phone_number, + } + }, + }, + ) + response.raise_for_status() + return f"Message sent: {text_content}" diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 000000000..1a987cba4 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,50 @@ +"""Tests for example servers""" +import pytest +from mcp.shared.memory import create_connected_server_and_client_session as client_session + +@pytest.mark.anyio +async def test_simple_echo(): + """Test the simple echo server""" + from examples.fastmcp.simple_echo import mcp + + async with client_session(mcp._mcp_server) as client: + result = await client.call_tool("echo", {"text": "hello"}) + assert len(result.content) == 1 + content = result.content[0] + assert content.text == "hello" + +@pytest.mark.anyio +async def test_complex_inputs(): + """Test the complex inputs server""" + from examples.fastmcp.complex_inputs import mcp + + async with client_session(mcp._mcp_server) as client: + tank = { + "shrimp": [{"name": "bob"}, {"name": "alice"}] + } + result = await client.call_tool("name_shrimp", { + "tank": tank, + "extra_names": ["charlie"] + }) + assert len(result.content) == 3 + assert result.content[0].text == "bob" + assert result.content[1].text == "alice" + assert result.content[2].text == "charlie" + +@pytest.mark.anyio +async def test_desktop(): + """Test the desktop server""" + from examples.fastmcp.desktop import mcp + + async with client_session(mcp._mcp_server) as client: + # Test the add function + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert len(result.content) == 1 + content = result.content[0] + assert content.text == "3" + + # Test the desktop resource + result = await client.read_resource("dir://desktop") + assert len(result.contents) == 1 + content = result.contents[0] + assert isinstance(content.text, str) \ No newline at end of file From 87cee0ff3370fb754a3d51f9b6e7de20bea8ffd3 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 18 Dec 2024 15:26:02 +0000 Subject: [PATCH 04/20] feat: Add CLI package --- pyproject.toml | 6 +- src/mcp/cli/__init__.py | 7 + src/mcp/cli/claude.py | 137 ++++++++++++ src/mcp/cli/cli.py | 467 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 30 +++ 5 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 src/mcp/cli/__init__.py create mode 100644 src/mcp/cli/claude.py create mode 100644 src/mcp/cli/cli.py diff --git a/pyproject.toml b/pyproject.toml index 9ba6b9aac..4d532fdf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp" -version = "1.1.2.dev0" +version = "1.2.0.dev0" description = "Model Context Protocol SDK" readme = "README.md" requires-python = ">=3.10" @@ -33,6 +33,10 @@ dependencies = [ [project.optional-dependencies] rich = ["rich>=13.9.4"] +cli = ["typer>=0.12.4", "python-dotenv>=1.0.0"] + +[project.scripts] +mcp = "mcp.cli:app [cli]" [tool.uv] resolution = "lowest-direct" diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py new file mode 100644 index 000000000..4de805855 --- /dev/null +++ b/src/mcp/cli/__init__.py @@ -0,0 +1,7 @@ +"""FastMCP CLI package.""" + +from .cli import app + + +if __name__ == "__main__": + app() diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py new file mode 100644 index 000000000..7182d4ad6 --- /dev/null +++ b/src/mcp/cli/claude.py @@ -0,0 +1,137 @@ +"""Claude app integration utilities.""" + +import json +import sys +from pathlib import Path + +from mcp.server.fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +def get_claude_config_path() -> Path | None: + """Get the Claude config directory based on platform.""" + if sys.platform == "win32": + path = Path(Path.home(), "AppData", "Roaming", "Claude") + elif sys.platform == "darwin": + path = Path(Path.home(), "Library", "Application Support", "Claude") + else: + return None + + if path.exists(): + return path + return None + + +def update_claude_config( + file_spec: str, + server_name: str, + *, + with_editable: Path | None = None, + with_packages: list[str] | None = None, + env_vars: dict[str, str] | None = None, +) -> bool: + """Add or update a FastMCP server in Claude's configuration. + + Args: + file_spec: Path to the server file, optionally with :object suffix + server_name: Name for the server in Claude's config + with_editable: Optional directory to install in editable mode + with_packages: Optional list of additional packages to install + env_vars: Optional dictionary of environment variables. These are merged with + any existing variables, with new values taking precedence. + + Raises: + RuntimeError: If Claude Desktop's config directory is not found, indicating + Claude Desktop may not be installed or properly set up. + """ + config_dir = get_claude_config_path() + if not config_dir: + raise RuntimeError( + "Claude Desktop config directory not found. Please ensure Claude Desktop " + "is installed and has been run at least once to initialize its configuration." + ) + + config_file = config_dir / "claude_desktop_config.json" + if not config_file.exists(): + try: + config_file.write_text("{}") + except Exception as e: + logger.error( + "Failed to create Claude config file", + extra={ + "error": str(e), + "config_file": str(config_file), + }, + ) + return False + + try: + config = json.loads(config_file.read_text()) + if "mcpServers" not in config: + config["mcpServers"] = {} + + # Always preserve existing env vars and merge with new ones + if ( + server_name in config["mcpServers"] + and "env" in config["mcpServers"][server_name] + ): + existing_env = config["mcpServers"][server_name]["env"] + if env_vars: + # New vars take precedence over existing ones + env_vars = {**existing_env, **env_vars} + else: + env_vars = existing_env + + # Build uv run command + args = ["run"] + + # Collect all packages in a set to deduplicate + packages = {"fastmcp"} + if with_packages: + packages.update(pkg for pkg in with_packages if pkg) + + # Add all packages with --with + for pkg in sorted(packages): + args.extend(["--with", pkg]) + + if with_editable: + args.extend(["--with-editable", str(with_editable)]) + + # Convert file path to absolute before adding to command + # Split off any :object suffix first + if ":" in file_spec: + file_path, server_object = file_spec.rsplit(":", 1) + file_spec = f"{Path(file_path).resolve()}:{server_object}" + else: + file_spec = str(Path(file_spec).resolve()) + + # Add fastmcp run command + args.extend(["fastmcp", "run", file_spec]) + + server_config = { + "command": "uv", + "args": args, + } + + # Add environment variables if specified + if env_vars: + server_config["env"] = env_vars + + config["mcpServers"][server_name] = server_config + + config_file.write_text(json.dumps(config, indent=2)) + logger.info( + f"Added server '{server_name}' to Claude config", + extra={"config_file": str(config_file)}, + ) + return True + except Exception as e: + logger.error( + "Failed to update Claude config", + extra={ + "error": str(e), + "config_file": str(config_file), + }, + ) + return False diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py new file mode 100644 index 000000000..c1728ae4a --- /dev/null +++ b/src/mcp/cli/cli.py @@ -0,0 +1,467 @@ +"""MCP CLI tools.""" + +import importlib.metadata +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +try: + import typer + from typing_extensions import Annotated +except ImportError: + print("Error: typer is required. Install with 'pip install mcp[cli]'") + sys.exit(1) + +try: + from mcp.cli import claude + from mcp.server.fastmcp.utilities.logging import get_logger +except ImportError: + print("Error: mcp.server.fastmcp is not installed or not in PYTHONPATH") + sys.exit(1) + +try: + import dotenv +except ImportError: + dotenv = None + +logger = get_logger("cli") + +app = typer.Typer( + name="mcp", + help="MCP development tools", + add_completion=False, + no_args_is_help=True, # Show help if no args provided +) + + +def _get_npx_command(): + """Get the correct npx command for the current platform.""" + if sys.platform == "win32": + # Try both npx.cmd and npx.exe on Windows + for cmd in ["npx.cmd", "npx.exe", "npx"]: + try: + subprocess.run( + [cmd, "--version"], check=True, capture_output=True, shell=True + ) + return cmd + except subprocess.CalledProcessError: + continue + return None + return "npx" # On Unix-like systems, just use npx + + +def _parse_env_var(env_var: str) -> tuple[str, str]: + """Parse environment variable string in format KEY=VALUE.""" + if "=" not in env_var: + logger.error( + f"Invalid environment variable format: {env_var}. Must be KEY=VALUE" + ) + sys.exit(1) + key, value = env_var.split("=", 1) + return key.strip(), value.strip() + + +def _build_uv_command( + file_spec: str, + with_editable: Path | None = None, + with_packages: list[str] | None = None, +) -> list[str]: + """Build the uv run command that runs a MCP server through mcp run.""" + cmd = ["uv"] + + cmd.extend(["run", "--with", "mcp"]) + + if with_editable: + cmd.extend(["--with-editable", str(with_editable)]) + + if with_packages: + for pkg in with_packages: + if pkg: + cmd.extend(["--with", pkg]) + + # Add mcp run command + cmd.extend(["mcp", "run", file_spec]) + return cmd + + +def _parse_file_path(file_spec: str) -> tuple[Path, str | None]: + """Parse a file path that may include a server object specification. + + Args: + file_spec: Path to file, optionally with :object suffix + + Returns: + Tuple of (file_path, server_object) + """ + # First check if we have a Windows path (e.g., C:\...) + has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":" + + # Split on the last colon, but only if it's not part of the Windows drive letter + # and there's actually another colon in the string after the drive letter + if ":" in (file_spec[2:] if has_windows_drive else file_spec): + file_str, server_object = file_spec.rsplit(":", 1) + else: + file_str, server_object = file_spec, None + + # Resolve the file path + file_path = Path(file_str).expanduser().resolve() + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + sys.exit(1) + if not file_path.is_file(): + logger.error(f"Not a file: {file_path}") + sys.exit(1) + + return file_path, server_object + + +def _import_server(file: Path, server_object: str | None = None): + """Import a MCP server from a file. + + Args: + file: Path to the file + server_object: Optional object name in format "module:object" or just "object" + + Returns: + The server object + """ + # Add parent directory to Python path so imports can be resolved + file_dir = str(file.parent) + if file_dir not in sys.path: + sys.path.insert(0, file_dir) + + # Import the module + spec = importlib.util.spec_from_file_location("server_module", file) + if not spec or not spec.loader: + logger.error("Could not load module", extra={"file": str(file)}) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # If no object specified, try common server names + if not server_object: + # Look for the most common server object names + for name in ["mcp", "server", "app"]: + if hasattr(module, name): + return getattr(module, name) + + logger.error( + f"No server object found in {file}. Please either:\n" + "1. Use a standard variable name (mcp, server, or app)\n" + "2. Specify the object name with file:object syntax", + extra={"file": str(file)}, + ) + sys.exit(1) + + # Handle module:object syntax + if ":" in server_object: + module_name, object_name = server_object.split(":", 1) + try: + server_module = importlib.import_module(module_name) + server = getattr(server_module, object_name, None) + except ImportError: + logger.error( + f"Could not import module '{module_name}'", + extra={"file": str(file)}, + ) + sys.exit(1) + else: + # Just object name + server = getattr(module, server_object, None) + + if server is None: + logger.error( + f"Server object '{server_object}' not found", + extra={"file": str(file)}, + ) + sys.exit(1) + + return server + + +@app.command() +def version() -> None: + """Show the MCP version.""" + try: + version = importlib.metadata.version("mcp") + print(f"MCP version {version}") + except importlib.metadata.PackageNotFoundError: + print("MCP version unknown (package not installed)") + sys.exit(1) + + +@app.command() +def dev( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + with_editable: Annotated[ + Path | None, + typer.Option( + "--with-editable", + "-e", + help="Directory containing pyproject.toml to install in editable mode", + exists=True, + file_okay=False, + resolve_path=True, + ), + ] = None, + with_packages: Annotated[ + list[str], + typer.Option( + "--with", + help="Additional packages to install", + ), + ] = [], +) -> None: + """Run a MCP server with the MCP Inspector.""" + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Starting dev server", + extra={ + "file": str(file), + "server_object": server_object, + "with_editable": str(with_editable) if with_editable else None, + "with_packages": with_packages, + }, + ) + + try: + # Import server to get dependencies + server = _import_server(file, server_object) + if hasattr(server, "dependencies"): + with_packages = list(set(with_packages + server.dependencies)) + + uv_cmd = _build_uv_command(file_spec, with_editable, with_packages) + + # Get the correct npx command + npx_cmd = _get_npx_command() + if not npx_cmd: + logger.error( + "npx not found. Please ensure Node.js and npm are properly installed " + "and added to your system PATH." + ) + sys.exit(1) + + # Run the MCP Inspector command with shell=True on Windows + shell = sys.platform == "win32" + process = subprocess.run( + [npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd, + check=True, + shell=shell, + env=dict(os.environ.items()), # Convert to list of tuples for env update + ) + sys.exit(process.returncode) + except subprocess.CalledProcessError as e: + logger.error( + "Dev server failed", + extra={ + "file": str(file), + "error": str(e), + "returncode": e.returncode, + }, + ) + sys.exit(e.returncode) + except FileNotFoundError: + logger.error( + "npx not found. Please ensure Node.js and npm are properly installed " + "and added to your system PATH. You may need to restart your terminal " + "after installation.", + extra={"file": str(file)}, + ) + sys.exit(1) + + +@app.command() +def run( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + transport: Annotated[ + str | None, + typer.Option( + "--transport", + "-t", + help="Transport protocol to use (stdio or sse)", + ), + ] = None, +) -> None: + """Run a MCP server. + + The server can be specified in two ways: + 1. Module approach: server.py - runs the module directly, expecting a server.run() call + 2. Import approach: server.py:app - imports and runs the specified server object + + Note: This command runs the server directly. You are responsible for ensuring + all dependencies are available. For dependency management, use mcp install + or mcp dev instead. + """ + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Running server", + extra={ + "file": str(file), + "server_object": server_object, + "transport": transport, + }, + ) + + try: + # Import and get server object + server = _import_server(file, server_object) + + # Run the server + kwargs = {} + if transport: + kwargs["transport"] = transport + + server.run(**kwargs) + + except Exception as e: + logger.error( + f"Failed to run server: {e}", + extra={ + "file": str(file), + "error": str(e), + }, + ) + sys.exit(1) + + +@app.command() +def install( + file_spec: str = typer.Argument( + ..., + help="Python file to run, optionally with :object suffix", + ), + server_name: Annotated[ + str | None, + typer.Option( + "--name", + "-n", + help="Custom name for the server (defaults to server's name attribute or file name)", + ), + ] = None, + with_editable: Annotated[ + Path | None, + typer.Option( + "--with-editable", + "-e", + help="Directory containing pyproject.toml to install in editable mode", + exists=True, + file_okay=False, + resolve_path=True, + ), + ] = None, + with_packages: Annotated[ + list[str], + typer.Option( + "--with", + help="Additional packages to install", + ), + ] = [], + env_vars: Annotated[ + list[str], + typer.Option( + "--env-var", + "-e", + help="Environment variables in KEY=VALUE format", + ), + ] = [], + env_file: Annotated[ + Path | None, + typer.Option( + "--env-file", + "-f", + help="Load environment variables from a .env file", + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + ), + ] = None, +) -> None: + """Install a MCP server in the Claude desktop app. + + Environment variables are preserved once added and only updated if new values + are explicitly provided. + """ + file, server_object = _parse_file_path(file_spec) + + logger.debug( + "Installing server", + extra={ + "file": str(file), + "server_name": server_name, + "server_object": server_object, + "with_editable": str(with_editable) if with_editable else None, + "with_packages": with_packages, + }, + ) + + if not claude.get_claude_config_path(): + logger.error("Claude app not found") + sys.exit(1) + + # Try to import server to get its name, but fall back to file name if dependencies missing + name = server_name + server = None + if not name: + try: + server = _import_server(file, server_object) + name = server.name + except (ImportError, ModuleNotFoundError) as e: + logger.debug( + "Could not import server (likely missing dependencies), using file name", + extra={"error": str(e)}, + ) + name = file.stem + + # Get server dependencies if available + server_dependencies = getattr(server, "dependencies", []) if server else [] + if server_dependencies: + with_packages = list(set(with_packages + server_dependencies)) + + # Process environment variables if provided + env_dict: dict[str, str] | None = None + if env_file or env_vars: + env_dict = {} + # Load from .env file if specified + if env_file: + if dotenv: + try: + env_dict |= { + k: v + for k, v in dotenv.dotenv_values(env_file).items() + if v is not None + } + except Exception as e: + logger.error(f"Failed to load .env file: {e}") + sys.exit(1) + else: + logger.error("python-dotenv is not installed. Cannot load .env file.") + sys.exit(1) + + # Add command line environment variables + for env_var in env_vars: + key, value = _parse_env_var(env_var) + env_dict[key] = value + + if claude.update_claude_config( + file_spec, + name, + with_editable=with_editable, + with_packages=with_packages, + env_vars=env_dict, + ): + logger.info(f"Successfully installed {name} in Claude app") + else: + logger.error(f"Failed to install {name} in Claude app") + sys.exit(1) diff --git a/uv.lock b/uv.lock index 18009ba8f..d5bd584fd 100644 --- a/uv.lock +++ b/uv.lock @@ -204,6 +204,10 @@ dependencies = [ ] [package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] rich = [ { name = "rich" }, ] @@ -226,9 +230,11 @@ requires-dist = [ { name = "httpx-sse", specifier = ">=0.4" }, { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, ] [package.metadata.requires-dev] @@ -605,6 +611,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -704,6 +719,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/70/efa56ce2271c44a7f4f43533a0477e6854a0948e9f7b76491de1fd3be7c9/trio-0.26.2-py3-none-any.whl", hash = "sha256:c5237e8133eb0a1d72f09a971a55c28ebe69e351c783fc64bc37db8db8bbe1d0", size = 475996 }, ] +[[package]] +name = "typer" +version = "0.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/f7/f174a1cae84848ae8b27170a96187b91937b743f0580ff968078fe16930a/typer-0.12.4.tar.gz", hash = "sha256:c9c1613ed6a166162705b3347b8d10b661ccc5d95692654d0fb628118f2c34e6", size = 97945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/cc/15083dcde1252a663398b1b2a173637a3ec65adadfb95137dc95df1e6adc/typer-0.12.4-py3-none-any.whl", hash = "sha256:819aa03699f438397e876aa12b0d63766864ecba1b579092cc9fe35d886e34b6", size = 47402 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From dad01945c764660033befe0ab211c6c84c21de4a Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Wed, 18 Dec 2024 16:11:29 +0000 Subject: [PATCH 05/20] docs: Update README.md to include FastMCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the README.md to include FastMCP as the recommended way to build MCP servers. FastMCP is a high-level, Pythonic interface that makes it easy to build MCP servers with minimal boilerplate. The low-level implementation is still available for more control. 🤖 Generated with Claude CLI. Co-Authored-By: Claude --- README.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4ef08cfeb..e393a2086 100644 --- a/README.md +++ b/README.md @@ -71,18 +71,35 @@ Connections between clients and servers are established through transports like ## Quick Start -### Creating a Server +### FastMCP -MCP servers follow a decorator approach to register handlers for MCP primitives like resources, prompts, and tools. The goal is to provide a simple interface for exposing capabilities to LLM clients. +The fastest way to build MCP servers is with FastMCP, which provides a high-level, Pythonic interface: -**example_server.py** +```python +from fastmcp import FastMCP + +mcp = FastMCP("Demo") + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" +``` + +FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic - in most cases, decorating a function is all you need. + +For more information about FastMCP, see the [FastMCP documentation](https://github.com/jlowin/fastmcp). + +### Low-Level Implementation + +For more control, you can use the low-level MCP implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: ```python -# /// script -# dependencies = [ -# "mcp" -# ] -# /// from mcp.server import Server, NotificationOptions from mcp.server.models import InitializationOptions import mcp.server.stdio @@ -339,4 +356,4 @@ We are passionate about supporting contributors of all levels of experience and ## License -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file From aa3b550ec6e90d7e34e26b645ec3b44cfc5c945d Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 15:28:03 +0000 Subject: [PATCH 06/20] docs: Update FastMCP import paths and dependencies --- README.md | 4 ++-- examples/fastmcp/memory.py | 4 ++-- examples/fastmcp/text_me.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e393a2086..1d9ba535e 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Connections between clients and servers are established through transports like The fastest way to build MCP servers is with FastMCP, which provides a high-level, Pythonic interface: ```python -from fastmcp import FastMCP +from mcp.server.fastmcp import FastMCP mcp = FastMCP("Demo") @@ -93,7 +93,7 @@ def get_greeting(name: str) -> str: FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic - in most cases, decorating a function is all you need. -For more information about FastMCP, see the [FastMCP documentation](https://github.com/jlowin/fastmcp). +FastMCP was originally developed by Jeremiah Lowin at [jlowin/fastmcp](https://github.com/jlowin/fastmcp). We are grateful for his contribution in developing this excellent framework that has now been integrated into MCP. ### Low-Level Implementation diff --git a/examples/fastmcp/memory.py b/examples/fastmcp/memory.py index 6e24da21f..6b114aae5 100644 --- a/examples/fastmcp/memory.py +++ b/examples/fastmcp/memory.py @@ -1,8 +1,8 @@ # /// script -# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector", "fastmcp"] +# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] # /// -# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector fastmcp +# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector """ Recursive memory system inspired by the human brain's clustering of memories. diff --git a/examples/fastmcp/text_me.py b/examples/fastmcp/text_me.py index f28682e43..803f024d0 100644 --- a/examples/fastmcp/text_me.py +++ b/examples/fastmcp/text_me.py @@ -1,5 +1,5 @@ # /// script -# dependencies = ["fastmcp"] +# dependencies = [] # /// """ From b90da481eb10dc0ea64c24c7daf3fbe11823bcdf Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 20:46:27 +0000 Subject: [PATCH 07/20] docs: Update README to recommend mcp.server.lowlevel and deprecate mcp.server --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d9ba535e..2b3561ce3 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,10 @@ FastMCP was originally developed by Jeremiah Lowin at [jlowin/fastmcp](https://g ### Low-Level Implementation -For more control, you can use the low-level MCP implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: +For more control, you can use the low-level MCP implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server. We recommend using `mcp.server.lowlevel` for all low-level server implementations, as the direct `mcp.server` imports will be deprecated and removed in SDK 2.0.0. ```python -from mcp.server import Server, NotificationOptions +from mcp.server.lowlevel import Server, NotificationOptions from mcp.server.models import InitializationOptions import mcp.server.stdio import mcp.types as types From bed35f3737849ffcc4659ddf749f2fcab0ad01b4 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 20:51:59 +0000 Subject: [PATCH 08/20] Update example servers to use mcp.server.lowlevel --- examples/servers/simple-prompt/mcp_simple_prompt/server.py | 2 +- examples/servers/simple-resource/mcp_simple_resource/server.py | 3 ++- examples/servers/simple-tool/mcp_simple_tool/server.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index f22bdc5ec..1e4c9e18d 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -1,7 +1,7 @@ import anyio import click import mcp.types as types -from mcp.server import Server +from mcp.server.lowlevel import Server # Using lowlevel API as mcp.server is deprecated def create_messages( diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index a7395e4f9..6009c7b89 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -1,7 +1,8 @@ import anyio import click import mcp.types as types -from mcp.server import AnyUrl, Server +from pydantic import AnyUrl +from mcp.server.lowlevel import Server SAMPLE_RESOURCES = { "greeting": "Hello! This is a sample text resource.", diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 4343f772e..10796d571 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -2,7 +2,7 @@ import click import httpx import mcp.types as types -from mcp.server import Server +from mcp.server.lowlevel import Server # Using lowlevel API as mcp.server is deprecated async def fetch_website( From f2128a7977185fb873f3698a452e86a46ea41eb2 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 21:26:19 +0000 Subject: [PATCH 09/20] docs: Update README with new structure and content --- README.md | 206 +++++++++++++----------------------------------------- uv.lock | 8 +-- 2 files changed, 52 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index 2b3561ce3..8dc667aa6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,9 @@ # MCP Python SDK + +
+ +Python implementation of the Model Context Protocol (MCP) + [![PyPI][pypi-badge]][pypi-url] [![MIT licensed][mit-badge]][mit-url] [![Python Version][python-badge]][python-url] @@ -6,6 +11,23 @@ [![Specification][spec-badge]][spec-url] [![GitHub Discussions][discussions-badge]][discussions-url] +
+ + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Writing MCP Servers](#writing-mcp-servers) + - [FastMCP (Recommended)](#fastmcp-recommended) + - [Low-Level Server (Advanced)](#low-level-server-advanced) +- [Writing MCP Clients](#writing-mcp-clients) +- [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) + [pypi-badge]: https://img.shields.io/pypi/v/mcp.svg [pypi-url]: https://pypi.org/project/mcp/ [mit-badge]: https://img.shields.io/pypi/l/mcp.svg @@ -19,7 +41,6 @@ [discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk [discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions -Python implementation of the [Model Context Protocol](https://modelcontextprotocol.io) (MCP), providing both client and server capabilities for integrating with LLM surfaces. ## Overview @@ -45,35 +66,13 @@ pip install mcp pip install -r requirements.txt ``` -## Overview -MCP servers provide focused functionality like resources, tools, prompts, and other capabilities that can be reused across many client applications. These servers are designed to be easy to build, highly composable, and modular. - -### Key design principles -- Servers are extremely easy to build with clear, simple interfaces -- Multiple servers can be composed seamlessly through a shared protocol -- Each server operates in isolation and cannot access conversation context -- Features can be added progressively through capability negotiation - -### Server provided primitives -- [Prompts](https://modelcontextprotocol.io/docs/concepts/prompts): Templatable text -- [Resources](https://modelcontextprotocol.io/docs/concepts/resources): File-like attachments -- [Tools](https://modelcontextprotocol.io/docs/concepts/tools): Functions that models can call -- Utilities: - - Completion: Auto-completion provider for prompt arguments or resource URI templates - - Logging: Logging to the client - - Pagination*: Pagination for long results - -### Client provided primitives - - [Sampling](https://modelcontextprotocol.io/docs/concepts/sampling): Allow servers to sample using client models - - Roots: Information about locations to operate on (e.g., directories) - -Connections between clients and servers are established through transports like **stdio** or **SSE** (Note that most clients support stdio, but not SSE at the moment). The transport layer handles message framing, delivery, and error handling. +## Writing MCP Servers -## Quick Start +The MCP Python SDK provides two ways to implement servers: -### FastMCP +### FastMCP (Recommended) -The fastest way to build MCP servers is with FastMCP, which provides a high-level, Pythonic interface: +FastMCP provides a high-level, Pythonic interface for building MCP servers quickly and easily. It handles all the complex protocol details so you can focus on building great tools: ```python from mcp.server.fastmcp import FastMCP @@ -91,13 +90,17 @@ def get_greeting(name: str) -> str: return f"Hello, {name}!" ``` -FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic - in most cases, decorating a function is all you need. +FastMCP features: +- Simple, decorator-based API +- Automatic type handling and validation +- Built-in support for async functions +- Progress reporting and logging +- Resource templating +- Image handling -FastMCP was originally developed by Jeremiah Lowin at [jlowin/fastmcp](https://github.com/jlowin/fastmcp). We are grateful for his contribution in developing this excellent framework that has now been integrated into MCP. +### Low-Level Server (Advanced) -### Low-Level Implementation - -For more control, you can use the low-level MCP implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server. We recommend using `mcp.server.lowlevel` for all low-level server implementations, as the direct `mcp.server` imports will be deprecated and removed in SDK 2.0.0. +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: ```python from mcp.server.lowlevel import Server, NotificationOptions @@ -108,7 +111,6 @@ import mcp.types as types # Create a server instance server = Server("example-server") -# Add prompt capabilities @server.list_prompts() async def handle_list_prompts() -> list[types.Prompt]: return [ @@ -147,7 +149,6 @@ async def handle_get_prompt( ) async def run(): - # Run the server as STDIO async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, @@ -167,9 +168,9 @@ if __name__ == "__main__": asyncio.run(run()) ``` -### Creating a Client +## Writing MCP Clients -**example_client.py** +The SDK provides a high-level client interface for connecting to MCP servers: ```python from mcp import ClientSession, StdioServerParameters @@ -188,17 +189,12 @@ async def run(): # Initialize the connection await session.initialize() - # The example server only supports prompt primitives: - # List available prompts prompts = await session.list_prompts() # Get a prompt prompt = await session.get_prompt("example-prompt", arguments={"arg1": "value"}) - """ - Other example calls include: - # List available resources resources = await session.list_resources() @@ -210,16 +206,15 @@ async def run(): # Call a tool result = await session.call_tool("tool-name", arguments={"arg1": "value"}) - """ if __name__ == "__main__": import asyncio asyncio.run(run()) ``` -## Primitives +## MCP Primitives -The MCP Python SDK provides decorators that map to the core protocol primitives. Each primitive follows a different interaction pattern based on how it is controlled and used: +The MCP protocol defines three core primitives that servers can implement: | Primitive | Control | Description | Example Use | |-----------|-----------------------|-----------------------------------------------------|------------------------------| @@ -227,122 +222,17 @@ The MCP Python SDK provides decorators that map to the core protocol primitives. | Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | | Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | -### User-Controlled Primitives - -**Prompts** are designed to be explicitly selected by users for their interactions with LLMs. - -| Decorator | Description | -|--------------------------|----------------------------------------| -| `@server.list_prompts()` | List available prompt templates | -| `@server.get_prompt()` | Get a specific prompt with arguments | - -### Application-Controlled Primitives - -**Resources** are controlled by the client application, which decides how and when they should be used based on its own logic. - -| Decorator | Description | -|--------------------------------|---------------------------------------| -| `@server.list_resources()` | List available resources | -| `@server.read_resource()` | Read a specific resource's content | -| `@server.subscribe_resource()` | Subscribe to resource updates | - -### Model-Controlled Primitives - -**Tools** are exposed to LLMs to enable automated actions, with user approval. - -| Decorator | Description | -|------------------------|------------------------------------| -| `@server.list_tools()` | List available tools | -| `@server.call_tool()` | Execute a tool with arguments | - -### Server Management - -Additional decorators for server functionality: - -| Decorator | Description | -|-------------------------------|--------------------------------| -| `@server.set_logging_level()` | Update server logging level | - -### Capabilities - -MCP servers declare capabilities during initialization. These map to specific decorators: - -| Capability | Feature Flag | Decorators | Description | -|-------------|------------------------------|-----------------------------------------------------------------|-------------------------------------| -| `prompts` | `listChanged` | `@list_prompts`
`@get_prompt` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| `@list_resources`
`@read_resource`
`@subscribe_resource`| Resource exposure and updates | -| `tools` | `listChanged` | `@list_tools`
`@call_tool` | Tool discovery and execution | -| `logging` | - | `@set_logging_level` | Server logging configuration | -| `completion`| - | `@complete_argument` | Argument completion suggestions | +### Server Capabilities -Capabilities are negotiated during connection initialization. Servers only need to implement the decorators for capabilities they support. - -## Client Interaction - -The MCP Python SDK enables servers to interact with clients through request context and session management. This allows servers to perform operations like LLM sampling and progress tracking. - -### Request Context - -The Request Context provides access to the current request and client session. It can be accessed through `server.request_context` and enables: - -- Sampling from the client's LLM -- Sending progress updates -- Logging messages -- Accessing request metadata - -Example using request context for LLM sampling: - -```python -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]: - # Access the current request context - context = server.request_context - - # Use the session to sample from the client's LLM - result = await context.session.create_message( - messages=[ - types.SamplingMessage( - role="user", - content=types.TextContent( - type="text", - text="Analyze this data: " + json.dumps(arguments) - ) - ) - ], - max_tokens=100 - ) - - return [types.TextContent(type="text", text=result.content.text)] -``` - -Using request context for progress updates: - -```python -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]: - context = server.request_context - - if progress_token := context.meta.progressToken: - # Send progress notifications - await context.session.send_progress_notification( - progress_token=progress_token, - progress=0.5, - total=1.0 - ) - - # Perform operation... - - if progress_token: - await context.session.send_progress_notification( - progress_token=progress_token, - progress=1.0, - total=1.0 - ) - - return [types.TextContent(type="text", text="Operation complete")] -``` +MCP servers declare capabilities during initialization: -The request context is automatically set for each request and provides a safe way to access the current client session and request metadata. +| Capability | Feature Flag | Description | +|-------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completion`| - | Argument completion suggestions | ## Documentation diff --git a/uv.lock b/uv.lock index d5bd584fd..dcc5cc1da 100644 --- a/uv.lock +++ b/uv.lock @@ -191,7 +191,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.1.2.dev0" +version = "1.2.0.dev0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -565,11 +565,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +sdist = { url = "https://files.pythonhosted.org/packages/31/06/1ef763af20d0572c032fa22882cfbfb005fba6e7300715a37840858c919e/python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba", size = 37399 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, + { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482 }, ] [[package]] From 45cea71d907cfabbcb359fe9e0b139126fc11edc Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 21:37:29 +0000 Subject: [PATCH 10/20] docs: Update README with FastMCP-style structure and examples --- README.md | 349 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 220 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 8dc667aa6..4653f3ae5 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,22 @@ - [Overview](#overview) - [Installation](#installation) -- [Writing MCP Servers](#writing-mcp-servers) - - [FastMCP (Recommended)](#fastmcp-recommended) - - [Low-Level Server (Advanced)](#low-level-server-advanced) -- [Writing MCP Clients](#writing-mcp-clients) -- [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) +- [Quickstart](#quickstart) +- [What is MCP?](#what-is-mcp) +- [Core Concepts](#core-concepts) + - [Server](#server) + - [Resources](#resources) + - [Tools](#tools) + - [Prompts](#prompts) + - [Images](#images) + - [Context](#context) +- [Running Your Server](#running-your-server) + - [Development Mode](#development-mode) + - [Claude Desktop Integration](#claude-desktop-integration) + - [Direct Execution](#direct-execution) +- [Examples](#examples) + - [Echo Server](#echo-server) + - [SQLite Explorer](#sqlite-explorer) - [Documentation](#documentation) - [Contributing](#contributing) - [License](#license) @@ -41,7 +51,6 @@ [discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk [discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions - ## Overview The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: @@ -53,186 +62,268 @@ The Model Context Protocol allows applications to provide context for LLMs in a ## Installation -We recommend the use of [uv](https://docs.astral.sh/uv/) to manage your Python projects: +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects: ```bash uv add mcp ``` -Alternatively, add mcp to your `requirements.txt`: -``` +Alternatively: +```bash pip install mcp -# or add to requirements.txt -pip install -r requirements.txt ``` -## Writing MCP Servers - -The MCP Python SDK provides two ways to implement servers: - -### FastMCP (Recommended) +## Quickstart -FastMCP provides a high-level, Pythonic interface for building MCP servers quickly and easily. It handles all the complex protocol details so you can focus on building great tools: +Let's create a simple MCP server that exposes a calculator tool and some data: ```python +# server.py from mcp.server.fastmcp import FastMCP +# Create an MCP server mcp = FastMCP("Demo") +# Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: """Add two numbers""" return a + b +# Add a dynamic greeting resource @mcp.resource("greeting://{name}") def get_greeting(name: str) -> str: """Get a personalized greeting""" return f"Hello, {name}!" ``` -FastMCP features: -- Simple, decorator-based API -- Automatic type handling and validation -- Built-in support for async functions -- Progress reporting and logging -- Resource templating -- Image handling +You can install this server in [Claude Desktop](https://claude.ai/download) and interact with it right away by running: +```bash +mcp install server.py +``` + +Alternatively, you can test it with the MCP Inspector: +```bash +mcp dev server.py +``` -### Low-Level Server (Advanced) +## What is MCP? -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: + +- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) +- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) +- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) +- And more! + +## Core Concepts + +### Server + +The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: ```python -from mcp.server.lowlevel import Server, NotificationOptions -from mcp.server.models import InitializationOptions -import mcp.server.stdio -import mcp.types as types +from mcp.server.fastmcp import FastMCP + +# Create a named server +mcp = FastMCP("My App") + +# Specify dependencies for deployment and development +mcp = FastMCP("My App", dependencies=["pandas", "numpy"]) +``` + +### Resources -# Create a server instance -server = Server("example-server") +Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: +```python +@mcp.resource("config://app") +def get_config() -> str: + """Static configuration data""" + return "App configuration here" + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """Dynamic user data""" + return f"Profile data for user {user_id}" +``` + +### Tools + +Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + +```python +@mcp.tool() +def calculate_bmi(weight_kg: float, height_m: float) -> float: + """Calculate BMI given weight in kg and height in meters""" + return weight_kg / (height_m ** 2) + +@mcp.tool() +async def fetch_weather(city: str) -> str: + """Fetch current weather for a city""" + async with httpx.AsyncClient() as client: + response = await client.get(f"https://api.weather.com/{city}") + return response.text +``` + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + +```python +@mcp.prompt() +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + +@mcp.prompt() +def debug_error(error: str) -> list[Message]: return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[ - types.PromptArgument( - name="arg1", - description="Example argument", - required=True - ) - ] - ) + UserMessage("I'm seeing this error:"), + UserMessage(error), + AssistantMessage("I'll help debug that. What have you tried so far?") ] +``` -@server.get_prompt() -async def handle_get_prompt( - name: str, - arguments: dict[str, str] | None -) -> types.GetPromptResult: - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent( - type="text", - text="Example prompt text" - ) - ) - ] - ) - -async def run(): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ) - ) - ) +### Images -if __name__ == "__main__": - import asyncio - asyncio.run(run()) +FastMCP provides an `Image` class that automatically handles image data: + +```python +from mcp.server.fastmcp import FastMCP, Image +from PIL import Image as PILImage + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") ``` -## Writing MCP Clients +### Context -The SDK provides a high-level client interface for connecting to MCP servers: +The Context object gives your tools and resources access to MCP capabilities: ```python -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client +from mcp.server.fastmcp import FastMCP, Context + +@mcp.tool() +async def long_task(files: list[str], ctx: Context) -> str: + """Process multiple files with progress tracking""" + for i, file in enumerate(files): + ctx.info(f"Processing {file}") + await ctx.report_progress(i, len(files)) + data = await ctx.read_resource(f"file://{file}") + return "Processing complete" +``` + +## Running Your Server -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="python", # Executable - args=["example_server.py"], # Optional command line arguments - env=None # Optional environment variables -) +### Development Mode -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() +The fastest way to test and debug your server is with the MCP Inspector: - # List available prompts - prompts = await session.list_prompts() +```bash +mcp dev server.py - # Get a prompt - prompt = await session.get_prompt("example-prompt", arguments={"arg1": "value"}) +# Add dependencies +mcp dev server.py --with pandas --with numpy + +# Mount local code +mcp dev server.py --with-editable . +``` - # List available resources - resources = await session.list_resources() +### Claude Desktop Integration - # List available tools - tools = await session.list_tools() +Once your server is ready, install it in Claude Desktop: - # Read a resource - resource = await session.read_resource("file://some/path") +```bash +mcp install server.py + +# Custom name +mcp install server.py --name "My Analytics Server" + +# Environment variables +mcp install server.py -e API_KEY=abc123 -e DB_URL=postgres://... +mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + +```python +from mcp.server.fastmcp import FastMCP - # Call a tool - result = await session.call_tool("tool-name", arguments={"arg1": "value"}) +mcp = FastMCP("My App") if __name__ == "__main__": - import asyncio - asyncio.run(run()) + mcp.run() ``` -## MCP Primitives +Run it with: +```bash +python server.py +# or +mcp run server.py +``` + +## Examples + +### Echo Server + +A simple server demonstrating resources, tools, and prompts: + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Echo") -The MCP protocol defines three core primitives that servers can implement: +@mcp.resource("echo://{message}") +def echo_resource(message: str) -> str: + """Echo a message as a resource""" + return f"Resource echo: {message}" -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | +@mcp.tool() +def echo_tool(message: str) -> str: + """Echo a message as a tool""" + return f"Tool echo: {message}" + +@mcp.prompt() +def echo_prompt(message: str) -> str: + """Create an echo prompt""" + return f"Please process this message: {message}" +``` + +### SQLite Explorer -### Server Capabilities +A more complex example showing database integration: -MCP servers declare capabilities during initialization: +```python +from mcp.server.fastmcp import FastMCP +import sqlite3 -| Capability | Feature Flag | Description | -|-------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completion`| - | Argument completion suggestions | +mcp = FastMCP("SQLite Explorer") + +@mcp.resource("schema://main") +def get_schema() -> str: + """Provide the database schema as a resource""" + conn = sqlite3.connect("database.db") + schema = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table'" + ).fetchall() + return "\n".join(sql[0] for sql in schema if sql[0]) + +@mcp.tool() +def query_data(sql: str) -> str: + """Execute SQL queries safely""" + conn = sqlite3.connect("database.db") + try: + result = conn.execute(sql).fetchall() + return "\n".join(str(row) for row in result) + except Exception as e: + return f"Error: {str(e)}" +``` ## Documentation From ca3121282e86e8e7fa8065fec110eac767b7a561 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 21:45:00 +0000 Subject: [PATCH 11/20] docs: Update README to specify [cli] dependency for mcp CLI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4653f3ae5..f31128d60 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The Model Context Protocol allows applications to provide context for LLMs in a We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects: ```bash -uv add mcp +uv add "mcp[cli]" ``` Alternatively: From 7bbf71e29ae16566c2afab46ff0d4231c3a64e4a Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 21:46:09 +0000 Subject: [PATCH 12/20] docs: Add back advanced usage sections to README Add back sections on low-level server implementation, client usage, MCP primitives and server capabilities that were previously removed. --- README.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/README.md b/README.md index f31128d60..335542c79 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ - [Examples](#examples) - [Echo Server](#echo-server) - [SQLite Explorer](#sqlite-explorer) +- [Advanced Usage](#advanced-usage) + - [Low-Level Server](#low-level-server) + - [Writing MCP Clients](#writing-mcp-clients) + - [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) - [Documentation](#documentation) - [Contributing](#contributing) - [License](#license) @@ -325,6 +330,144 @@ def query_data(sql: str) -> str: return f"Error: {str(e)}" ``` +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server: + +```python +from mcp.server.lowlevel import Server, NotificationOptions +from mcp.server.models import InitializationOptions +import mcp.server.stdio +import mcp.types as types + +# Create a server instance +server = Server("example-server") + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[ + types.PromptArgument( + name="arg1", + description="Example argument", + required=True + ) + ] + ) + ] + +@server.get_prompt() +async def handle_get_prompt( + name: str, + arguments: dict[str, str] | None +) -> types.GetPromptResult: + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent( + type="text", + text="Example prompt text" + ) + ) + ] + ) + +async def run(): + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ) + ) + ) + +if __name__ == "__main__": + import asyncio + asyncio.run(run()) +``` + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers: + +```python +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="python", # Executable + args=["example_server.py"], # Optional command line arguments + env=None # Optional environment variables +) + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + + # Get a prompt + prompt = await session.get_prompt("example-prompt", arguments={"arg1": "value"}) + + # List available resources + resources = await session.list_resources() + + # List available tools + tools = await session.list_tools() + + # Read a resource + resource = await session.read_resource("file://some/path") + + # Call a tool + result = await session.call_tool("tool-name", arguments={"arg1": "value"}) + +if __name__ == "__main__": + import asyncio + asyncio.run(run()) +``` + +### MCP Primitives + +The MCP protocol defines three core primitives that servers can implement: + +| Primitive | Control | Description | Example Use | +|-----------|-----------------------|-----------------------------------------------------|------------------------------| +| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | +| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | +| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | + +### Server Capabilities + +MCP servers declare capabilities during initialization: + +| Capability | Feature Flag | Description | +|-------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completion`| - | Argument completion suggestions | + ## Documentation - [Model Context Protocol documentation](https://modelcontextprotocol.io) From a79f51f55fdd6f6a3ea7fb6a32826aa40d00de99 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 22:33:40 +0000 Subject: [PATCH 13/20] style: Fix imports and line length formatting --- examples/fastmcp/complex_inputs.py | 4 +- examples/fastmcp/memory.py | 9 ++- examples/fastmcp/readme-quickstart.py | 1 - examples/fastmcp/screenshot.py | 2 +- examples/fastmcp/simple_echo.py | 1 - examples/fastmcp/text_me.py | 1 + .../mcp_simple_resource/server.py | 2 +- pyproject.toml | 1 - src/mcp/cli/__init__.py | 1 - src/mcp/cli/claude.py | 4 +- src/mcp/cli/cli.py | 12 ++-- src/mcp/server/__init__.py | 2 +- src/mcp/server/fastmcp/__init__.py | 3 +- src/mcp/server/fastmcp/prompts/base.py | 9 +-- src/mcp/server/fastmcp/resources/__init__.py | 10 ++-- .../fastmcp/resources/resource_manager.py | 1 - src/mcp/server/fastmcp/resources/types.py | 6 +- src/mcp/server/fastmcp/server.py | 31 +++++----- src/mcp/server/fastmcp/tools/base.py | 15 ++--- src/mcp/server/fastmcp/tools/tool_manager.py | 7 +-- .../server/fastmcp/utilities/func_metadata.py | 22 +++---- src/mcp/server/fastmcp/utilities/logging.py | 2 + src/mcp/server/lowlevel/__init__.py | 2 +- tests/server/fastmcp/prompts/test_base.py | 20 +++++-- tests/server/fastmcp/prompts/test_manager.py | 7 ++- .../fastmcp/resources/test_file_resources.py | 8 ++- .../resources/test_function_resources.py | 9 ++- .../resources/test_resource_manager.py | 6 +- .../resources/test_resource_template.py | 7 +++ .../fastmcp/resources/test_resources.py | 1 + .../fastmcp/servers/test_file_server.py | 11 +++- tests/server/fastmcp/test_func_metadata.py | 13 ++--- tests/server/fastmcp/test_server.py | 58 ++++++++++++++++--- tests/server/fastmcp/test_tool_manager.py | 20 ++++++- tests/test_examples.py | 30 +++++----- tests/test_types.py | 5 +- uv.lock | 14 ----- 37 files changed, 232 insertions(+), 125 deletions(-) diff --git a/examples/fastmcp/complex_inputs.py b/examples/fastmcp/complex_inputs.py index 48aacbe95..e859165a9 100644 --- a/examples/fastmcp/complex_inputs.py +++ b/examples/fastmcp/complex_inputs.py @@ -4,8 +4,10 @@ Demonstrates validation via pydantic with complex models. """ -from pydantic import BaseModel, Field from typing import Annotated + +from pydantic import BaseModel, Field + from mcp.server.fastmcp import FastMCP mcp = FastMCP("Shrimp Tank") diff --git a/examples/fastmcp/memory.py b/examples/fastmcp/memory.py index 6b114aae5..dbc890815 100644 --- a/examples/fastmcp/memory.py +++ b/examples/fastmcp/memory.py @@ -6,7 +6,8 @@ """ Recursive memory system inspired by the human brain's clustering of memories. -Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient similarity search. +Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient +similarity search. """ import asyncio @@ -111,7 +112,8 @@ async def save(self, deps: Deps): if self.id is None: result = await conn.fetchrow( """ - INSERT INTO memories (content, summary, importance, access_count, timestamp, embedding) + INSERT INTO memories (content, summary, importance, access_count, + timestamp, embedding) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id """, @@ -336,7 +338,8 @@ async def initialize_database(): timestamp DOUBLE PRECISION NOT NULL, embedding vector(1536) NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories USING hnsw (embedding vector_l2_ops); + CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories + USING hnsw (embedding vector_l2_ops); """) finally: await pool.close() diff --git a/examples/fastmcp/readme-quickstart.py b/examples/fastmcp/readme-quickstart.py index 6c8e27529..d1c522a81 100644 --- a/examples/fastmcp/readme-quickstart.py +++ b/examples/fastmcp/readme-quickstart.py @@ -1,6 +1,5 @@ from mcp.server.fastmcp import FastMCP - # Create an MCP server mcp = FastMCP("Demo") diff --git a/examples/fastmcp/screenshot.py b/examples/fastmcp/screenshot.py index 34c9a6155..694b49f2f 100644 --- a/examples/fastmcp/screenshot.py +++ b/examples/fastmcp/screenshot.py @@ -5,10 +5,10 @@ """ import io + from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.utilities.types import Image - # Create server mcp = FastMCP("Screenshot Demo", dependencies=["pyautogui", "Pillow"]) diff --git a/examples/fastmcp/simple_echo.py b/examples/fastmcp/simple_echo.py index a9aa7d241..c26152646 100644 --- a/examples/fastmcp/simple_echo.py +++ b/examples/fastmcp/simple_echo.py @@ -4,7 +4,6 @@ from mcp.server.fastmcp import FastMCP - # Create server mcp = FastMCP("Echo Server") diff --git a/examples/fastmcp/text_me.py b/examples/fastmcp/text_me.py index 803f024d0..8053c6cc5 100644 --- a/examples/fastmcp/text_me.py +++ b/examples/fastmcp/text_me.py @@ -19,6 +19,7 @@ """ from typing import Annotated + import httpx from pydantic import BeforeValidator from pydantic_settings import BaseSettings, SettingsConfigDict diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 6009c7b89..9864fc519 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -1,8 +1,8 @@ import anyio import click import mcp.types as types -from pydantic import AnyUrl from mcp.server.lowlevel import Server +from pydantic import AnyUrl SAMPLE_RESOURCES = { "greeting": "Hello! This is a sample text resource.", diff --git a/pyproject.toml b/pyproject.toml index 4d532fdf2..f64f3f454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ dev-dependencies = [ "trio>=0.26.2", "pytest-flakefinder>=1.1.0", "pytest-xdist>=3.6.1", - "pytest-asyncio>=0.24.0", ] [build-system] diff --git a/src/mcp/cli/__init__.py b/src/mcp/cli/__init__.py index 4de805855..3ef56d806 100644 --- a/src/mcp/cli/__init__.py +++ b/src/mcp/cli/__init__.py @@ -2,6 +2,5 @@ from .cli import app - if __name__ == "__main__": app() diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index 7182d4ad6..9e2ef6c8f 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -48,8 +48,8 @@ def update_claude_config( config_dir = get_claude_config_path() if not config_dir: raise RuntimeError( - "Claude Desktop config directory not found. Please ensure Claude Desktop " - "is installed and has been run at least once to initialize its configuration." + "Claude Desktop config directory not found. Please ensure Claude Desktop" + " is installed and has been run at least once to initialize its config." ) config_file = config_dir / "claude_desktop_config.json" diff --git a/src/mcp/cli/cli.py b/src/mcp/cli/cli.py index c1728ae4a..3e164d9ff 100644 --- a/src/mcp/cli/cli.py +++ b/src/mcp/cli/cli.py @@ -295,7 +295,8 @@ def run( """Run a MCP server. The server can be specified in two ways: - 1. Module approach: server.py - runs the module directly, expecting a server.run() call + 1. Module approach: server.py - runs the module directly, expecting a server.run() + call 2. Import approach: server.py:app - imports and runs the specified server object Note: This command runs the server directly. You are responsible for ensuring @@ -346,7 +347,8 @@ def install( typer.Option( "--name", "-n", - help="Custom name for the server (defaults to server's name attribute or file name)", + help="Custom name for the server (defaults to server's name attribute or" + " file name)", ), ] = None, with_editable: Annotated[ @@ -410,7 +412,8 @@ def install( logger.error("Claude app not found") sys.exit(1) - # Try to import server to get its name, but fall back to file name if dependencies missing + # Try to import server to get its name, but fall back to file name if dependencies + # missing name = server_name server = None if not name: @@ -419,7 +422,8 @@ def install( name = server.name except (ImportError, ModuleNotFoundError) as e: logger.debug( - "Could not import server (likely missing dependencies), using file name", + "Could not import server (likely missing dependencies), using file" + " name", extra={"error": str(e)}, ) name = file.stem diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index 4db3e6de1..8ffbe1ec4 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,4 +1,4 @@ -from .lowlevel import Server, NotificationOptions from .fastmcp import FastMCP +from .lowlevel import NotificationOptions, Server __all__ = ["Server", "FastMCP", "NotificationOptions"] diff --git a/src/mcp/server/fastmcp/__init__.py b/src/mcp/server/fastmcp/__init__.py index 4ff1a05df..84b052078 100644 --- a/src/mcp/server/fastmcp/__init__.py +++ b/src/mcp/server/fastmcp/__init__.py @@ -1,7 +1,8 @@ """FastMCP - A more ergonomic interface for MCP servers.""" from importlib.metadata import version -from .server import FastMCP, Context + +from .server import Context, FastMCP from .utilities.types import Image __version__ = version("mcp") diff --git a/src/mcp/server/fastmcp/prompts/base.py b/src/mcp/server/fastmcp/prompts/base.py index 8358f4b4f..0df3d2fd3 100644 --- a/src/mcp/server/fastmcp/prompts/base.py +++ b/src/mcp/server/fastmcp/prompts/base.py @@ -1,13 +1,14 @@ """Base classes for FastMCP prompts.""" -import json -from typing import Any, Literal, Sequence, Awaitable import inspect +import json from collections.abc import Callable +from typing import Any, Awaitable, Literal, Sequence -from pydantic import BaseModel, Field, TypeAdapter, validate_call -from mcp.types import TextContent, ImageContent, EmbeddedResource import pydantic_core +from pydantic import BaseModel, Field, TypeAdapter, validate_call + +from mcp.types import EmbeddedResource, ImageContent, TextContent CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource diff --git a/src/mcp/server/fastmcp/resources/__init__.py b/src/mcp/server/fastmcp/resources/__init__.py index 92deb8735..b5805fb34 100644 --- a/src/mcp/server/fastmcp/resources/__init__.py +++ b/src/mcp/server/fastmcp/resources/__init__.py @@ -1,14 +1,14 @@ from .base import Resource +from .resource_manager import ResourceManager +from .templates import ResourceTemplate from .types import ( - TextResource, BinaryResource, - FunctionResource, + DirectoryResource, FileResource, + FunctionResource, HttpResource, - DirectoryResource, + TextResource, ) -from .templates import ResourceTemplate -from .resource_manager import ResourceManager __all__ = [ "Resource", diff --git a/src/mcp/server/fastmcp/resources/resource_manager.py b/src/mcp/server/fastmcp/resources/resource_manager.py index 1f9561e69..ded34bf0b 100644 --- a/src/mcp/server/fastmcp/resources/resource_manager.py +++ b/src/mcp/server/fastmcp/resources/resource_manager.py @@ -1,7 +1,6 @@ """Resource manager functionality.""" from typing import Callable -from collections.abc import Iterable from pydantic import AnyUrl diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index b1a8088d4..97de7c29c 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -1,11 +1,11 @@ """Concrete resource implementations.""" -import anyio import json -from pathlib import Path -from typing import Any, Callable from collections.abc import Callable +from pathlib import Path +from typing import Any +import anyio import httpx import pydantic.json import pydantic_core diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index b00627e8f..f0909287d 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -1,17 +1,25 @@ """FastMCP - A more ergonomic interface for MCP servers.""" -import anyio import functools import inspect import json import re from itertools import chain from typing import Any, Callable, Literal, Sequence -from collections.abc import Iterable +import anyio import pydantic_core -from pydantic import Field import uvicorn +from pydantic import BaseModel, Field +from pydantic.networks import AnyUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.fastmcp.exceptions import ResourceError +from mcp.server.fastmcp.prompts import Prompt, PromptManager +from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager +from mcp.server.fastmcp.tools import ToolManager +from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger +from mcp.server.fastmcp.utilities.types import Image from mcp.server.lowlevel import Server as MCPServer from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server @@ -24,6 +32,8 @@ ) from mcp.types import ( Prompt as MCPPrompt, +) +from mcp.types import ( PromptArgument as MCPPromptArgument, ) from mcp.types import ( @@ -35,16 +45,6 @@ from mcp.types import ( Tool as MCPTool, ) -from pydantic import BaseModel -from pydantic.networks import AnyUrl -from pydantic_settings import BaseSettings, SettingsConfigDict - -from mcp.server.fastmcp.exceptions import ResourceError -from mcp.server.fastmcp.prompts import Prompt, PromptManager -from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager -from mcp.server.fastmcp.tools import ToolManager -from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger -from mcp.server.fastmcp.utilities.types import Image logger = get_logger(__name__) @@ -226,8 +226,9 @@ def add_tool( def tool(self, name: str | None = None, description: str | None = None) -> Callable: """Decorator to register a tool. - Tools can optionally request a Context object by adding a parameter with the Context type annotation. - The context provides access to MCP capabilities like logging, progress reporting, and resource access. + Tools can optionally request a Context object by adding a parameter with the + Context type annotation. The context provides access to MCP capabilities like + logging, progress reporting, and resource access. Args: name: Optional name for the tool (defaults to function name) diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 8f2ea482e..a8751a5f1 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -1,12 +1,12 @@ -import mcp.server.fastmcp -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.utilities.func_metadata import func_metadata, FuncMetadata -from pydantic import BaseModel, Field - - import inspect from typing import TYPE_CHECKING, Any, Callable +from pydantic import BaseModel, Field + +import mcp.server.fastmcp +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata + if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -19,7 +19,8 @@ class Tool(BaseModel): description: str = Field(description="Description of what the tool does") parameters: dict = Field(description="JSON schema for tool parameters") fn_metadata: FuncMetadata = Field( - description="Metadata about the function including a pydantic model for tool arguments" + description="Metadata about the function including a pydantic model for tool" + " arguments" ) is_async: bool = Field(description="Whether the tool is async") context_kwarg: str | None = Field( diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 52b45d1e1..807c26b0c 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -1,9 +1,8 @@ -from mcp.server.fastmcp.exceptions import ToolError -from mcp.server.fastmcp.tools.base import Tool - -from typing import Any, Callable, TYPE_CHECKING from collections.abc import Callable +from typing import TYPE_CHECKING, Any +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.fastmcp.tools.base import Tool from mcp.server.fastmcp.utilities.logging import get_logger if TYPE_CHECKING: diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index b1f13854e..cf93049e3 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -1,21 +1,19 @@ import inspect -from collections.abc import Callable, Sequence, Awaitable +import json +from collections.abc import Awaitable, Callable, Sequence from typing import ( Annotated, Any, ForwardRef, ) -from pydantic import Field -from mcp.server.fastmcp.exceptions import InvalidSignature + +from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model from pydantic._internal._typing_extra import eval_type_backport -import json -from pydantic import BaseModel from pydantic.fields import FieldInfo -from pydantic import ConfigDict, create_model -from pydantic import WithJsonSchema from pydantic_core import PydanticUndefined -from mcp.server.fastmcp.utilities.logging import get_logger +from mcp.server.fastmcp.exceptions import InvalidSignature +from mcp.server.fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -105,7 +103,8 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadata: - """Given a function, return metadata including a pydantic model representing its signature. + """Given a function, return metadata including a pydantic model representing its + signature. The use case for this is ``` @@ -114,7 +113,8 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat return func(**validated_args.model_dump_one_level()) ``` - **critically** it also provides pre-parse helper to attempt to parse things from JSON. + **critically** it also provides pre-parse helper to attempt to parse things from + JSON. Args: func: The function to convert to a pydantic model @@ -130,7 +130,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat for param in params.values(): if param.name.startswith("_"): raise InvalidSignature( - f"Parameter {param.name} of {func.__name__} may not start with an underscore" + f"Parameter {param.name} of {func.__name__} cannot start with '_'" ) if param.name in skip_names: continue diff --git a/src/mcp/server/fastmcp/utilities/logging.py b/src/mcp/server/fastmcp/utilities/logging.py index 60738f8e2..df9da433a 100644 --- a/src/mcp/server/fastmcp/utilities/logging.py +++ b/src/mcp/server/fastmcp/utilities/logging.py @@ -3,6 +3,7 @@ import logging from typing import Literal + def get_logger(name: str) -> logging.Logger: """Get a logger nested under MCPnamespace. @@ -27,6 +28,7 @@ def configure_logging( try: from rich.console import Console from rich.logging import RichHandler + handlers.append(RichHandler(console=Console(stderr=True), rich_tracebacks=True)) except ImportError: pass diff --git a/src/mcp/server/lowlevel/__init__.py b/src/mcp/server/lowlevel/__init__.py index a6dff439e..66df38991 100644 --- a/src/mcp/server/lowlevel/__init__.py +++ b/src/mcp/server/lowlevel/__init__.py @@ -1,3 +1,3 @@ -from .server import Server, NotificationOptions +from .server import NotificationOptions, Server __all__ = ["Server", "NotificationOptions"] diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/fastmcp/prompts/test_base.py index 63dc230ea..bb47d6d38 100644 --- a/tests/server/fastmcp/prompts/test_base.py +++ b/tests/server/fastmcp/prompts/test_base.py @@ -1,16 +1,18 @@ -from pydantic import FileUrl import pytest +from pydantic import FileUrl + from mcp.server.fastmcp.prompts.base import ( - Prompt, - UserMessage, - TextContent, AssistantMessage, Message, + Prompt, + TextContent, + UserMessage, ) from mcp.types import EmbeddedResource, TextResourceContents class TestRenderPrompt: + @pytest.mark.anyio async def test_basic_fn(self): def fn() -> str: return "Hello, world!" @@ -20,6 +22,7 @@ def fn() -> str: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] + @pytest.mark.anyio async def test_async_fn(self): async def fn() -> str: return "Hello, world!" @@ -29,6 +32,7 @@ async def fn() -> str: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] + @pytest.mark.anyio async def test_fn_with_args(self): async def fn(name: str, age: int = 30) -> str: return f"Hello, {name}! You're {age} years old." @@ -42,6 +46,7 @@ async def fn(name: str, age: int = 30) -> str: ) ] + @pytest.mark.anyio async def test_fn_with_invalid_kwargs(self): async def fn(name: str, age: int = 30) -> str: return f"Hello, {name}! You're {age} years old." @@ -50,6 +55,7 @@ async def fn(name: str, age: int = 30) -> str: with pytest.raises(ValueError): await prompt.render(arguments=dict(age=40)) + @pytest.mark.anyio async def test_fn_returns_message(self): async def fn() -> UserMessage: return UserMessage(content="Hello, world!") @@ -59,6 +65,7 @@ async def fn() -> UserMessage: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] + @pytest.mark.anyio async def test_fn_returns_assistant_message(self): async def fn() -> AssistantMessage: return AssistantMessage( @@ -70,6 +77,7 @@ async def fn() -> AssistantMessage: AssistantMessage(content=TextContent(type="text", text="Hello, world!")) ] + @pytest.mark.anyio async def test_fn_returns_multiple_messages(self): expected = [ UserMessage("Hello, world!"), @@ -83,6 +91,7 @@ async def fn() -> list[Message]: prompt = Prompt.from_function(fn) assert await prompt.render() == expected + @pytest.mark.anyio async def test_fn_returns_list_of_strings(self): expected = [ "Hello, world!", @@ -95,6 +104,7 @@ async def fn() -> list[str]: prompt = Prompt.from_function(fn) assert await prompt.render() == [UserMessage(t) for t in expected] + @pytest.mark.anyio async def test_fn_returns_resource_content(self): """Test returning a message with resource content.""" @@ -124,6 +134,7 @@ async def fn() -> UserMessage: ) ] + @pytest.mark.anyio async def test_fn_returns_mixed_content(self): """Test returning messages with mixed content types.""" @@ -163,6 +174,7 @@ async def fn() -> list[Message]: ), ] + @pytest.mark.anyio async def test_fn_returns_dict_with_resource(self): """Test returning a dict with resource content.""" diff --git a/tests/server/fastmcp/prompts/test_manager.py b/tests/server/fastmcp/prompts/test_manager.py index 7b97b302a..c64a4a564 100644 --- a/tests/server/fastmcp/prompts/test_manager.py +++ b/tests/server/fastmcp/prompts/test_manager.py @@ -1,5 +1,6 @@ import pytest -from mcp.server.fastmcp.prompts.base import UserMessage, TextContent, Prompt + +from mcp.server.fastmcp.prompts.base import Prompt, TextContent, UserMessage from mcp.server.fastmcp.prompts.manager import PromptManager @@ -60,6 +61,7 @@ def fn2() -> str: assert len(prompts) == 2 assert prompts == [prompt1, prompt2] + @pytest.mark.anyio async def test_render_prompt(self): """Test rendering a prompt.""" @@ -74,6 +76,7 @@ def fn() -> str: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] + @pytest.mark.anyio async def test_render_prompt_with_args(self): """Test rendering a prompt with arguments.""" @@ -88,12 +91,14 @@ def fn(name: str) -> str: UserMessage(content=TextContent(type="text", text="Hello, World!")) ] + @pytest.mark.anyio async def test_render_unknown_prompt(self): """Test rendering a non-existent prompt.""" manager = PromptManager() with pytest.raises(ValueError, match="Unknown prompt: unknown"): await manager.render_prompt("unknown") + @pytest.mark.anyio async def test_render_prompt_with_missing_args(self): """Test rendering a prompt with missing required arguments.""" diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/fastmcp/resources/test_file_resources.py index f9ec15920..36cbca32c 100644 --- a/tests/server/fastmcp/resources/test_file_resources.py +++ b/tests/server/fastmcp/resources/test_file_resources.py @@ -1,8 +1,8 @@ import os - -import pytest from pathlib import Path from tempfile import NamedTemporaryFile + +import pytest from pydantic import FileUrl from mcp.server.fastmcp.resources import FileResource @@ -53,6 +53,7 @@ def test_file_resource_str_path_conversion(self, temp_file: Path): assert isinstance(resource.path, Path) assert resource.path.is_absolute() + @pytest.mark.anyio async def test_read_text_file(self, temp_file: Path): """Test reading a text file.""" resource = FileResource( @@ -64,6 +65,7 @@ async def test_read_text_file(self, temp_file: Path): assert content == "test content" assert resource.mime_type == "text/plain" + @pytest.mark.anyio async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" resource = FileResource( @@ -85,6 +87,7 @@ def test_relative_path_error(self): path=Path("test.txt"), ) + @pytest.mark.anyio async def test_missing_file_error(self, temp_file: Path): """Test error when file doesn't exist.""" # Create path to non-existent file @@ -100,6 +103,7 @@ async def test_missing_file_error(self, temp_file: Path): @pytest.mark.skipif( os.name == "nt", reason="File permissions behave differently on Windows" ) + @pytest.mark.anyio async def test_permission_error(self, temp_file: Path): """Test reading a file without permissions.""" temp_file.chmod(0o000) # Remove all permissions diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py index e132e5f15..b92af5c3a 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel, AnyUrl import pytest +from pydantic import AnyUrl, BaseModel + from mcp.server.fastmcp.resources import FunctionResource @@ -24,6 +25,7 @@ def my_func() -> str: assert resource.mime_type == "text/plain" # default assert resource.fn == my_func + @pytest.mark.anyio async def test_read_text(self): """Test reading text from a FunctionResource.""" @@ -39,6 +41,7 @@ def get_data() -> str: assert content == "Hello, world!" assert resource.mime_type == "text/plain" + @pytest.mark.anyio async def test_read_binary(self): """Test reading binary data from a FunctionResource.""" @@ -53,6 +56,7 @@ def get_data() -> bytes: content = await resource.read() assert content == b"Hello, world!" + @pytest.mark.anyio async def test_json_conversion(self): """Test automatic JSON conversion of non-string results.""" @@ -68,6 +72,7 @@ def get_data() -> dict: assert isinstance(content, str) assert '"key": "value"' in content + @pytest.mark.anyio async def test_error_handling(self): """Test error handling in FunctionResource.""" @@ -82,6 +87,7 @@ def failing_func() -> str: with pytest.raises(ValueError, match="Error reading resource function://test"): await resource.read() + @pytest.mark.anyio async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" @@ -96,6 +102,7 @@ class MyModel(BaseModel): content = await resource.read() assert content == '{"name": "test"}' + @pytest.mark.anyio async def test_custom_type_conversion(self): """Test handling of custom types.""" diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index d8d04e569..4423e5315 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -1,6 +1,7 @@ -import pytest from pathlib import Path from tempfile import NamedTemporaryFile + +import pytest from pydantic import AnyUrl, FileUrl from mcp.server.fastmcp.resources import ( @@ -80,6 +81,7 @@ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): manager.add_resource(resource) assert "Resource already exists" not in caplog.text + @pytest.mark.anyio async def test_get_resource(self, temp_file: Path): """Test getting a resource by URI.""" manager = ResourceManager() @@ -92,6 +94,7 @@ async def test_get_resource(self, temp_file: Path): retrieved = await manager.get_resource(resource.uri) assert retrieved == resource + @pytest.mark.anyio async def test_get_resource_from_template(self): """Test getting a resource through a template.""" manager = ResourceManager() @@ -111,6 +114,7 @@ def greet(name: str) -> str: content = await resource.read() assert content == "Hello, world!" + @pytest.mark.anyio async def test_get_unknown_resource(self): """Test getting a non-existent resource.""" manager = ResourceManager() diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index 95d058501..09bc600d0 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -1,4 +1,5 @@ import json + import pytest from pydantic import BaseModel @@ -45,6 +46,7 @@ def my_func(key: str, value: int) -> dict: assert template.matches("test://foo") is None assert template.matches("other://foo/123") is None + @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template.""" @@ -68,6 +70,7 @@ def my_func(key: str, value: int) -> dict: data = json.loads(content) assert data == {"key": "foo", "value": 123} + @pytest.mark.anyio async def test_template_error(self): """Test error handling in template resource creation.""" @@ -83,6 +86,7 @@ def failing_func(x: str) -> str: with pytest.raises(ValueError, match="Error creating resource from template"): await template.create_resource("fail://test", {"x": "test"}) + @pytest.mark.anyio async def test_async_text_resource(self): """Test creating a text resource from async function.""" @@ -104,6 +108,7 @@ async def greet(name: str) -> str: content = await resource.read() assert content == "Hello, world!" + @pytest.mark.anyio async def test_async_binary_resource(self): """Test creating a binary resource from async function.""" @@ -125,6 +130,7 @@ async def get_bytes(value: str) -> bytes: content = await resource.read() assert content == b"test" + @pytest.mark.anyio async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" @@ -152,6 +158,7 @@ def get_data(key: str, value: int) -> MyModel: data = json.loads(content) assert data == {"key": "foo", "value": 123} + @pytest.mark.anyio async def test_custom_type_conversion(self): """Test handling of custom types.""" diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py index dddcd561f..08b3e65e1 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/fastmcp/resources/test_resources.py @@ -90,6 +90,7 @@ def dummy_func() -> str: ) assert resource.mime_type == "application/json" + @pytest.mark.anyio async def test_resource_read_abstract(self): """Test that Resource.read() is abstract.""" diff --git a/tests/server/fastmcp/servers/test_file_server.py b/tests/server/fastmcp/servers/test_file_server.py index d9b34d667..28773b1d5 100644 --- a/tests/server/fastmcp/servers/test_file_server.py +++ b/tests/server/fastmcp/servers/test_file_server.py @@ -1,8 +1,10 @@ import json -from mcp.server.fastmcp import FastMCP -import pytest from pathlib import Path +import pytest + +from mcp.server.fastmcp import FastMCP + @pytest.fixture() def test_dir(tmp_path_factory) -> Path: @@ -71,6 +73,7 @@ def delete_file(path: str) -> bool: return mcp +@pytest.mark.anyio async def test_list_resources(mcp: FastMCP): resources = await mcp.list_resources() assert len(resources) == 4 @@ -83,6 +86,7 @@ async def test_list_resources(mcp: FastMCP): ] +@pytest.mark.anyio async def test_read_resource_dir(mcp: FastMCP): files = await mcp.read_resource("dir://test_dir") files = json.loads(files) @@ -94,11 +98,13 @@ async def test_read_resource_dir(mcp: FastMCP): ] +@pytest.mark.anyio async def test_read_resource_file(mcp: FastMCP): result = await mcp.read_resource("file://test_dir/example.py") assert result == "print('hello world')" +@pytest.mark.anyio async def test_delete_file(mcp: FastMCP, test_dir: Path): await mcp.call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) @@ -106,6 +112,7 @@ async def test_delete_file(mcp: FastMCP, test_dir: Path): assert not (test_dir / "example.py").exists() +@pytest.mark.anyio async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): await mcp.call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 044798d2e..8619e4737 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -85,6 +85,7 @@ def complex_arguments_fn( return "ok!" +@pytest.mark.anyio async def test_complex_function_runtime_arg_validation_non_json(): """Test that basic non-JSON arguments are validated correctly""" meta = func_metadata(complex_arguments_fn) @@ -121,6 +122,7 @@ async def test_complex_function_runtime_arg_validation_non_json(): ) +@pytest.mark.anyio async def test_complex_function_runtime_arg_validation_with_json(): """Test that JSON string arguments are parsed and validated correctly""" meta = func_metadata(complex_arguments_fn) @@ -140,7 +142,7 @@ async def test_complex_function_runtime_arg_validation_with_json(): "unannotated": "test", "my_model_a": "{}", # JSON string "my_model_a_forward_ref": "{}", # JSON string - "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', # JSON string + "my_model_b": '{"how_many_shrimp": 5, "ok": {"x": 1}, "y": null}', }, arguments_to_pass_directly=None, ) @@ -197,6 +199,7 @@ def func_with_many_params( assert model.also_keep == 2.5 # type: ignore +@pytest.mark.anyio async def test_lambda_function(): """Test lambda function schema and validation""" fn = lambda x, y=5: x # noqa: E731 @@ -297,7 +300,7 @@ def test_complex_function_json_schema(): }, "field_with_default_via_field_annotation_before_nondefault_arg": { "default": 1, - "title": "Field With Default Via Field Annotation Before Nondefault Arg", + "title": "Field With Default Via Field Annotation Before Arg", "type": "integer", }, "unannotated": {"title": "unannotated", "type": "string"}, @@ -316,11 +319,7 @@ def test_complex_function_json_schema(): "type": "string", }, "my_model_a_with_default": { - "allOf": [ - { - "$ref": "#/$defs/SomeInputModelA" - } - ], + "allOf": [{"$ref": "#/$defs/SomeInputModelA"}], "default": {}, }, "an_int_with_default": { diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index 27e13f28d..f5aa4cd5e 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -3,32 +3,34 @@ from typing import TYPE_CHECKING, Union import pytest +from pydantic import AnyUrl + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage +from mcp.server.fastmcp.resources import FileResource, FunctionResource +from mcp.server.fastmcp.utilities.types import Image from mcp.shared.exceptions import McpError from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) from mcp.types import ( + BlobResourceContents, ImageContent, TextContent, TextResourceContents, - BlobResourceContents, ) -from pydantic import AnyUrl - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.fastmcp.prompts.base import EmbeddedResource, Message, UserMessage -from mcp.server.fastmcp.resources import FileResource, FunctionResource -from mcp.server.fastmcp.utilities.types import Image if TYPE_CHECKING: from mcp.server.fastmcp import Context class TestServer: + @pytest.mark.anyio async def test_create_server(self): mcp = FastMCP() assert mcp.name == "FastMCP" + @pytest.mark.anyio async def test_add_tool_decorator(self): mcp = FastMCP() @@ -38,6 +40,7 @@ def add(x: int, y: int) -> int: assert len(mcp._tool_manager.list_tools()) == 1 + @pytest.mark.anyio async def test_add_tool_decorator_incorrect_usage(self): mcp = FastMCP() @@ -47,6 +50,7 @@ async def test_add_tool_decorator_incorrect_usage(self): def add(x: int, y: int) -> int: return x + y + @pytest.mark.anyio async def test_add_resource_decorator(self): mcp = FastMCP() @@ -56,6 +60,7 @@ def get_data(x: str) -> str: assert len(mcp._resource_manager._templates) == 1 + @pytest.mark.anyio async def test_add_resource_decorator_incorrect_usage(self): mcp = FastMCP() @@ -88,12 +93,14 @@ def mixed_content_tool_fn() -> list[Union[TextContent, ImageContent]]: class TestServerTools: + @pytest.mark.anyio async def test_add_tool(self): mcp = FastMCP() mcp.add_tool(tool_fn) mcp.add_tool(tool_fn) assert len(mcp._tool_manager.list_tools()) == 1 + @pytest.mark.anyio async def test_list_tools(self): mcp = FastMCP() mcp.add_tool(tool_fn) @@ -101,6 +108,7 @@ async def test_list_tools(self): tools = await client.list_tools() assert len(tools.tools) == 1 + @pytest.mark.anyio async def test_call_tool(self): mcp = FastMCP() mcp.add_tool(tool_fn) @@ -109,6 +117,7 @@ async def test_call_tool(self): assert not hasattr(result, "error") assert len(result.content) > 0 + @pytest.mark.anyio async def test_tool_exception_handling(self): mcp = FastMCP() mcp.add_tool(error_tool_fn) @@ -120,6 +129,7 @@ async def test_tool_exception_handling(self): assert "Test error" in content.text assert result.isError is True + @pytest.mark.anyio async def test_tool_error_handling(self): mcp = FastMCP() mcp.add_tool(error_tool_fn) @@ -131,6 +141,7 @@ async def test_tool_error_handling(self): assert "Test error" in content.text assert result.isError is True + @pytest.mark.anyio async def test_tool_error_details(self): """Test that exception details are properly formatted in the response""" mcp = FastMCP() @@ -143,6 +154,7 @@ async def test_tool_error_details(self): assert "Test error" in content.text assert result.isError is True + @pytest.mark.anyio async def test_tool_return_value_conversion(self): mcp = FastMCP() mcp.add_tool(tool_fn) @@ -153,6 +165,7 @@ async def test_tool_return_value_conversion(self): assert isinstance(content, TextContent) assert content.text == "3" + @pytest.mark.anyio async def test_tool_image_helper(self, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" @@ -171,6 +184,7 @@ async def test_tool_image_helper(self, tmp_path: Path): decoded = base64.b64decode(content.data) assert decoded == b"fake png data" + @pytest.mark.anyio async def test_tool_mixed_content(self): mcp = FastMCP() mcp.add_tool(mixed_content_tool_fn) @@ -185,8 +199,10 @@ async def test_tool_mixed_content(self): assert content2.mimeType == "image/png" assert content2.data == "abc" + @pytest.mark.anyio async def test_tool_mixed_list_with_image(self, tmp_path: Path): - """Test that lists containing Image objects and other types are handled correctly""" + """Test that lists containing Image objects and other types are handled + correctly""" # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"test image data") @@ -224,6 +240,7 @@ def mixed_list_fn() -> list: class TestServerResources: + @pytest.mark.anyio async def test_text_resource(self): mcp = FastMCP() @@ -240,6 +257,7 @@ def get_text(): assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" + @pytest.mark.anyio async def test_binary_resource(self): mcp = FastMCP() @@ -259,6 +277,7 @@ def get_binary(): assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() + @pytest.mark.anyio async def test_file_resource_text(self, tmp_path: Path): mcp = FastMCP() @@ -276,6 +295,7 @@ async def test_file_resource_text(self, tmp_path: Path): assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" + @pytest.mark.anyio async def test_file_resource_binary(self, tmp_path: Path): mcp = FastMCP() @@ -301,6 +321,7 @@ async def test_file_resource_binary(self, tmp_path: Path): class TestServerResourceTemplates: + @pytest.mark.anyio async def test_resource_with_params(self): """Test that a resource with function parameters raises an error if the URI parameters don't match""" @@ -312,6 +333,7 @@ async def test_resource_with_params(self): def get_data_fn(param: str) -> str: return f"Data: {param}" + @pytest.mark.anyio async def test_resource_with_uri_params(self): """Test that a resource with URI parameters is automatically a template""" mcp = FastMCP() @@ -322,6 +344,7 @@ async def test_resource_with_uri_params(self): def get_data() -> str: return "Data" + @pytest.mark.anyio async def test_resource_with_untyped_params(self): """Test that a resource with untyped parameters raises an error""" mcp = FastMCP() @@ -330,6 +353,7 @@ async def test_resource_with_untyped_params(self): def get_data(param) -> str: return "Data" + @pytest.mark.anyio async def test_resource_matching_params(self): """Test that a resource with matching URI and function parameters works""" mcp = FastMCP() @@ -343,6 +367,7 @@ def get_data(name: str) -> str: assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" + @pytest.mark.anyio async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = FastMCP() @@ -353,6 +378,7 @@ async def test_resource_mismatched_params(self): def get_data(user: str) -> str: return f"Data for {user}" + @pytest.mark.anyio async def test_resource_multiple_params(self): """Test that multiple parameters work correctly""" mcp = FastMCP() @@ -368,6 +394,7 @@ def get_data(org: str, repo: str) -> str: assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/fastmcp" + @pytest.mark.anyio async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" mcp = FastMCP() @@ -390,6 +417,7 @@ def get_static_data() -> str: assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" + @pytest.mark.anyio async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" mcp = FastMCP() @@ -412,6 +440,7 @@ def get_data(name: str) -> str: class TestContextInjection: """Test context injection in tools.""" + @pytest.mark.anyio async def test_context_detection(self): """Test that context parameters are properly detected.""" mcp = FastMCP() @@ -422,6 +451,7 @@ def tool_with_context(x: int, ctx: Context) -> str: tool = mcp._tool_manager.add_tool(tool_with_context) assert tool.context_kwarg == "ctx" + @pytest.mark.anyio async def test_context_injection(self): """Test that context is properly injected into tool calls.""" mcp = FastMCP() @@ -439,6 +469,7 @@ def tool_with_context(x: int, ctx: Context) -> str: assert "Request" in content.text assert "42" in content.text + @pytest.mark.anyio async def test_async_context(self): """Test that context works in async functions.""" mcp = FastMCP() @@ -456,6 +487,7 @@ async def async_tool(x: int, ctx: Context) -> str: assert "Async request" in content.text assert "42" in content.text + @pytest.mark.anyio async def test_context_logging(self): """Test that context logging methods work.""" mcp = FastMCP() @@ -475,6 +507,7 @@ def logging_tool(msg: str, ctx: Context) -> str: assert isinstance(content, TextContent) assert "Logged messages for test" in content.text + @pytest.mark.anyio async def test_optional_context(self): """Test that context is optional.""" mcp = FastMCP() @@ -490,6 +523,7 @@ def no_context(x: int) -> int: assert isinstance(content, TextContent) assert content.text == "42" + @pytest.mark.anyio async def test_context_resource_access(self): """Test that context can access resources.""" mcp = FastMCP() @@ -514,6 +548,7 @@ async def tool_with_resource(ctx: Context) -> str: class TestServerPrompts: """Test prompt functionality in FastMCP server.""" + @pytest.mark.anyio async def test_prompt_decorator(self): """Test that the prompt decorator registers prompts correctly.""" mcp = FastMCP() @@ -530,6 +565,7 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" + @pytest.mark.anyio async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" mcp = FastMCP() @@ -545,6 +581,7 @@ def fn() -> str: assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" + @pytest.mark.anyio async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" mcp = FastMCP() @@ -569,6 +606,7 @@ def test_prompt_decorator_error(self): def fn() -> str: return "Hello, world!" + @pytest.mark.anyio async def test_list_prompts(self): """Test listing prompts through MCP protocol.""" mcp = FastMCP() @@ -590,6 +628,7 @@ def fn(name: str, optional: str = "default") -> str: assert prompt.arguments[1].name == "optional" assert prompt.arguments[1].required is False + @pytest.mark.anyio async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" mcp = FastMCP() @@ -607,6 +646,7 @@ def fn(name: str) -> str: assert isinstance(content, TextContent) assert content.text == "Hello, World!" + @pytest.mark.anyio async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" mcp = FastMCP() @@ -636,6 +676,7 @@ def fn() -> Message: assert resource.text == "File contents" assert resource.mimeType == "text/plain" + @pytest.mark.anyio async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = FastMCP() @@ -643,6 +684,7 @@ async def test_get_unknown_prompt(self): with pytest.raises(McpError, match="Unknown prompt"): await client.get_prompt("unknown") + @pytest.mark.anyio async def test_get_prompt_missing_args(self): """Test error when required arguments are missing.""" mcp = FastMCP() diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index 884059a63..4adfc47bf 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -1,9 +1,10 @@ +import json import logging from typing import Optional import pytest from pydantic import BaseModel -import json + from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.tools import ToolManager @@ -27,6 +28,7 @@ def add(a: int, b: int) -> int: assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" + @pytest.mark.anyio async def test_async_function(self): """Test registering and running an async function.""" @@ -111,6 +113,7 @@ def f(x: int) -> int: class TestCallTools: + @pytest.mark.anyio async def test_call_tool(self): def add(a: int, b: int) -> int: """Add two numbers.""" @@ -121,6 +124,7 @@ def add(a: int, b: int) -> int: result = await manager.call_tool("add", {"a": 1, "b": 2}) assert result == 3 + @pytest.mark.anyio async def test_call_async_tool(self): async def double(n: int) -> int: """Double a number.""" @@ -131,6 +135,7 @@ async def double(n: int) -> int: result = await manager.call_tool("double", {"n": 5}) assert result == 10 + @pytest.mark.anyio async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: """Add two numbers.""" @@ -141,6 +146,7 @@ def add(a: int, b: int = 1) -> int: result = await manager.call_tool("add", {"a": 1}) assert result == 2 + @pytest.mark.anyio async def test_call_tool_with_missing_args(self): def add(a: int, b: int) -> int: """Add two numbers.""" @@ -151,11 +157,13 @@ def add(a: int, b: int) -> int: with pytest.raises(ToolError): await manager.call_tool("add", {"a": 1}) + @pytest.mark.anyio async def test_call_unknown_tool(self): manager = ToolManager() with pytest.raises(ToolError): await manager.call_tool("unknown", {"a": 1}) + @pytest.mark.anyio async def test_call_tool_with_list_int_input(self): def sum_vals(vals: list[int]) -> int: return sum(vals) @@ -168,6 +176,7 @@ def sum_vals(vals: list[int]) -> int: result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) assert result == 6 + @pytest.mark.anyio async def test_call_tool_with_list_str_or_str_input(self): def concat_strs(vals: list[str] | str) -> str: return vals if isinstance(vals, str) else "".join(vals) @@ -184,6 +193,7 @@ def concat_strs(vals: list[str] | str) -> str: result = await manager.call_tool("concat_strs", {"vals": '"a"'}) assert result == '"a"' + @pytest.mark.anyio async def test_call_tool_with_complex_model(self): from mcp.server.fastmcp import Context @@ -212,6 +222,7 @@ def name_shrimp(tank: MyShrimpTank, ctx: Context) -> list[str]: class TestToolSchema: + @pytest.mark.anyio async def test_context_arg_excluded_from_schema(self): from mcp.server.fastmcp import Context @@ -229,7 +240,8 @@ class TestContextHandling: """Test context handling in the tool manager.""" def test_context_parameter_detection(self): - """Test that context parameters are properly detected in Tool.from_function().""" + """Test that context parameters are properly detected in + Tool.from_function().""" from mcp.server.fastmcp import Context def tool_with_context(x: int, ctx: Context) -> str: @@ -245,6 +257,7 @@ def tool_without_context(x: int) -> str: tool = manager.add_tool(tool_without_context) assert tool.context_kwarg is None + @pytest.mark.anyio async def test_context_injection(self): """Test that context is properly injected during tool execution.""" from mcp.server.fastmcp import Context, FastMCP @@ -261,6 +274,7 @@ def tool_with_context(x: int, ctx: Context) -> str: result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) assert result == "42" + @pytest.mark.anyio async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" from mcp.server.fastmcp import Context, FastMCP @@ -277,6 +291,7 @@ async def async_tool(x: int, ctx: Context) -> str: result = await manager.call_tool("async_tool", {"x": 42}, context=ctx) assert result == "42" + @pytest.mark.anyio async def test_context_optional(self): """Test that context is optional when calling tools.""" from mcp.server.fastmcp import Context @@ -290,6 +305,7 @@ def tool_with_context(x: int, ctx: Optional[Context] = None) -> str: result = await manager.call_tool("tool_with_context", {"x": 42}) assert result == "42" + @pytest.mark.anyio async def test_context_error_handling(self): """Test error handling when context injection fails.""" from mcp.server.fastmcp import Context, FastMCP diff --git a/tests/test_examples.py b/tests/test_examples.py index 1a987cba4..ea8a256af 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,50 +1,54 @@ """Tests for example servers""" + import pytest -from mcp.shared.memory import create_connected_server_and_client_session as client_session + +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) + @pytest.mark.anyio async def test_simple_echo(): """Test the simple echo server""" from examples.fastmcp.simple_echo import mcp - + async with client_session(mcp._mcp_server) as client: result = await client.call_tool("echo", {"text": "hello"}) assert len(result.content) == 1 content = result.content[0] assert content.text == "hello" + @pytest.mark.anyio async def test_complex_inputs(): """Test the complex inputs server""" from examples.fastmcp.complex_inputs import mcp - + async with client_session(mcp._mcp_server) as client: - tank = { - "shrimp": [{"name": "bob"}, {"name": "alice"}] - } - result = await client.call_tool("name_shrimp", { - "tank": tank, - "extra_names": ["charlie"] - }) + tank = {"shrimp": [{"name": "bob"}, {"name": "alice"}]} + result = await client.call_tool( + "name_shrimp", {"tank": tank, "extra_names": ["charlie"]} + ) assert len(result.content) == 3 assert result.content[0].text == "bob" assert result.content[1].text == "alice" assert result.content[2].text == "charlie" + @pytest.mark.anyio async def test_desktop(): """Test the desktop server""" from examples.fastmcp.desktop import mcp - + async with client_session(mcp._mcp_server) as client: # Test the add function result = await client.call_tool("add", {"a": 1, "b": 2}) assert len(result.content) == 1 content = result.content[0] assert content.text == "3" - + # Test the desktop resource result = await client.read_resource("dir://desktop") assert len(result.contents) == 1 content = result.contents[0] - assert isinstance(content.text, str) \ No newline at end of file + assert isinstance(content.text, str) diff --git a/tests/test_types.py b/tests/test_types.py index c3981ad3e..a39d33412 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,3 +1,5 @@ +import pytest + from mcp.types import ( LATEST_PROTOCOL_VERSION, ClientRequest, @@ -6,7 +8,8 @@ ) -def test_jsonrpc_request(): +@pytest.mark.anyio +async def test_jsonrpc_request(): json_data = { "jsonrpc": "2.0", "id": 1, diff --git a/uv.lock b/uv.lock index dcc5cc1da..54c5b9816 100644 --- a/uv.lock +++ b/uv.lock @@ -216,7 +216,6 @@ rich = [ dev = [ { name = "pyright" }, { name = "pytest" }, - { name = "pytest-asyncio" }, { name = "pytest-flakefinder" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -241,7 +240,6 @@ requires-dist = [ dev = [ { name = "pyright", specifier = ">=1.1.378" }, { name = "pytest", specifier = ">=8.3.3" }, - { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-flakefinder", specifier = ">=1.1.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.6.9" }, @@ -526,18 +524,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] -[[package]] -name = "pytest-asyncio" -version = "0.24.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, -] - [[package]] name = "pytest-flakefinder" version = "1.1.0" From 561397c409b9cb2d74e41d2bcad0ca0494d3a235 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 22:42:25 +0000 Subject: [PATCH 14/20] remove comment --- examples/servers/simple-prompt/mcp_simple_prompt/server.py | 2 +- examples/servers/simple-tool/mcp_simple_tool/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index 1e4c9e18d..8427b12f8 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -1,7 +1,7 @@ import anyio import click import mcp.types as types -from mcp.server.lowlevel import Server # Using lowlevel API as mcp.server is deprecated +from mcp.server.lowlevel import Server def create_messages( diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 10796d571..79b0b8b52 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -2,7 +2,7 @@ import click import httpx import mcp.types as types -from mcp.server.lowlevel import Server # Using lowlevel API as mcp.server is deprecated +from mcp.server.lowlevel import Server async def fetch_website( From 0b09fa59447f7883ba46ceb2aef90454bc7bac97 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 23:19:41 +0000 Subject: [PATCH 15/20] fix type issues --- src/mcp/server/fastmcp/resources/types.py | 1 + tests/test_examples.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index 97de7c29c..79acf274f 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -6,6 +6,7 @@ from typing import Any import anyio +import anyio.to_thread import httpx import pydantic.json import pydantic_core diff --git a/tests/test_examples.py b/tests/test_examples.py index ea8a256af..62cd18c88 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,6 +5,7 @@ from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) +from mcp.types import TextContent @pytest.mark.anyio @@ -16,6 +17,7 @@ async def test_simple_echo(): result = await client.call_tool("echo", {"text": "hello"}) assert len(result.content) == 1 content = result.content[0] + assert isinstance(content, TextContent) assert content.text == "hello" @@ -30,6 +32,9 @@ async def test_complex_inputs(): "name_shrimp", {"tank": tank, "extra_names": ["charlie"]} ) assert len(result.content) == 3 + assert isinstance(result.content[0], TextContent) + assert isinstance(result.content[1], TextContent) + assert isinstance(result.content[2], TextContent) assert result.content[0].text == "bob" assert result.content[1].text == "alice" assert result.content[2].text == "charlie" @@ -38,6 +43,8 @@ async def test_complex_inputs(): @pytest.mark.anyio async def test_desktop(): """Test the desktop server""" + from pydantic import AnyUrl + from examples.fastmcp.desktop import mcp async with client_session(mcp._mcp_server) as client: @@ -45,10 +52,12 @@ async def test_desktop(): result = await client.call_tool("add", {"a": 1, "b": 2}) assert len(result.content) == 1 content = result.content[0] + assert isinstance(content, TextContent) assert content.text == "3" # Test the desktop resource - result = await client.read_resource("dir://desktop") + result = await client.read_resource(AnyUrl("dir://desktop")) assert len(result.contents) == 1 content = result.contents[0] + assert isinstance(content, TextContent) assert isinstance(content.text, str) From d2c3e21173b8af62dff2c37f18426b1f42f54e0b Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Thu, 19 Dec 2024 23:19:52 +0000 Subject: [PATCH 16/20] upgrade to pydantic 2.10.0 to fix type issues --- pyproject.toml | 4 +- uv.lock | 168 +++++++++++++++++++++++++++---------------------- 2 files changed, 95 insertions(+), 77 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f64f3f454..0c705cf7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "anyio>=4.5", "httpx>=0.27", "httpx-sse>=0.4", - "pydantic>=2.7.2,<3.0.0", + "pydantic>=2.10.0,<3.0.0", "starlette>=0.27", "sse-starlette>=1.6.1", "pydantic-settings>=2.6.1", @@ -43,7 +43,7 @@ resolution = "lowest-direct" dev-dependencies = [ "pyright>=1.1.378", "pytest>=8.3.3", - "ruff>=0.6.9", + "ruff>=0.8.1", "trio>=0.26.2", "pytest-flakefinder>=1.1.0", "pytest-xdist>=3.6.1", diff --git a/uv.lock b/uv.lock index 54c5b9816..c7de80bf2 100644 --- a/uv.lock +++ b/uv.lock @@ -227,7 +227,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, - { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, + { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, @@ -242,7 +242,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-flakefinder", specifier = ">=1.1.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "ruff", specifier = ">=0.6.9" }, + { name = "ruff", specifier = ">=0.8.1" }, { name = "trio", specifier = ">=0.26.2" }, ] @@ -404,73 +404,91 @@ wheels = [ [[package]] name = "pydantic" -version = "2.7.2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/ed/b7a827705eb2490ffb6752a0302e58611ea743d7140e7dafaedee2afc953/pydantic-2.7.2.tar.gz", hash = "sha256:71b2945998f9c9b7919a45bde9a50397b289937d215ae141c1d0903ba7149fd7", size = 714293 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/78/58c36d0cf331b659d0ccd99175e3523c457b4f8e67cb92a8fdc22ec1667c/pydantic-2.10.0.tar.gz", hash = "sha256:0aca0f045ff6e2f097f1fe89521115335f15049eeb8a7bef3dafe4b19a74e289", size = 781980 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/b9/ec44b1394957d5aa8d3a7c33f8304cd7670d10a43a286db56cec086346be/pydantic-2.7.2-py3-none-any.whl", hash = "sha256:834ab954175f94e6e68258537dc49402c4a5e9d0409b9f1b86b7e934a8372de7", size = 409545 }, + { url = "https://files.pythonhosted.org/packages/9e/ee/255cbfdbf5c47650de70ac8a5425107511f505ed0366c29d537f7f1842e1/pydantic-2.10.0-py3-none-any.whl", hash = "sha256:5e7807ba9201bdf61b1b58aa6eb690916c40a47acfb114b1b4fef3e7fd5b30fc", size = 454346 }, ] [[package]] name = "pydantic-core" -version = "2.18.3" +version = "2.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/75/6da10bfa9a317884a7b4bf0c42297aca72391ad69eb51b974bded53fddc0/pydantic_core-2.18.3.tar.gz", hash = "sha256:432e999088d85c8f36b9a3f769a8e2b57aabd817bbb729a90d1fe7f18f6f1f39", size = 384545 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/ff/61c330412137b46a55b2269d0a49fd8b90e29fb57b72760b8e09b49db896/pydantic_core-2.18.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:744697428fcdec6be5670460b578161d1ffe34743a5c15656be7ea82b008197c", size = 1832602 }, - { url = "https://files.pythonhosted.org/packages/7d/3d/1640253d1da28910b02b00bf6af4a80f1de27f561879128f76bbacb8436d/pydantic_core-2.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b40c05ced1ba4218b14986fe6f283d22e1ae2ff4c8e28881a70fb81fbfcda7", size = 1752322 }, - { url = "https://files.pythonhosted.org/packages/97/1f/0d18bac0a38f8f407c219d1b558e959efc94297c1f23810dba64a64624cc/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a9a75622357076efb6b311983ff190fbfb3c12fc3a853122b34d3d358126c", size = 1776174 }, - { url = "https://files.pythonhosted.org/packages/94/ea/ce0d90ff9a623e0fe8916bfd89b5fa49b2193493965e7a7787459c1ccb7c/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2e253af04ceaebde8eb201eb3f3e3e7e390f2d275a88300d6a1959d710539e2", size = 1767064 }, - { url = "https://files.pythonhosted.org/packages/29/23/13b0fb2419b6d21e5f0b7292e6c09720e913b068a441df32cf8cbbc16133/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:855ec66589c68aa367d989da5c4755bb74ee92ccad4fdb6af942c3612c067e34", size = 1964644 }, - { url = "https://files.pythonhosted.org/packages/97/44/22afcd3b8650e157c87d20b73f8a27c25f4f0f240bdc9eb5248bbcdc6f30/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d3e42bb54e7e9d72c13ce112e02eb1b3b55681ee948d748842171201a03a98a", size = 2815869 }, - { url = "https://files.pythonhosted.org/packages/a0/27/aeade6d7b2f2bcc8fc835bdf6aa705f6f34508da380f170e13cd37477dd4/pydantic_core-2.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6ac9ffccc9d2e69d9fba841441d4259cb668ac180e51b30d3632cd7abca2b9b", size = 2028872 }, - { url = "https://files.pythonhosted.org/packages/b1/0e/a8a462fade9a9a533a9379da246e3fe7d9383c5203b6f6862a54284ea744/pydantic_core-2.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c56eca1686539fa0c9bda992e7bd6a37583f20083c37590413381acfc5f192d6", size = 1891477 }, - { url = "https://files.pythonhosted.org/packages/35/1b/63c24026c6207b5aa5cd749af319891b5ac3139e2b5dd789bf4a9e95085e/pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:17954d784bf8abfc0ec2a633108207ebc4fa2df1a0e4c0c3ccbaa9bb01d2c426", size = 1996291 }, - { url = "https://files.pythonhosted.org/packages/79/34/05139583ecef8b5a0f5be8105b6b001016e054bcf63ac96a03790c4a790d/pydantic_core-2.18.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:98ed737567d8f2ecd54f7c8d4f8572ca7c7921ede93a2e52939416170d357812", size = 2097593 }, - { url = "https://files.pythonhosted.org/packages/a3/e3/0b53c3b8e71be2db70eb7bfb6811bd6d093aa41fdb1ccc9f7ea18b40287b/pydantic_core-2.18.3-cp310-none-win32.whl", hash = "sha256:9f9e04afebd3ed8c15d67a564ed0a34b54e52136c6d40d14c5547b238390e779", size = 1700454 }, - { url = "https://files.pythonhosted.org/packages/e2/67/85ee8a54220139159b14088dd40f4d43e60822f8d64bb2a5b9b04d673bd2/pydantic_core-2.18.3-cp310-none-win_amd64.whl", hash = "sha256:45e4ffbae34f7ae30d0047697e724e534a7ec0a82ef9994b7913a412c21462a0", size = 1889156 }, - { url = "https://files.pythonhosted.org/packages/4a/cf/2847167bab3e7676ba6f0b49963ba04112b1e4281d8c70e302c2fd29e08c/pydantic_core-2.18.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b9ebe8231726c49518b16b237b9fe0d7d361dd221302af511a83d4ada01183ab", size = 1831516 }, - { url = "https://files.pythonhosted.org/packages/0c/84/a14457b3cb1ec1f5d1567395abe11ab420dd76733bc79dd0124a874e9eac/pydantic_core-2.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b8e20e15d18bf7dbb453be78a2d858f946f5cdf06c5072453dace00ab652e2b2", size = 1751781 }, - { url = "https://files.pythonhosted.org/packages/9a/a5/5c1d98cdba8e6b2fda1975dcdb59cd608257eee69637deca22389ca16a54/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d9ff283cd3459fa0bf9b0256a2b6f01ac1ff9ffb034e24457b9035f75587cb", size = 1775332 }, - { url = "https://files.pythonhosted.org/packages/f7/27/83d6903b1eb5ac5db67acf7be1b397c962acba1bbb27bc4fa6af4b4e82bb/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f7ef5f0ebb77ba24c9970da18b771711edc5feaf00c10b18461e0f5f5949231", size = 1766532 }, - { url = "https://files.pythonhosted.org/packages/41/83/db99c69d1f3bf71b0771d7233ac65722ba24ebc39b76b4f168da735726e0/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73038d66614d2e5cde30435b5afdced2b473b4c77d4ca3a8624dd3e41a9c19be", size = 1964053 }, - { url = "https://files.pythonhosted.org/packages/49/78/daf71cbf3b3bc1605bc750b37c5e70dff985b676fd66ac7427b8fb730dc7/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6afd5c867a74c4d314c557b5ea9520183fadfbd1df4c2d6e09fd0d990ce412cd", size = 2814359 }, - { url = "https://files.pythonhosted.org/packages/08/6b/391098a7f0863b5e54c60244c069acfca969af56af4eb7cf52e08b009560/pydantic_core-2.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd7df92f28d351bb9f12470f4c533cf03d1b52ec5a6e5c58c65b183055a60106", size = 2028398 }, - { url = "https://files.pythonhosted.org/packages/41/f5/cf4a616568dddd85c71bf8b4bdc492c41c1af6eb9b0fc87e8835fd63447c/pydantic_core-2.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:80aea0ffeb1049336043d07799eace1c9602519fb3192916ff525b0287b2b1e4", size = 1891053 }, - { url = "https://files.pythonhosted.org/packages/44/2e/ebdc3f4deb3e3bbf14f0da00394dd07074cfb2ea1431024ed0fc64be3e9c/pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaee40f25bba38132e655ffa3d1998a6d576ba7cf81deff8bfa189fb43fd2bbe", size = 1995808 }, - { url = "https://files.pythonhosted.org/packages/85/96/6f37b40651b3e43a3c9d0cf8419b333d1f0edc20f70171a9aa52a44d45c8/pydantic_core-2.18.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9128089da8f4fe73f7a91973895ebf2502539d627891a14034e45fb9e707e26d", size = 2096891 }, - { url = "https://files.pythonhosted.org/packages/9d/9f/d3d655c8e09eb87a5eecbb5d442205c56c9dd9acd49670386c29c430f5ea/pydantic_core-2.18.3-cp311-none-win32.whl", hash = "sha256:fec02527e1e03257aa25b1a4dcbe697b40a22f1229f5d026503e8b7ff6d2eda7", size = 1699771 }, - { url = "https://files.pythonhosted.org/packages/d2/c7/e01cb2017c4b7b274258694f73e8bbbb0988a28b49802e569d1d9bfd51cb/pydantic_core-2.18.3-cp311-none-win_amd64.whl", hash = "sha256:58ff8631dbab6c7c982e6425da8347108449321f61fe427c52ddfadd66642af7", size = 1888426 }, - { url = "https://files.pythonhosted.org/packages/fc/90/30f4755a09691f4efebc93e86c98e696e8a109db5a5b36f1d0d94311eac1/pydantic_core-2.18.3-cp311-none-win_arm64.whl", hash = "sha256:3fc1c7f67f34c6c2ef9c213e0f2a351797cda98249d9ca56a70ce4ebcaba45f4", size = 1762547 }, - { url = "https://files.pythonhosted.org/packages/77/72/3ce28b58f3d9c9a8bb59984d810be3eabba4455e92de806a4edacd4e5c0b/pydantic_core-2.18.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f0928cde2ae416a2d1ebe6dee324709c6f73e93494d8c7aea92df99aab1fc40f", size = 1826479 }, - { url = "https://files.pythonhosted.org/packages/94/bc/e5d1938f36cad75525e923ecfef6f544970d4f14800716728ea5555fc574/pydantic_core-2.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bee9bb305a562f8b9271855afb6ce00223f545de3d68560b3c1649c7c5295e9", size = 1750007 }, - { url = "https://files.pythonhosted.org/packages/20/a8/4c6eb74f4b421e9ea62e2bea42683b58ed2d43376895ecc5c376f3cc1630/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e862823be114387257dacbfa7d78547165a85d7add33b446ca4f4fae92c7ff5c", size = 1771054 }, - { url = "https://files.pythonhosted.org/packages/f6/0a/d5a1765b5000f56ee3a9659658aed4f978bb85b45bb01c0f921f2a70b511/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a36f78674cbddc165abab0df961b5f96b14461d05feec5e1f78da58808b97e7", size = 1752825 }, - { url = "https://files.pythonhosted.org/packages/0c/20/2e7da2f5cbc6f1849c6bad4ea04e8e763512f4af6250972c35d354b59ab1/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba905d184f62e7ddbb7a5a751d8a5c805463511c7b08d1aca4a3e8c11f2e5048", size = 1962656 }, - { url = "https://files.pythonhosted.org/packages/7e/bb/f01be2f91439f155f8b522259ef92099383d3d6e8df559caa26b8d21dd43/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fdd362f6a586e681ff86550b2379e532fee63c52def1c666887956748eaa326", size = 2738939 }, - { url = "https://files.pythonhosted.org/packages/c2/9f/e2f17d24aee5406a8e8e57784fa737abde9ac538d18028b523268796bcce/pydantic_core-2.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24b214b7ee3bd3b865e963dbed0f8bc5375f49449d70e8d407b567af3222aae4", size = 2066345 }, - { url = "https://files.pythonhosted.org/packages/59/c2/5597c61f62cef54cd3f183db5980bf7b3ee7aeb9bd9ab3458d275af33bd7/pydantic_core-2.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:691018785779766127f531674fa82bb368df5b36b461622b12e176c18e119022", size = 1886795 }, - { url = "https://files.pythonhosted.org/packages/da/b6/2e0a0a51b8fe047d985a7ee1b328d8d8fbef5be54c4870bbe21d2cb846de/pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:60e4c625e6f7155d7d0dcac151edf5858102bc61bf959d04469ca6ee4e8381bd", size = 1991654 }, - { url = "https://files.pythonhosted.org/packages/9c/ef/ade132a1d5a6f5bceee347b06a3853d63730d508c6e91dbd83ec44c4361e/pydantic_core-2.18.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4e651e47d981c1b701dcc74ab8fec5a60a5b004650416b4abbef13db23bc7be", size = 2098061 }, - { url = "https://files.pythonhosted.org/packages/1d/4b/a925d2ada3d8a554a362c29f1b0f60cb82db7e791e43e91a6f6bc093dacd/pydantic_core-2.18.3-cp312-none-win32.whl", hash = "sha256:ffecbb5edb7f5ffae13599aec33b735e9e4c7676ca1633c60f2c606beb17efc5", size = 1704763 }, - { url = "https://files.pythonhosted.org/packages/e3/5c/477dac00c0d6d34921fec2507ae6aea2cd7c84072eab1dca5bcbbf86c4a2/pydantic_core-2.18.3-cp312-none-win_amd64.whl", hash = "sha256:2c8333f6e934733483c7eddffdb094c143b9463d2af7e6bd85ebcb2d4a1b82c6", size = 1884445 }, - { url = "https://files.pythonhosted.org/packages/4d/f4/285df83eb0c4a8c710bf002b342a114fcd9e388946a0a35dc06f687f865d/pydantic_core-2.18.3-cp312-none-win_arm64.whl", hash = "sha256:7a20dded653e516a4655f4c98e97ccafb13753987434fe7cf044aa25f5b7d417", size = 1763753 }, - { url = "https://files.pythonhosted.org/packages/7f/6b/7bb6e75d4cb9aacca9683cb491b194e94146c6a304de5857a13e3dc0e094/pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:77319771a026f7c7d29c6ebc623de889e9563b7087911b46fd06c044a12aa5e9", size = 1830389 }, - { url = "https://files.pythonhosted.org/packages/a5/e6/a3775ca64d41a9cfd2ff57f1322e5e9cec12809f87c58f09d3c4d468d6db/pydantic_core-2.18.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:df11fa992e9f576473038510d66dd305bcd51d7dd508c163a8c8fe148454e059", size = 1711345 }, - { url = "https://files.pythonhosted.org/packages/30/64/b6a46b84f1237511aaeb8e73b3b357bdb34f63c958b92a483c7abdfe6b73/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d531076bdfb65af593326ffd567e6ab3da145020dafb9187a1d131064a55f97c", size = 1774898 }, - { url = "https://files.pythonhosted.org/packages/ac/ca/0fd2e3849cd6b87b08fa9676dec86bf33c6c9fbc80af2247b0120dbfae80/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33ce258e4e6e6038f2b9e8b8a631d17d017567db43483314993b3ca345dcbbb", size = 1923112 }, - { url = "https://files.pythonhosted.org/packages/32/92/eab2738a19fea14f55314eca5e31d85e0680daa1d439d9a4485ba808faf2/pydantic_core-2.18.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1f9cd7f5635b719939019be9bda47ecb56e165e51dd26c9a217a433e3d0d59a9", size = 1888546 }, - { url = "https://files.pythonhosted.org/packages/fe/85/32c6733055194d624b1a03c1ae6fee4121c1ecac99d87a63a9911eac7d65/pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cd4a032bb65cc132cae1fe3e52877daecc2097965cd3914e44fbd12b00dae7c5", size = 1994693 }, - { url = "https://files.pythonhosted.org/packages/50/67/ff5701b8f54842f9485d2b27455a4911d99b662ceb44ca81e5e26c9421a9/pydantic_core-2.18.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f2718430098bcdf60402136c845e4126a189959d103900ebabb6774a5d9fdb", size = 2094391 }, - { url = "https://files.pythonhosted.org/packages/af/d1/1c18f8e215930665e65597dd677937595355057f631bf4b9110aa6f88f79/pydantic_core-2.18.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0037a92cf0c580ed14e10953cdd26528e8796307bb8bb312dc65f71547df04d", size = 1898163 }, +sdist = { url = "https://files.pythonhosted.org/packages/d1/cd/8331ae216bcc5a3f2d4c6b941c9f63de647e2700d38133f4f7e0132a00c4/pydantic_core-2.27.0.tar.gz", hash = "sha256:f57783fbaf648205ac50ae7d646f27582fc706be3977e87c3c124e7a92407b10", size = 412675 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/97/8a42e9c17c305516c0d956a2887d616d3a1b0531b0053ac95a917e4a1ab7/pydantic_core-2.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2ac6b919f7fed71b17fe0b4603c092a4c9b5bae414817c9c81d3c22d1e1bcc", size = 1893954 }, + { url = "https://files.pythonhosted.org/packages/5b/09/ff3ce866f769ebbae2abdcd742247dc2bd6967d646daf54a562ceee6abdb/pydantic_core-2.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e015833384ca3e1a0565a79f5d953b0629d9138021c27ad37c92a9fa1af7623c", size = 1807944 }, + { url = "https://files.pythonhosted.org/packages/88/d7/e04d06ca71a0bd7f4cac24e6aa562129969c91117e5fad2520ede865c8cb/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db72e40628967f6dc572020d04b5f800d71264e0531c6da35097e73bdf38b003", size = 1829151 }, + { url = "https://files.pythonhosted.org/packages/14/24/90b0babb61b68ecc471ce5becad8f7fc5f7835c601774e5de577b051b7ad/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df45c4073bed486ea2f18757057953afed8dd77add7276ff01bccb79982cf46c", size = 1849502 }, + { url = "https://files.pythonhosted.org/packages/fc/34/62612e655b4d693a6ec515fd0ddab4bfc0cc6759076e09c23fc6966bd07b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:836a4bfe0cc6d36dc9a9cc1a7b391265bf6ce9d1eb1eac62ac5139f5d8d9a6fa", size = 2035489 }, + { url = "https://files.pythonhosted.org/packages/12/7d/0ff62235adda41b87c495c1b95c84d4debfecb91cfd62e3100abad9754fa/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bf1340ae507f6da6360b24179c2083857c8ca7644aab65807023cf35404ea8d", size = 2774949 }, + { url = "https://files.pythonhosted.org/packages/7f/ac/e1867e2b808a668f32ad9012eaeac0b0ee377eee8157ab93720f48ee609b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ab325fc86fbc077284c8d7f996d904d30e97904a87d6fb303dce6b3de7ebba9", size = 2130123 }, + { url = "https://files.pythonhosted.org/packages/2f/04/5006f2dbf655052826ac8d03d51b9a122de709fed76eb1040aa21772f530/pydantic_core-2.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1da0c98a85a6c6ed702d5556db3b09c91f9b0b78de37b7593e2de8d03238807a", size = 1981988 }, + { url = "https://files.pythonhosted.org/packages/80/8b/bdbe875c4758282402e3cc75fa6bf2f0c8ffac1874f384190034786d3cbc/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b0202ebf2268954090209a84f9897345719e46a57c5f2c9b7b250ca0a9d3e63", size = 1992043 }, + { url = "https://files.pythonhosted.org/packages/2f/2d/4e46981cfcf4ca4c2ff7734dec08162e398dc598c6c0687454b05a82dc2f/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:35380671c3c921fe8adf31ad349dc6f7588b7e928dbe44e1093789734f607399", size = 2087309 }, + { url = "https://files.pythonhosted.org/packages/d2/43/56ef2e72360d909629a54198d2bc7ef60f19fde8ceb5c90d7749120d0b61/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b4c19525c3538fbc0bbda6229f9682fb8199ce9ac37395880e6952798e00373", size = 2140517 }, + { url = "https://files.pythonhosted.org/packages/61/40/81e5d8f84ab070cf091d072bb61b6021ff79d7110b2d0145fe3171b6107b/pydantic_core-2.27.0-cp310-none-win32.whl", hash = "sha256:333c840a1303d1474f491e7be0b718226c730a39ead0f7dab2c7e6a2f3855555", size = 1814120 }, + { url = "https://files.pythonhosted.org/packages/05/64/e543d342b991d38426bcb841bc0b4b95b9bd2191367ba0cc75f258e3d583/pydantic_core-2.27.0-cp310-none-win_amd64.whl", hash = "sha256:99b2863c1365f43f74199c980a3d40f18a218fbe683dd64e470199db426c4d6a", size = 1972268 }, + { url = "https://files.pythonhosted.org/packages/85/ba/5ed9583a44d9fbd6fbc028df8e3eae574a3ef4761d7f56bb4e0eb428d5ce/pydantic_core-2.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4523c4009c3f39d948e01962223c9f5538602e7087a628479b723c939fab262d", size = 1891468 }, + { url = "https://files.pythonhosted.org/packages/50/1e/58baa0fde14aafccfcc09a8b45bdc11eb941b58a69536729d832e383bdbd/pydantic_core-2.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84af1cf7bfdcbc6fcf5a5f70cc9896205e0350306e4dd73d54b6a18894f79386", size = 1807103 }, + { url = "https://files.pythonhosted.org/packages/7d/87/0422a653ddfcf68763eb56d6e4e2ad19df6d5e006f3f4b854fda06ce2ba3/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e65466b31be1070b4a5b7dbfbd14b247884cb8e8b79c64fb0f36b472912dbaea", size = 1827446 }, + { url = "https://files.pythonhosted.org/packages/a4/48/8e431b7732695c93ded79214299a83ac04249d748243b8ba6644ab076574/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5c022bb0d453192426221605efc865373dde43b17822a264671c53b068ac20c", size = 1847798 }, + { url = "https://files.pythonhosted.org/packages/98/7d/e1f28e12a26035d7c8b7678830400e5b94129c9ccb74636235a2eeeee40f/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69bf3b6500f195c3deb69c1205ba8fc3cb21d1915f1f158a10d6b1ef29b6a", size = 2033797 }, + { url = "https://files.pythonhosted.org/packages/89/b4/ad5bc2b43b7ca8fd5f5068eca7f195565f53911d9ae69925f7f21859a929/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aa4d1b2eba9a325897308b3124014a142cdccb9f3e016f31d3ebee6b5ea5e75", size = 2767592 }, + { url = "https://files.pythonhosted.org/packages/3e/a6/7fb0725eaf1122518c018bfe38aaf4ad3d512e8598e2c08419b9a270f4bf/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e96ca781e0c01e32115912ebdf7b3fb0780ce748b80d7d28a0802fa9fbaf44e", size = 2130244 }, + { url = "https://files.pythonhosted.org/packages/a1/2c/453e52a866947a153bb575bbbb6b14db344f07a73b2ad820ff8f40e9807b/pydantic_core-2.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b872c86d8d71827235c7077461c502feb2db3f87d9d6d5a9daa64287d75e4fa0", size = 1979626 }, + { url = "https://files.pythonhosted.org/packages/7a/43/1faa8601085dab2a37dfaca8d48605b76e38aeefcde58bf95534ab96b135/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:82e1ad4ca170e8af4c928b67cff731b6296e6a0a0981b97b2eb7c275cc4e15bd", size = 1990741 }, + { url = "https://files.pythonhosted.org/packages/dd/ef/21f25f5964979b7e6f9102074083b5448c22c871da438d91db09601e6634/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:eb40f828bc2f73f777d1eb8fee2e86cd9692a4518b63b6b5aa8af915dfd3207b", size = 2086325 }, + { url = "https://files.pythonhosted.org/packages/8a/f9/81e5f910571a20655dd7bf10e6d6db8c279e250bfbdb5ab1a09ce3e0eb82/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9a8fbf506fde1529a1e3698198fe64bfbe2e0c09557bc6a7dcf872e7c01fec40", size = 2138839 }, + { url = "https://files.pythonhosted.org/packages/59/c4/27917b73d0631098b91f2ec303e1becb823fead0628ee9055fca78ec1e2e/pydantic_core-2.27.0-cp311-none-win32.whl", hash = "sha256:24f984fc7762ed5f806d9e8c4c77ea69fdb2afd987b4fd319ef06c87595a8c55", size = 1809514 }, + { url = "https://files.pythonhosted.org/packages/ea/48/a30c67d62b8f39095edc3dab6abe69225e8c57186f31cc59a1ab984ea8e6/pydantic_core-2.27.0-cp311-none-win_amd64.whl", hash = "sha256:68950bc08f9735306322bfc16a18391fcaac99ded2509e1cc41d03ccb6013cfe", size = 1971838 }, + { url = "https://files.pythonhosted.org/packages/4e/9e/3798b901cf331058bae0ba4712a52fb0106c39f913830aaf71f01fd10d45/pydantic_core-2.27.0-cp311-none-win_arm64.whl", hash = "sha256:3eb8849445c26b41c5a474061032c53e14fe92a11a5db969f722a2716cd12206", size = 1862174 }, + { url = "https://files.pythonhosted.org/packages/82/99/43149b127559f3152cd28cb7146592c6547cfe47d528761954e2e8fcabaf/pydantic_core-2.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8117839a9bdbba86e7f9df57018fe3b96cec934c3940b591b0fd3fbfb485864a", size = 1887064 }, + { url = "https://files.pythonhosted.org/packages/7e/dd/989570c76334aa55ccb4ee8b5e0e6881a513620c6172d93b2f3b77e10f81/pydantic_core-2.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a291d0b4243a259c8ea7e2b84eb9ccb76370e569298875a7c5e3e71baf49057a", size = 1804405 }, + { url = "https://files.pythonhosted.org/packages/3e/b5/bce1d6d6fb71d916c74bf988b7d0cd7fc0c23da5e08bc0d6d6e08c12bf36/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e35afd9e10b2698e6f2f32256678cb23ca6c1568d02628033a837638b3ed12", size = 1822595 }, + { url = "https://files.pythonhosted.org/packages/35/93/a6e5e04625ac8fcbed523d7b741e91cc3a37ed1e04e16f8f2f34269bbe53/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58ab0d979c969983cdb97374698d847a4acffb217d543e172838864636ef10d9", size = 1848701 }, + { url = "https://files.pythonhosted.org/packages/3a/74/56ead1436e3f6513b59b3a442272578a6ec09a39ab95abd5ee321bcc8c95/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d06b667e53320332be2bf6f9461f4a9b78092a079b8ce8634c9afaa7e10cd9f", size = 2031878 }, + { url = "https://files.pythonhosted.org/packages/e1/4d/8905b2710ef653c0da27224bfb6a084b5873ad6fdb975dda837943e5639d/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78f841523729e43e3928a364ec46e2e3f80e6625a4f62aca5c345f3f626c6e8a", size = 2673386 }, + { url = "https://files.pythonhosted.org/packages/1d/f0/abe1511f11756d12ce18d016f3555cb47211590e4849ee02e7adfdd1684e/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:400bf470e4327e920883b51e255617dfe4496d4e80c3fea0b5a5d0bf2c404dd4", size = 2152867 }, + { url = "https://files.pythonhosted.org/packages/c7/90/1c588d4d93ce53e1f5ab0cea2d76151fcd36613446bf99b670d7da9ddf89/pydantic_core-2.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:951e71da6c89d354572098bada5ba5b5dc3a9390c933af8a614e37755d3d1840", size = 1986595 }, + { url = "https://files.pythonhosted.org/packages/a3/9c/27d06369f39375966836cde5c8aec0a66dc2f532c13d9aa1a6c370131fbd/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a51ce96224eadd1845150b204389623c8e129fde5a67a84b972bd83a85c6c40", size = 1995731 }, + { url = "https://files.pythonhosted.org/packages/26/4e/b039e52b7f4c51d9fae6715d5d2e47a57c369b8e0cb75838974a193aae40/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:483c2213a609e7db2c592bbc015da58b6c75af7360ca3c981f178110d9787bcf", size = 2085771 }, + { url = "https://files.pythonhosted.org/packages/01/93/2796bd116a93e7e4e10baca4c55266c4d214b3b4e5ee7f0e9add69c184af/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:359e7951f04ad35111b5ddce184db3391442345d0ab073aa63a95eb8af25a5ef", size = 2150452 }, + { url = "https://files.pythonhosted.org/packages/0f/93/e57562d6ea961557174c3afa481a73ce0e2d8b823e0eb2b320bfb00debbe/pydantic_core-2.27.0-cp312-none-win32.whl", hash = "sha256:ee7d9d5537daf6d5c74a83b38a638cc001b648096c1cae8ef695b0c919d9d379", size = 1830767 }, + { url = "https://files.pythonhosted.org/packages/44/00/4f121ca5dd06420813e7858395b5832603ed0074a5b74ef3104c8dbc2fd5/pydantic_core-2.27.0-cp312-none-win_amd64.whl", hash = "sha256:2be0ad541bb9f059954ccf8877a49ed73877f862529575ff3d54bf4223e4dd61", size = 1973909 }, + { url = "https://files.pythonhosted.org/packages/c3/c7/36f87c0dabbde9c0dd59b9024e4bf117a5122515c864ddbe685ed8301670/pydantic_core-2.27.0-cp312-none-win_arm64.whl", hash = "sha256:6e19401742ed7b69e51d8e4df3c03ad5ec65a83b36244479fd70edde2828a5d9", size = 1877037 }, + { url = "https://files.pythonhosted.org/packages/9d/b2/740159bdfe532d856e340510246aa1fd723b97cadf1a38153bdfb52efa28/pydantic_core-2.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5f2b19b8d6fca432cb3acf48cf5243a7bf512988029b6e6fd27e9e8c0a204d85", size = 1886935 }, + { url = "https://files.pythonhosted.org/packages/ca/2a/2f435d9fd591c912ca227f29c652a93775d35d54677b57c3157bbad823b5/pydantic_core-2.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c86679f443e7085ea55a7376462553996c688395d18ef3f0d3dbad7838f857a2", size = 1805318 }, + { url = "https://files.pythonhosted.org/packages/ba/f2/755b628009530b19464bb95c60f829b47a6ef7930f8ca1d87dac90fd2848/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:510b11e9c3b1a852876d1ccd8d5903684336d635214148637ceb27366c75a467", size = 1822284 }, + { url = "https://files.pythonhosted.org/packages/3d/c2/a12744628b1b55c5384bd77657afa0780868484a92c37a189fb460d1cfe7/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb704155e73b833801c247f39d562229c0303f54770ca14fb1c053acb376cf10", size = 1848522 }, + { url = "https://files.pythonhosted.org/packages/60/1d/dfcb8ab94a4637d4cf682550a2bf94695863988e7bcbd6f4d83c04178e17/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ce048deb1e033e7a865ca384770bccc11d44179cf09e5193a535c4c2f497bdc", size = 2031678 }, + { url = "https://files.pythonhosted.org/packages/ee/c8/f9cbcab0275e031c4312223c75d999b61fba60995003cd89dc4866300059/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58560828ee0951bb125c6f2862fbc37f039996d19ceb6d8ff1905abf7da0bf3d", size = 2672948 }, + { url = "https://files.pythonhosted.org/packages/41/f9/c613546237cf58ed7a7fa9158410c14d0e7e0cbbf95f83a905c9424bb074/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb4785894936d7682635726613c44578c420a096729f1978cd061a7e72d5275", size = 2152419 }, + { url = "https://files.pythonhosted.org/packages/49/71/b951b03a271678b1d1b79481dac38cf8bce8a4e178f36ada0e9aff65a679/pydantic_core-2.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2883b260f7a93235488699d39cbbd94fa7b175d3a8063fbfddd3e81ad9988cb2", size = 1986408 }, + { url = "https://files.pythonhosted.org/packages/9a/2c/07b0d5b5e1cdaa07b7c23e758354377d294ff0395116d39c9fa734e5d89e/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c6fcb3fa3855d583aa57b94cf146f7781d5d5bc06cb95cb3afece33d31aac39b", size = 1995895 }, + { url = "https://files.pythonhosted.org/packages/63/09/c21e0d7438c7e742209cc8603607c8d389df96018396c8a2577f6e24c5c5/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e851a051f7260e6d688267eb039c81f05f23a19431bd7dfa4bf5e3cb34c108cd", size = 2085914 }, + { url = "https://files.pythonhosted.org/packages/68/e4/5ed8f09d92655dcd0a86ee547e509adb3e396cef0a48f5c31e3b060bb9d0/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edb1bfd45227dec8d50bc7c7d86463cd8728bcc574f9b07de7369880de4626a3", size = 2150217 }, + { url = "https://files.pythonhosted.org/packages/cd/e6/a202f0e1b81c729130404e82d9de90dc4418ec01df35000d48d027c38501/pydantic_core-2.27.0-cp313-none-win32.whl", hash = "sha256:678f66462058dd978702db17eb6a3633d634f7aa0deaea61e0a674152766d3fc", size = 1830973 }, + { url = "https://files.pythonhosted.org/packages/06/3d/21ed0f308e6618ce6c5c6bfb9e71734a9a3256d5474a53c8e5aaaba498ca/pydantic_core-2.27.0-cp313-none-win_amd64.whl", hash = "sha256:d28ca7066d6cdd347a50d8b725dc10d9a1d6a1cce09836cf071ea6a2d4908be0", size = 1974853 }, + { url = "https://files.pythonhosted.org/packages/d7/18/e5744a132b81f98b9f92e15f33f03229a1d254ce7af942b1422ec2ac656f/pydantic_core-2.27.0-cp313-none-win_arm64.whl", hash = "sha256:6f4a53af9e81d757756508b57cae1cf28293f0f31b9fa2bfcb416cc7fb230f9d", size = 1877469 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/c1dc814ab524cb247ceb6cb25236895a5cae996c438baf504db610fd6c92/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4fb49cfdb53af5041aba909be00cccfb2c0d0a2e09281bf542371c5fd36ad04c", size = 1889233 }, + { url = "https://files.pythonhosted.org/packages/24/bb/069a9dd910e6c09aab90a118c08d3cb30dc5738550e9f2d21f3b086352c2/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:49633583eb7dc5cba61aaf7cdb2e9e662323ad394e543ee77af265736bcd3eaa", size = 1768419 }, + { url = "https://files.pythonhosted.org/packages/cb/a1/f9b4e625ee8c7f683c8295c85d11f79a538eb53719f326646112a7800bda/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:153017e3d6cd3ce979de06d84343ca424bb6092727375eba1968c8b4693c6ecb", size = 1822870 }, + { url = "https://files.pythonhosted.org/packages/12/07/04abaeeabf212650de3edc300b2ab89fb17da9bc4408ef4e01a62efc87dc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff63a92f6e249514ef35bc795de10745be0226eaea06eb48b4bbeaa0c8850a4a", size = 1977039 }, + { url = "https://files.pythonhosted.org/packages/0f/9d/99bbeb21d5be1d5affdc171e0e84603a757056f9f4293ef236e41af0a5bc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5982048129f40b082c2654de10c0f37c67a14f5ff9d37cf35be028ae982f26df", size = 1974317 }, + { url = "https://files.pythonhosted.org/packages/5f/78/815aa74db1591a9ad4086bc1bf98e2126686245a956d76cd4e72bf9841ad/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:91bc66f878557313c2a6bcf396e7befcffe5ab4354cfe4427318968af31143c3", size = 1985101 }, + { url = "https://files.pythonhosted.org/packages/d9/a8/9c1557d5282108916448415e85f829b70ba99d97f03cee0e40a296e58a65/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:68ef5377eb582fa4343c9d0b57a5b094046d447b4c73dd9fbd9ffb216f829e7d", size = 2073399 }, + { url = "https://files.pythonhosted.org/packages/ca/b0/5296273d652fa9aa140771b3f4bb574edd3cbf397090625b988f6a57b02b/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c5726eec789ee38f2c53b10b1821457b82274f81f4f746bb1e666d8741fcfadb", size = 2129499 }, + { url = "https://files.pythonhosted.org/packages/e9/fd/7f39ff702fdca954f26c84b40d9bf744733bb1a50ca6b7569822b9cbb7f4/pydantic_core-2.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0c431e4be5c1a0c6654e0c31c661cd89e0ca956ef65305c3c3fd96f4e72ca39", size = 1997246 }, ] [[package]] @@ -574,27 +592,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526 }, - { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612 }, - { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197 }, - { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855 }, - { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889 }, - { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678 }, - { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682 }, - { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446 }, - { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048 }, - { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855 }, - { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007 }, - { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594 }, - { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024 }, - { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085 }, - { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088 }, - { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275 }, - { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 }, +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/d0/8ff5b189d125f4260f2255d143bf2fa413b69c2610c405ace7a0a8ec81ec/ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f", size = 3313222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/d6/1a6314e568db88acdbb5121ed53e2c52cebf3720d3437a76f82f923bf171/ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5", size = 10532605 }, + { url = "https://files.pythonhosted.org/packages/89/a8/a957a8812e31facffb6a26a30be0b5b4af000a6e30c7d43a22a5232a3398/ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087", size = 10278243 }, + { url = "https://files.pythonhosted.org/packages/a8/23/9db40fa19c453fabf94f7a35c61c58f20e8200b4734a20839515a19da790/ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209", size = 9917739 }, + { url = "https://files.pythonhosted.org/packages/e2/a0/6ee2d949835d5701d832fc5acd05c0bfdad5e89cfdd074a171411f5ccad5/ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871", size = 10779153 }, + { url = "https://files.pythonhosted.org/packages/7a/25/9c11dca9404ef1eb24833f780146236131a3c7941de394bc356912ef1041/ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1", size = 10304387 }, + { url = "https://files.pythonhosted.org/packages/c8/b9/84c323780db1b06feae603a707d82dbbd85955c8c917738571c65d7d5aff/ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5", size = 11360351 }, + { url = "https://files.pythonhosted.org/packages/6b/e1/9d4bbb2ace7aad14ded20e4674a48cda5b902aed7a1b14e6b028067060c4/ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d", size = 12022879 }, + { url = "https://files.pythonhosted.org/packages/75/28/752ff6120c0e7f9981bc4bc275d540c7f36db1379ba9db9142f69c88db21/ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26", size = 11610354 }, + { url = "https://files.pythonhosted.org/packages/ba/8c/967b61c2cc8ebd1df877607fbe462bc1e1220b4a30ae3352648aec8c24bd/ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1", size = 12813976 }, + { url = "https://files.pythonhosted.org/packages/7f/29/e059f945d6bd2d90213387b8c360187f2fefc989ddcee6bbf3c241329b92/ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c", size = 11154564 }, + { url = "https://files.pythonhosted.org/packages/55/47/cbd05e5a62f3fb4c072bc65c1e8fd709924cad1c7ec60a1000d1e4ee8307/ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa", size = 10760604 }, + { url = "https://files.pythonhosted.org/packages/bb/ee/4c3981c47147c72647a198a94202633130cfda0fc95cd863a553b6f65c6a/ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540", size = 10391071 }, + { url = "https://files.pythonhosted.org/packages/6b/e6/083eb61300214590b188616a8ac6ae1ef5730a0974240fb4bec9c17de78b/ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9", size = 10896657 }, + { url = "https://files.pythonhosted.org/packages/77/bd/aacdb8285d10f1b943dbeb818968efca35459afc29f66ae3bd4596fbf954/ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5", size = 11228362 }, + { url = "https://files.pythonhosted.org/packages/39/72/fcb7ad41947f38b4eaa702aca0a361af0e9c2bf671d7fd964480670c297e/ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790", size = 8803476 }, + { url = "https://files.pythonhosted.org/packages/e4/ea/cae9aeb0f4822c44651c8407baacdb2e5b4dcd7b31a84e1c5df33aa2cc20/ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6", size = 9614463 }, + { url = "https://files.pythonhosted.org/packages/eb/76/fbb4bd23dfb48fa7758d35b744413b650a9fd2ddd93bca77e30376864414/ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737", size = 8959621 }, ] [[package]] From 9d58fed2fde09cf93177f2acca037ddb1c462fd8 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Sat, 21 Dec 2024 00:49:02 +0000 Subject: [PATCH 17/20] add precommits --- .pre-commit-config.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..d75de49e9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +fail_fast: true + +repos: + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [yaml, json5] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff-format + - id: ruff + args: [--fix, --exit-non-zero-on-fix] From 5d9b544ce7149fb31695f2943688deeb476bd193 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Sat, 21 Dec 2024 00:54:59 +0000 Subject: [PATCH 18/20] add uvicorn dependency --- pyproject.toml | 1 + uv.lock | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0c705cf7d..83d036258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "starlette>=0.27", "sse-starlette>=1.6.1", "pydantic-settings>=2.6.1", + "uvicorn>=0.30", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index c7de80bf2..47162d736 100644 --- a/uv.lock +++ b/uv.lock @@ -201,6 +201,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "sse-starlette" }, { name = "starlette" }, + { name = "uvicorn" }, ] [package.optional-dependencies] @@ -234,6 +235,7 @@ requires-dist = [ { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, + { name = "uvicorn", specifier = ">=0.30" }, ] [package.metadata.requires-dev] @@ -746,3 +748,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "uvicorn" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/f7/4ad826703a49b320a4adf2470fdd2a3481ea13f4460cb615ad12c75be003/uvicorn-0.30.0.tar.gz", hash = "sha256:f678dec4fa3a39706bbf49b9ec5fc40049d42418716cea52b53f07828a60aa37", size = 42560 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a1/d57e38417a8dabb22df02b6aebc209dc73485792e6c5620e501547133d0b/uvicorn-0.30.0-py3-none-any.whl", hash = "sha256:78fa0b5f56abb8562024a59041caeb555c86e48d0efdd23c3fe7de7a4075bdab", size = 62388 }, +] From 680afeeed635371b5435aaf8e87e8e5039d342b7 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Sat, 21 Dec 2024 01:04:30 +0000 Subject: [PATCH 19/20] fix pydantic version --- pyproject.toml | 2 +- tests/conftest.py | 5 +- uv.lock | 142 +++++++++++++++++++++++----------------------- 3 files changed, 75 insertions(+), 74 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83d036258..73c525108 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "anyio>=4.5", "httpx>=0.27", "httpx-sse>=0.4", - "pydantic>=2.10.0,<3.0.0", + "pydantic>=2.10.1,<3.0.0", "starlette>=0.27", "sse-starlette>=1.6.1", "pydantic-settings>=2.6.1", diff --git a/tests/conftest.py b/tests/conftest.py index 381e5db5e..9c4f8b489 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest from pydantic import AnyUrl -from mcp.server import Server +from mcp.server.lowlevel import Server from mcp.server.models import InitializationOptions from mcp.types import Resource, ServerCapabilities @@ -28,6 +28,7 @@ async def handle_list_resources(): return server + @pytest.fixture def anyio_backend(): - return 'asyncio' + return "asyncio" diff --git a/uv.lock b/uv.lock index 47162d736..e05eee46f 100644 --- a/uv.lock +++ b/uv.lock @@ -228,7 +228,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, - { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, + { name = "pydantic", specifier = ">=2.10.1,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.6.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, @@ -406,91 +406,91 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.0" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/78/58c36d0cf331b659d0ccd99175e3523c457b4f8e67cb92a8fdc22ec1667c/pydantic-2.10.0.tar.gz", hash = "sha256:0aca0f045ff6e2f097f1fe89521115335f15049eeb8a7bef3dafe4b19a74e289", size = 781980 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/bd/7fc610993f616d2398958d0028d15eaf53bde5f80cb2edb7aa4f1feaf3a7/pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560", size = 783717 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/ee/255cbfdbf5c47650de70ac8a5425107511f505ed0366c29d537f7f1842e1/pydantic-2.10.0-py3-none-any.whl", hash = "sha256:5e7807ba9201bdf61b1b58aa6eb690916c40a47acfb114b1b4fef3e7fd5b30fc", size = 454346 }, + { url = "https://files.pythonhosted.org/packages/e0/fc/fda48d347bd50a788dd2a0f318a52160f911b86fc2d8b4c86f4d7c9bceea/pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e", size = 455329 }, ] [[package]] name = "pydantic-core" -version = "2.27.0" +version = "2.27.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/cd/8331ae216bcc5a3f2d4c6b941c9f63de647e2700d38133f4f7e0132a00c4/pydantic_core-2.27.0.tar.gz", hash = "sha256:f57783fbaf648205ac50ae7d646f27582fc706be3977e87c3c124e7a92407b10", size = 412675 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/97/8a42e9c17c305516c0d956a2887d616d3a1b0531b0053ac95a917e4a1ab7/pydantic_core-2.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2ac6b919f7fed71b17fe0b4603c092a4c9b5bae414817c9c81d3c22d1e1bcc", size = 1893954 }, - { url = "https://files.pythonhosted.org/packages/5b/09/ff3ce866f769ebbae2abdcd742247dc2bd6967d646daf54a562ceee6abdb/pydantic_core-2.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e015833384ca3e1a0565a79f5d953b0629d9138021c27ad37c92a9fa1af7623c", size = 1807944 }, - { url = "https://files.pythonhosted.org/packages/88/d7/e04d06ca71a0bd7f4cac24e6aa562129969c91117e5fad2520ede865c8cb/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db72e40628967f6dc572020d04b5f800d71264e0531c6da35097e73bdf38b003", size = 1829151 }, - { url = "https://files.pythonhosted.org/packages/14/24/90b0babb61b68ecc471ce5becad8f7fc5f7835c601774e5de577b051b7ad/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df45c4073bed486ea2f18757057953afed8dd77add7276ff01bccb79982cf46c", size = 1849502 }, - { url = "https://files.pythonhosted.org/packages/fc/34/62612e655b4d693a6ec515fd0ddab4bfc0cc6759076e09c23fc6966bd07b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:836a4bfe0cc6d36dc9a9cc1a7b391265bf6ce9d1eb1eac62ac5139f5d8d9a6fa", size = 2035489 }, - { url = "https://files.pythonhosted.org/packages/12/7d/0ff62235adda41b87c495c1b95c84d4debfecb91cfd62e3100abad9754fa/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bf1340ae507f6da6360b24179c2083857c8ca7644aab65807023cf35404ea8d", size = 2774949 }, - { url = "https://files.pythonhosted.org/packages/7f/ac/e1867e2b808a668f32ad9012eaeac0b0ee377eee8157ab93720f48ee609b/pydantic_core-2.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ab325fc86fbc077284c8d7f996d904d30e97904a87d6fb303dce6b3de7ebba9", size = 2130123 }, - { url = "https://files.pythonhosted.org/packages/2f/04/5006f2dbf655052826ac8d03d51b9a122de709fed76eb1040aa21772f530/pydantic_core-2.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1da0c98a85a6c6ed702d5556db3b09c91f9b0b78de37b7593e2de8d03238807a", size = 1981988 }, - { url = "https://files.pythonhosted.org/packages/80/8b/bdbe875c4758282402e3cc75fa6bf2f0c8ffac1874f384190034786d3cbc/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b0202ebf2268954090209a84f9897345719e46a57c5f2c9b7b250ca0a9d3e63", size = 1992043 }, - { url = "https://files.pythonhosted.org/packages/2f/2d/4e46981cfcf4ca4c2ff7734dec08162e398dc598c6c0687454b05a82dc2f/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:35380671c3c921fe8adf31ad349dc6f7588b7e928dbe44e1093789734f607399", size = 2087309 }, - { url = "https://files.pythonhosted.org/packages/d2/43/56ef2e72360d909629a54198d2bc7ef60f19fde8ceb5c90d7749120d0b61/pydantic_core-2.27.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b4c19525c3538fbc0bbda6229f9682fb8199ce9ac37395880e6952798e00373", size = 2140517 }, - { url = "https://files.pythonhosted.org/packages/61/40/81e5d8f84ab070cf091d072bb61b6021ff79d7110b2d0145fe3171b6107b/pydantic_core-2.27.0-cp310-none-win32.whl", hash = "sha256:333c840a1303d1474f491e7be0b718226c730a39ead0f7dab2c7e6a2f3855555", size = 1814120 }, - { url = "https://files.pythonhosted.org/packages/05/64/e543d342b991d38426bcb841bc0b4b95b9bd2191367ba0cc75f258e3d583/pydantic_core-2.27.0-cp310-none-win_amd64.whl", hash = "sha256:99b2863c1365f43f74199c980a3d40f18a218fbe683dd64e470199db426c4d6a", size = 1972268 }, - { url = "https://files.pythonhosted.org/packages/85/ba/5ed9583a44d9fbd6fbc028df8e3eae574a3ef4761d7f56bb4e0eb428d5ce/pydantic_core-2.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4523c4009c3f39d948e01962223c9f5538602e7087a628479b723c939fab262d", size = 1891468 }, - { url = "https://files.pythonhosted.org/packages/50/1e/58baa0fde14aafccfcc09a8b45bdc11eb941b58a69536729d832e383bdbd/pydantic_core-2.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84af1cf7bfdcbc6fcf5a5f70cc9896205e0350306e4dd73d54b6a18894f79386", size = 1807103 }, - { url = "https://files.pythonhosted.org/packages/7d/87/0422a653ddfcf68763eb56d6e4e2ad19df6d5e006f3f4b854fda06ce2ba3/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e65466b31be1070b4a5b7dbfbd14b247884cb8e8b79c64fb0f36b472912dbaea", size = 1827446 }, - { url = "https://files.pythonhosted.org/packages/a4/48/8e431b7732695c93ded79214299a83ac04249d748243b8ba6644ab076574/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5c022bb0d453192426221605efc865373dde43b17822a264671c53b068ac20c", size = 1847798 }, - { url = "https://files.pythonhosted.org/packages/98/7d/e1f28e12a26035d7c8b7678830400e5b94129c9ccb74636235a2eeeee40f/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6bb69bf3b6500f195c3deb69c1205ba8fc3cb21d1915f1f158a10d6b1ef29b6a", size = 2033797 }, - { url = "https://files.pythonhosted.org/packages/89/b4/ad5bc2b43b7ca8fd5f5068eca7f195565f53911d9ae69925f7f21859a929/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aa4d1b2eba9a325897308b3124014a142cdccb9f3e016f31d3ebee6b5ea5e75", size = 2767592 }, - { url = "https://files.pythonhosted.org/packages/3e/a6/7fb0725eaf1122518c018bfe38aaf4ad3d512e8598e2c08419b9a270f4bf/pydantic_core-2.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e96ca781e0c01e32115912ebdf7b3fb0780ce748b80d7d28a0802fa9fbaf44e", size = 2130244 }, - { url = "https://files.pythonhosted.org/packages/a1/2c/453e52a866947a153bb575bbbb6b14db344f07a73b2ad820ff8f40e9807b/pydantic_core-2.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b872c86d8d71827235c7077461c502feb2db3f87d9d6d5a9daa64287d75e4fa0", size = 1979626 }, - { url = "https://files.pythonhosted.org/packages/7a/43/1faa8601085dab2a37dfaca8d48605b76e38aeefcde58bf95534ab96b135/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:82e1ad4ca170e8af4c928b67cff731b6296e6a0a0981b97b2eb7c275cc4e15bd", size = 1990741 }, - { url = "https://files.pythonhosted.org/packages/dd/ef/21f25f5964979b7e6f9102074083b5448c22c871da438d91db09601e6634/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:eb40f828bc2f73f777d1eb8fee2e86cd9692a4518b63b6b5aa8af915dfd3207b", size = 2086325 }, - { url = "https://files.pythonhosted.org/packages/8a/f9/81e5f910571a20655dd7bf10e6d6db8c279e250bfbdb5ab1a09ce3e0eb82/pydantic_core-2.27.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9a8fbf506fde1529a1e3698198fe64bfbe2e0c09557bc6a7dcf872e7c01fec40", size = 2138839 }, - { url = "https://files.pythonhosted.org/packages/59/c4/27917b73d0631098b91f2ec303e1becb823fead0628ee9055fca78ec1e2e/pydantic_core-2.27.0-cp311-none-win32.whl", hash = "sha256:24f984fc7762ed5f806d9e8c4c77ea69fdb2afd987b4fd319ef06c87595a8c55", size = 1809514 }, - { url = "https://files.pythonhosted.org/packages/ea/48/a30c67d62b8f39095edc3dab6abe69225e8c57186f31cc59a1ab984ea8e6/pydantic_core-2.27.0-cp311-none-win_amd64.whl", hash = "sha256:68950bc08f9735306322bfc16a18391fcaac99ded2509e1cc41d03ccb6013cfe", size = 1971838 }, - { url = "https://files.pythonhosted.org/packages/4e/9e/3798b901cf331058bae0ba4712a52fb0106c39f913830aaf71f01fd10d45/pydantic_core-2.27.0-cp311-none-win_arm64.whl", hash = "sha256:3eb8849445c26b41c5a474061032c53e14fe92a11a5db969f722a2716cd12206", size = 1862174 }, - { url = "https://files.pythonhosted.org/packages/82/99/43149b127559f3152cd28cb7146592c6547cfe47d528761954e2e8fcabaf/pydantic_core-2.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8117839a9bdbba86e7f9df57018fe3b96cec934c3940b591b0fd3fbfb485864a", size = 1887064 }, - { url = "https://files.pythonhosted.org/packages/7e/dd/989570c76334aa55ccb4ee8b5e0e6881a513620c6172d93b2f3b77e10f81/pydantic_core-2.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a291d0b4243a259c8ea7e2b84eb9ccb76370e569298875a7c5e3e71baf49057a", size = 1804405 }, - { url = "https://files.pythonhosted.org/packages/3e/b5/bce1d6d6fb71d916c74bf988b7d0cd7fc0c23da5e08bc0d6d6e08c12bf36/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84e35afd9e10b2698e6f2f32256678cb23ca6c1568d02628033a837638b3ed12", size = 1822595 }, - { url = "https://files.pythonhosted.org/packages/35/93/a6e5e04625ac8fcbed523d7b741e91cc3a37ed1e04e16f8f2f34269bbe53/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58ab0d979c969983cdb97374698d847a4acffb217d543e172838864636ef10d9", size = 1848701 }, - { url = "https://files.pythonhosted.org/packages/3a/74/56ead1436e3f6513b59b3a442272578a6ec09a39ab95abd5ee321bcc8c95/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d06b667e53320332be2bf6f9461f4a9b78092a079b8ce8634c9afaa7e10cd9f", size = 2031878 }, - { url = "https://files.pythonhosted.org/packages/e1/4d/8905b2710ef653c0da27224bfb6a084b5873ad6fdb975dda837943e5639d/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78f841523729e43e3928a364ec46e2e3f80e6625a4f62aca5c345f3f626c6e8a", size = 2673386 }, - { url = "https://files.pythonhosted.org/packages/1d/f0/abe1511f11756d12ce18d016f3555cb47211590e4849ee02e7adfdd1684e/pydantic_core-2.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:400bf470e4327e920883b51e255617dfe4496d4e80c3fea0b5a5d0bf2c404dd4", size = 2152867 }, - { url = "https://files.pythonhosted.org/packages/c7/90/1c588d4d93ce53e1f5ab0cea2d76151fcd36613446bf99b670d7da9ddf89/pydantic_core-2.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:951e71da6c89d354572098bada5ba5b5dc3a9390c933af8a614e37755d3d1840", size = 1986595 }, - { url = "https://files.pythonhosted.org/packages/a3/9c/27d06369f39375966836cde5c8aec0a66dc2f532c13d9aa1a6c370131fbd/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a51ce96224eadd1845150b204389623c8e129fde5a67a84b972bd83a85c6c40", size = 1995731 }, - { url = "https://files.pythonhosted.org/packages/26/4e/b039e52b7f4c51d9fae6715d5d2e47a57c369b8e0cb75838974a193aae40/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:483c2213a609e7db2c592bbc015da58b6c75af7360ca3c981f178110d9787bcf", size = 2085771 }, - { url = "https://files.pythonhosted.org/packages/01/93/2796bd116a93e7e4e10baca4c55266c4d214b3b4e5ee7f0e9add69c184af/pydantic_core-2.27.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:359e7951f04ad35111b5ddce184db3391442345d0ab073aa63a95eb8af25a5ef", size = 2150452 }, - { url = "https://files.pythonhosted.org/packages/0f/93/e57562d6ea961557174c3afa481a73ce0e2d8b823e0eb2b320bfb00debbe/pydantic_core-2.27.0-cp312-none-win32.whl", hash = "sha256:ee7d9d5537daf6d5c74a83b38a638cc001b648096c1cae8ef695b0c919d9d379", size = 1830767 }, - { url = "https://files.pythonhosted.org/packages/44/00/4f121ca5dd06420813e7858395b5832603ed0074a5b74ef3104c8dbc2fd5/pydantic_core-2.27.0-cp312-none-win_amd64.whl", hash = "sha256:2be0ad541bb9f059954ccf8877a49ed73877f862529575ff3d54bf4223e4dd61", size = 1973909 }, - { url = "https://files.pythonhosted.org/packages/c3/c7/36f87c0dabbde9c0dd59b9024e4bf117a5122515c864ddbe685ed8301670/pydantic_core-2.27.0-cp312-none-win_arm64.whl", hash = "sha256:6e19401742ed7b69e51d8e4df3c03ad5ec65a83b36244479fd70edde2828a5d9", size = 1877037 }, - { url = "https://files.pythonhosted.org/packages/9d/b2/740159bdfe532d856e340510246aa1fd723b97cadf1a38153bdfb52efa28/pydantic_core-2.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5f2b19b8d6fca432cb3acf48cf5243a7bf512988029b6e6fd27e9e8c0a204d85", size = 1886935 }, - { url = "https://files.pythonhosted.org/packages/ca/2a/2f435d9fd591c912ca227f29c652a93775d35d54677b57c3157bbad823b5/pydantic_core-2.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c86679f443e7085ea55a7376462553996c688395d18ef3f0d3dbad7838f857a2", size = 1805318 }, - { url = "https://files.pythonhosted.org/packages/ba/f2/755b628009530b19464bb95c60f829b47a6ef7930f8ca1d87dac90fd2848/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:510b11e9c3b1a852876d1ccd8d5903684336d635214148637ceb27366c75a467", size = 1822284 }, - { url = "https://files.pythonhosted.org/packages/3d/c2/a12744628b1b55c5384bd77657afa0780868484a92c37a189fb460d1cfe7/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb704155e73b833801c247f39d562229c0303f54770ca14fb1c053acb376cf10", size = 1848522 }, - { url = "https://files.pythonhosted.org/packages/60/1d/dfcb8ab94a4637d4cf682550a2bf94695863988e7bcbd6f4d83c04178e17/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ce048deb1e033e7a865ca384770bccc11d44179cf09e5193a535c4c2f497bdc", size = 2031678 }, - { url = "https://files.pythonhosted.org/packages/ee/c8/f9cbcab0275e031c4312223c75d999b61fba60995003cd89dc4866300059/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58560828ee0951bb125c6f2862fbc37f039996d19ceb6d8ff1905abf7da0bf3d", size = 2672948 }, - { url = "https://files.pythonhosted.org/packages/41/f9/c613546237cf58ed7a7fa9158410c14d0e7e0cbbf95f83a905c9424bb074/pydantic_core-2.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb4785894936d7682635726613c44578c420a096729f1978cd061a7e72d5275", size = 2152419 }, - { url = "https://files.pythonhosted.org/packages/49/71/b951b03a271678b1d1b79481dac38cf8bce8a4e178f36ada0e9aff65a679/pydantic_core-2.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2883b260f7a93235488699d39cbbd94fa7b175d3a8063fbfddd3e81ad9988cb2", size = 1986408 }, - { url = "https://files.pythonhosted.org/packages/9a/2c/07b0d5b5e1cdaa07b7c23e758354377d294ff0395116d39c9fa734e5d89e/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c6fcb3fa3855d583aa57b94cf146f7781d5d5bc06cb95cb3afece33d31aac39b", size = 1995895 }, - { url = "https://files.pythonhosted.org/packages/63/09/c21e0d7438c7e742209cc8603607c8d389df96018396c8a2577f6e24c5c5/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e851a051f7260e6d688267eb039c81f05f23a19431bd7dfa4bf5e3cb34c108cd", size = 2085914 }, - { url = "https://files.pythonhosted.org/packages/68/e4/5ed8f09d92655dcd0a86ee547e509adb3e396cef0a48f5c31e3b060bb9d0/pydantic_core-2.27.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edb1bfd45227dec8d50bc7c7d86463cd8728bcc574f9b07de7369880de4626a3", size = 2150217 }, - { url = "https://files.pythonhosted.org/packages/cd/e6/a202f0e1b81c729130404e82d9de90dc4418ec01df35000d48d027c38501/pydantic_core-2.27.0-cp313-none-win32.whl", hash = "sha256:678f66462058dd978702db17eb6a3633d634f7aa0deaea61e0a674152766d3fc", size = 1830973 }, - { url = "https://files.pythonhosted.org/packages/06/3d/21ed0f308e6618ce6c5c6bfb9e71734a9a3256d5474a53c8e5aaaba498ca/pydantic_core-2.27.0-cp313-none-win_amd64.whl", hash = "sha256:d28ca7066d6cdd347a50d8b725dc10d9a1d6a1cce09836cf071ea6a2d4908be0", size = 1974853 }, - { url = "https://files.pythonhosted.org/packages/d7/18/e5744a132b81f98b9f92e15f33f03229a1d254ce7af942b1422ec2ac656f/pydantic_core-2.27.0-cp313-none-win_arm64.whl", hash = "sha256:6f4a53af9e81d757756508b57cae1cf28293f0f31b9fa2bfcb416cc7fb230f9d", size = 1877469 }, - { url = "https://files.pythonhosted.org/packages/d3/ad/c1dc814ab524cb247ceb6cb25236895a5cae996c438baf504db610fd6c92/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4fb49cfdb53af5041aba909be00cccfb2c0d0a2e09281bf542371c5fd36ad04c", size = 1889233 }, - { url = "https://files.pythonhosted.org/packages/24/bb/069a9dd910e6c09aab90a118c08d3cb30dc5738550e9f2d21f3b086352c2/pydantic_core-2.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:49633583eb7dc5cba61aaf7cdb2e9e662323ad394e543ee77af265736bcd3eaa", size = 1768419 }, - { url = "https://files.pythonhosted.org/packages/cb/a1/f9b4e625ee8c7f683c8295c85d11f79a538eb53719f326646112a7800bda/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:153017e3d6cd3ce979de06d84343ca424bb6092727375eba1968c8b4693c6ecb", size = 1822870 }, - { url = "https://files.pythonhosted.org/packages/12/07/04abaeeabf212650de3edc300b2ab89fb17da9bc4408ef4e01a62efc87dc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff63a92f6e249514ef35bc795de10745be0226eaea06eb48b4bbeaa0c8850a4a", size = 1977039 }, - { url = "https://files.pythonhosted.org/packages/0f/9d/99bbeb21d5be1d5affdc171e0e84603a757056f9f4293ef236e41af0a5bc/pydantic_core-2.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5982048129f40b082c2654de10c0f37c67a14f5ff9d37cf35be028ae982f26df", size = 1974317 }, - { url = "https://files.pythonhosted.org/packages/5f/78/815aa74db1591a9ad4086bc1bf98e2126686245a956d76cd4e72bf9841ad/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:91bc66f878557313c2a6bcf396e7befcffe5ab4354cfe4427318968af31143c3", size = 1985101 }, - { url = "https://files.pythonhosted.org/packages/d9/a8/9c1557d5282108916448415e85f829b70ba99d97f03cee0e40a296e58a65/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:68ef5377eb582fa4343c9d0b57a5b094046d447b4c73dd9fbd9ffb216f829e7d", size = 2073399 }, - { url = "https://files.pythonhosted.org/packages/ca/b0/5296273d652fa9aa140771b3f4bb574edd3cbf397090625b988f6a57b02b/pydantic_core-2.27.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c5726eec789ee38f2c53b10b1821457b82274f81f4f746bb1e666d8741fcfadb", size = 2129499 }, - { url = "https://files.pythonhosted.org/packages/e9/fd/7f39ff702fdca954f26c84b40d9bf744733bb1a50ca6b7569822b9cbb7f4/pydantic_core-2.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c0c431e4be5c1a0c6654e0c31c661cd89e0ca956ef65305c3c3fd96f4e72ca39", size = 1997246 }, +sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 }, + { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 }, + { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 }, + { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 }, + { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 }, + { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 }, + { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 }, + { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 }, + { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 }, + { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 }, + { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 }, + { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 }, + { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 }, + { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 }, + { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 }, + { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 }, + { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 }, + { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 }, + { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 }, + { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 }, + { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 }, + { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 }, + { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 }, + { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 }, + { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 }, + { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 }, + { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, + { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, + { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, + { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, + { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, + { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, + { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, + { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, + { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, + { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, + { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, + { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, + { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, + { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, + { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, + { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, + { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, + { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, + { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, + { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, + { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, + { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, + { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 }, + { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 }, + { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 }, + { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 }, + { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 }, + { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 }, + { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 }, + { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 }, + { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, ] [[package]] From 44e1abb7f11cb67f43ecc835edbd8a35a7f83f76 Mon Sep 17 00:00:00 2001 From: David Soria Parra Date: Sat, 21 Dec 2024 01:16:10 +0000 Subject: [PATCH 20/20] fix tests --- pyproject.toml | 1 + tests/server/fastmcp/test_func_metadata.py | 4 ++-- tests/test_examples.py | 15 ++++++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 73c525108..8e487f403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ target-version = "py310" [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] +"tests/server/fastmcp/test_func_metadata.py" = ["E501"] [tool.uv.workspace] members = ["examples/servers/*"] diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 8619e4737..7173b43b2 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -300,7 +300,7 @@ def test_complex_function_json_schema(): }, "field_with_default_via_field_annotation_before_nondefault_arg": { "default": 1, - "title": "Field With Default Via Field Annotation Before Arg", + "title": "Field With Default Via Field Annotation Before Nondefault Arg", "type": "integer", }, "unannotated": {"title": "unannotated", "type": "string"}, @@ -319,7 +319,7 @@ def test_complex_function_json_schema(): "type": "string", }, "my_model_a_with_default": { - "allOf": [{"$ref": "#/$defs/SomeInputModelA"}], + "$ref": "#/$defs/SomeInputModelA", "default": {}, }, "an_int_with_default": { diff --git a/tests/test_examples.py b/tests/test_examples.py index 62cd18c88..b097fafbf 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -5,7 +5,7 @@ from mcp.shared.memory import ( create_connected_server_and_client_session as client_session, ) -from mcp.types import TextContent +from mcp.types import TextContent, TextResourceContents @pytest.mark.anyio @@ -41,12 +41,19 @@ async def test_complex_inputs(): @pytest.mark.anyio -async def test_desktop(): +async def test_desktop(monkeypatch): """Test the desktop server""" + from pathlib import Path + from pydantic import AnyUrl from examples.fastmcp.desktop import mcp + # Mock desktop directory listing + mock_files = [Path("/fake/path/file1.txt"), Path("/fake/path/file2.txt")] + monkeypatch.setattr(Path, "iterdir", lambda self: mock_files) + monkeypatch.setattr(Path, "home", lambda: Path("/fake/home")) + async with client_session(mcp._mcp_server) as client: # Test the add function result = await client.call_tool("add", {"a": 1, "b": 2}) @@ -59,5 +66,7 @@ async def test_desktop(): result = await client.read_resource(AnyUrl("dir://desktop")) assert len(result.contents) == 1 content = result.contents[0] - assert isinstance(content, TextContent) + assert isinstance(content, TextResourceContents) assert isinstance(content.text, str) + assert "/fake/path/file1.txt" in content.text + assert "/fake/path/file2.txt" in content.text