From cffc38f11c2614a4760550b0da079e13234fc3b1 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 3 Sep 2025 10:22:41 -0500 Subject: [PATCH 1/9] Add FastMCP Toolset w/o tests --- .../pydantic_ai/toolsets/fastmcp.py | 257 ++++++++++++++++++ pydantic_ai_slim/pyproject.toml | 2 + 2 files changed, 259 insertions(+) create mode 100644 pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py new file mode 100644 index 0000000000..c0c85b6604 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import base64 +import contextlib +from asyncio import Lock +from contextlib import AsyncExitStack +from enum import Enum +from typing import TYPE_CHECKING, Any, Self + +import pydantic_core +from mcp.types import ( + AudioContent, + ContentBlock, + EmbeddedResource, + ImageContent, + TextContent, + TextResourceContents, + Tool as MCPTool, +) + +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR, messages +from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition +from pydantic_ai.toolsets import AbstractToolset +from pydantic_ai.toolsets.abstract import ToolsetTool + +try: + from fastmcp.client import Client + from fastmcp.client.transports import MCPConfigTransport + from fastmcp.exceptions import ToolError + from fastmcp.mcp_config import MCPConfig + from fastmcp.server.server import FastMCP +except ImportError as _import_error: + raise ImportError( + 'Please install the `fastmcp` package to use the FastMCP server, ' + 'you can use the `fastmcp` optional group — `pip install "pydantic-ai-slim[fastmcp]"`' + ) from _import_error + + +if TYPE_CHECKING: + from fastmcp import FastMCP + from fastmcp.client.client import CallToolResult + from fastmcp.client.transports import FastMCPTransport + from fastmcp.mcp_config import MCPServerTypes + + +FastMCPToolResult = messages.BinaryContent | dict[str, Any] | str | None + +FastMCPToolResults = list[FastMCPToolResult] | FastMCPToolResult + + +class ToolErrorBehavior(str, Enum): + """The behavior to take when a tool error occurs.""" + + MODEL_RETRY = 'model-retry' + """Raise a `ModelRetry` containing the tool error message.""" + + ERROR = 'raise' + """Raise the tool error as an exception.""" + + +class FastMCPToolset(AbstractToolset[AgentDepsT]): + """A toolset that uses a FastMCP client as the underlying toolset.""" + + _fastmcp_client: Client[Any] | None = None + _tool_error_behavior: ToolErrorBehavior + + _tool_retries: int + + _enter_lock: Lock + _running_count: int + _exit_stack: AsyncExitStack | None + + def __init__( + self, fastmcp_client: Client[Any], tool_retries: int = 2, tool_error_behavior: ToolErrorBehavior | None = None + ): + self._tool_retries = tool_retries + self._fastmcp_client = fastmcp_client + self._enter_lock = Lock() + self._running_count = 0 + + self._tool_error_behavior = tool_error_behavior or ToolErrorBehavior.ERROR + + super().__init__() + + @property + def id(self) -> str | None: + return None + + async def __aenter__(self) -> Self: + async with self._enter_lock: + if self._running_count == 0 and self._fastmcp_client: + self._exit_stack = AsyncExitStack() + await self._exit_stack.enter_async_context(self._fastmcp_client) + self._running_count += 1 + + return self + + async def __aexit__(self, *args: Any) -> bool | None: + async with self._enter_lock: + self._running_count -= 1 + if self._running_count == 0 and self._exit_stack: + await self._exit_stack.aclose() + self._exit_stack = None + + return None + + @property + def fastmcp_client(self) -> Client[FastMCPTransport]: + if not self._fastmcp_client: + msg = 'FastMCP client not initialized' + raise RuntimeError(msg) + + return self._fastmcp_client + + async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: + mcp_tools: list[MCPTool] = await self.fastmcp_client.list_tools() + + return { + tool.name: convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self._tool_retries) + for tool in mcp_tools + } + + async def call_tool( + self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT] + ) -> Any: + try: + call_tool_result: CallToolResult = await self.fastmcp_client.call_tool(name=name, arguments=tool_args) + except ToolError as e: + if self._tool_error_behavior == ToolErrorBehavior.MODEL_RETRY: + raise ModelRetry(message=str(object=e)) from e + else: + raise e + + # We don't use call_tool_result.data at the moment because it requires the json schema to be translatable + # back into pydantic models otherwise it will be missing data. + + return call_tool_result.structured_content or _map_fastmcp_tool_results(parts=call_tool_result.content) + + @classmethod + def from_fastmcp_server( + cls, fastmcp_server: FastMCP[Any], tool_error_behavior: ToolErrorBehavior | None = None + ) -> Self: + """Build a FastMCPToolset from a FastMCP server. + + Example: + ```python + fastmcp_server = FastMCP('my_server') + @fastmcp_server.tool() + async def my_tool(a: int, b: int) -> int: + return a + b + + toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server=fastmcp_server) + ``` + """ + fastmcp_client: Client[FastMCPTransport] = Client[FastMCPTransport](transport=fastmcp_server) + return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) + + @classmethod + def from_mcp_server( + cls, + name: str, + mcp_server: MCPServerTypes | dict[str, Any], + tool_error_behavior: ToolErrorBehavior | None = None, + ) -> Self: + """Build a FastMCPToolset from an individual MCP server configuration. + + Example: + ```python + cls.from_mcp_server(name='my_server', mcp_server={ + 'cmd': 'uvx', + 'args': [ + "time-server-mcp", + ] + }) + ``` + """ + mcp_config: MCPConfig = MCPConfig.from_dict(config={name: mcp_server}) + + return cls.from_mcp_config(mcp_config=mcp_config, tool_error_behavior=tool_error_behavior) + + @classmethod + def from_mcp_config( + cls, mcp_config: MCPConfig | dict[str, Any], tool_error_behavior: ToolErrorBehavior | None = None + ) -> Self: + """Build a FastMCPToolset from an MCP json-derived / dictionary configuration object. + + Example: + ```python + cls.from_mcp_config(mcp_config={ + 'mcpServers': { + 'my_server': { + 'cmd': 'uvx', + 'args': [ + "time-server-mcp", + ] + } + } + }) + ``` + """ + fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=mcp_config) + return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) + + +def convert_mcp_tool_to_toolset_tool( + toolset: FastMCPToolset[AgentDepsT], + mcp_tool: MCPTool, + retries: int, +) -> ToolsetTool[AgentDepsT]: + """Convert an MCP tool to a toolset tool.""" + return ToolsetTool[AgentDepsT]( + tool_def=ToolDefinition( + name=mcp_tool.name, + description=mcp_tool.description, + parameters_json_schema=mcp_tool.inputSchema, + ), + toolset=toolset, + max_retries=retries, + args_validator=TOOL_SCHEMA_VALIDATOR, + ) + + +def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult]: + """Map FastMCP tool results to toolset tool results.""" + return [_map_fastmcp_tool_result(part) for part in parts] + + +def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: + if isinstance(part, TextContent): + text = part.text + if text.startswith(('[', '{')): + with contextlib.suppress(ValueError): + result: Any = pydantic_core.from_json(text) + if isinstance(result, dict | list): + return result # pyright: ignore[reportUnknownVariableType, reportReturnType] + return text + + if isinstance(part, ImageContent): + return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) + + if isinstance(part, AudioContent): + return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) + + if isinstance(part, EmbeddedResource): + resource = part.resource + if isinstance(resource, TextResourceContents): + return resource.text + + # BlobResourceContents + return messages.BinaryContent( + data=base64.b64decode(resource.blob), + media_type=resource.mimeType or 'application/octet-stream', + ) + + msg = f'Unsupported/Unknown content block type: {type(part)}' + raise ValueError(msg) diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index bf35813b45..9785d6fe5e 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -89,6 +89,8 @@ cli = [ ] # MCP mcp = ["mcp>=1.12.3"] +# FastMCP +fastmcp = ["fastmcp>=2.12.0"] # Evals evals = ["pydantic-evals=={{ version }}"] # A2A From 456900e2168975d985fc2a81689cdaed915c1019 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 3 Sep 2025 13:53:54 -0500 Subject: [PATCH 2/9] Adding tests --- docs/toolsets.md | 43 ++ .../pydantic_ai/toolsets/fastmcp.py | 128 ++--- tests/test_fastmcp.py | 453 ++++++++++++++++++ 3 files changed, 569 insertions(+), 55 deletions(-) create mode 100644 tests/test_fastmcp.py diff --git a/docs/toolsets.md b/docs/toolsets.md index 7c1bce84d6..361e06db4f 100644 --- a/docs/toolsets.md +++ b/docs/toolsets.md @@ -663,6 +663,49 @@ If you want to reuse a network connection or session across tool listings and ca See the [MCP Client](./mcp/client.md) documentation for how to use MCP servers with Pydantic AI. +### FastMCP Tools {#fastmcp-tools} + +If you'd like to use tools from a [FastMCP](https://fastmcp.dev) Server, Client, or JSON MCP Configuration with Pydantic AI, you can use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] [toolset](toolsets.md). + +You will need to install the `fastmcp` package and any others required by the tools in question. + +```python {test="skip"} +from fastmcp import FastMCP + +from pydantic_ai import Agent +from pydantic_ai.toolsets.fastmcp import FastMCPToolset + +fastmcp_server = FastMCP('my_server') +@fastmcp_server.tool() +async def my_tool(a: int, b: int) -> int: + return a + b + +toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server) + +agent = Agent('openai:gpt-4o', toolsets=[toolset]) +``` + +You can also use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] to create a toolset from a JSON MCP Configuration. + +```python {test="skip"} +from pydantic_ai import Agent +from pydantic_ai.toolsets.fastmcp import FastMCPToolset + +mcp_config = { + 'mcpServers': { + 'time_mcp_server': { + 'command': 'uvx', + 'args': ['mcp-server-time'] + } + } +} + +toolset = FastMCPToolset.from_mcp_config(mcp_config) + +agent = Agent('openai:gpt-4o', toolsets=[toolset]) +``` + + ### LangChain Tools {#langchain-tools} If you'd like to use tools or a [toolkit](https://python.langchain.com/docs/concepts/tools/#toolkits) from LangChain's [community tool library](https://python.langchain.com/docs/integrations/tools/) with Pydantic AI, you can use the [`LangChainToolset`][pydantic_ai.ext.langchain.LangChainToolset] which takes a list of LangChain tools. Note that Pydantic AI will not validate the arguments in this case -- it's up to the model to provide arguments matching the schema specified by the LangChain tool, and up to the LangChain tool to raise an error if the arguments are invalid. diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index c0c85b6604..611a17fbf6 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Self import pydantic_core +from fastmcp.client.transports import MCPConfigTransport from mcp.types import ( AudioContent, ContentBlock, @@ -26,7 +27,7 @@ try: from fastmcp.client import Client - from fastmcp.client.transports import MCPConfigTransport + from fastmcp.client.transports import FastMCPTransport, MCPConfigTransport from fastmcp.exceptions import ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.server.server import FastMCP @@ -62,7 +63,7 @@ class ToolErrorBehavior(str, Enum): class FastMCPToolset(AbstractToolset[AgentDepsT]): """A toolset that uses a FastMCP client as the underlying toolset.""" - _fastmcp_client: Client[Any] | None = None + _fastmcp_client: Client[Any] _tool_error_behavior: ToolErrorBehavior _tool_retries: int @@ -92,7 +93,8 @@ async def __aenter__(self) -> Self: if self._running_count == 0 and self._fastmcp_client: self._exit_stack = AsyncExitStack() await self._exit_stack.enter_async_context(self._fastmcp_client) - self._running_count += 1 + + self._running_count += 1 return self @@ -105,37 +107,36 @@ async def __aexit__(self, *args: Any) -> bool | None: return None - @property - def fastmcp_client(self) -> Client[FastMCPTransport]: - if not self._fastmcp_client: - msg = 'FastMCP client not initialized' - raise RuntimeError(msg) - - return self._fastmcp_client - async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: - mcp_tools: list[MCPTool] = await self.fastmcp_client.list_tools() + async with self: + mcp_tools: list[MCPTool] = await self._fastmcp_client.list_tools() - return { - tool.name: convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self._tool_retries) - for tool in mcp_tools - } + return { + tool.name: convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self._tool_retries) + for tool in mcp_tools + } async def call_tool( self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT] ) -> Any: - try: - call_tool_result: CallToolResult = await self.fastmcp_client.call_tool(name=name, arguments=tool_args) - except ToolError as e: - if self._tool_error_behavior == ToolErrorBehavior.MODEL_RETRY: - raise ModelRetry(message=str(object=e)) from e - else: - raise e + async with self: + try: + call_tool_result: CallToolResult = await self._fastmcp_client.call_tool(name=name, arguments=tool_args) + except ToolError as e: + if self._tool_error_behavior == ToolErrorBehavior.MODEL_RETRY: + raise ModelRetry(message=str(object=e)) from e + else: + raise e # We don't use call_tool_result.data at the moment because it requires the json schema to be translatable # back into pydantic models otherwise it will be missing data. - return call_tool_result.structured_content or _map_fastmcp_tool_results(parts=call_tool_result.content) + if call_tool_result.structured_content: + return call_tool_result.structured_content + + mapped_results = _map_fastmcp_tool_results(parts=call_tool_result.content) + + return mapped_results[0] if len(mapped_results) == 1 else mapped_results @classmethod def from_fastmcp_server( @@ -145,6 +146,10 @@ def from_fastmcp_server( Example: ```python + from fastmcp import FastMCP + + from pydantic_ai.toolsets.fastmcp import FastMCPToolset + fastmcp_server = FastMCP('my_server') @fastmcp_server.tool() async def my_tool(a: int, b: int) -> int: @@ -153,7 +158,8 @@ async def my_tool(a: int, b: int) -> int: toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server=fastmcp_server) ``` """ - fastmcp_client: Client[FastMCPTransport] = Client[FastMCPTransport](transport=fastmcp_server) + transport = FastMCPTransport(fastmcp_server) + fastmcp_client: Client[FastMCPTransport] = Client[FastMCPTransport](transport=transport) return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) @classmethod @@ -167,13 +173,23 @@ def from_mcp_server( Example: ```python - cls.from_mcp_server(name='my_server', mcp_server={ - 'cmd': 'uvx', + from pydantic_ai import Agent + from pydantic_ai.toolsets.fastmcp import FastMCPToolset + + time_mcp_server = { + 'command': 'uvx', 'args': [ - "time-server-mcp", + 'mcp-server-time', ] - }) + } + + toolset = FastMCPToolset.from_mcp_server(name='time_server', mcp_server=time_mcp_server) + agent = Agent('openai:gpt-4o', toolsets=[toolset]) + async def main(): + async with agent: # (1)! + ... ``` + 1. This will start the MCP Server running over stdio. """ mcp_config: MCPConfig = MCPConfig.from_dict(config={name: mcp_server}) @@ -187,19 +203,38 @@ def from_mcp_config( Example: ```python - cls.from_mcp_config(mcp_config={ + from pydantic_ai import Agent + from pydantic_ai.toolsets.fastmcp import FastMCPToolset + + mcp_config = { 'mcpServers': { - 'my_server': { - 'cmd': 'uvx', + 'time_server': { + 'command': 'uvx', 'args': [ - "time-server-mcp", + 'mcp-server-time', + ] + }, + 'fetch_server': { + 'command': 'uvx', + 'args': [ + 'mcp-server-fetch', ] } } - }) + } + + fastmcp_toolset = FastMCPToolset.from_mcp_config(mcp_config) + + agent = Agent('openai:gpt-4o', toolsets=[fastmcp_toolset]) + async def main(): + async with agent: # (1)! + ... ``` + + 1. This will start both MCP Servers running over stdio`. """ - fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=mcp_config) + transport: MCPConfigTransport = MCPConfigTransport(config=mcp_config) + fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=transport) return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) @@ -228,13 +263,7 @@ def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResu def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: if isinstance(part, TextContent): - text = part.text - if text.startswith(('[', '{')): - with contextlib.suppress(ValueError): - result: Any = pydantic_core.from_json(text) - if isinstance(result, dict | list): - return result # pyright: ignore[reportUnknownVariableType, reportReturnType] - return text + return part.text if isinstance(part, ImageContent): return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) @@ -242,16 +271,5 @@ def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: if isinstance(part, AudioContent): return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) - if isinstance(part, EmbeddedResource): - resource = part.resource - if isinstance(resource, TextResourceContents): - return resource.text - - # BlobResourceContents - return messages.BinaryContent( - data=base64.b64decode(resource.blob), - media_type=resource.mimeType or 'application/octet-stream', - ) - - msg = f'Unsupported/Unknown content block type: {type(part)}' - raise ValueError(msg) + msg = f'Unsupported/Unknown content block type: {type(part)}' # pragma: no cover + raise ValueError(msg) # pragma: no cover diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py new file mode 100644 index 0000000000..4324dfc6fa --- /dev/null +++ b/tests/test_fastmcp.py @@ -0,0 +1,453 @@ +"""Tests for the FastMCP Toolset implementation.""" + +from __future__ import annotations + +import base64 +from mcp.types import BlobResourceContents +from typing import Any + +import pytest +from pydantic import AnyUrl +from fastmcp.exceptions import ToolError +from inline_snapshot import snapshot + +from pydantic_ai._run_context import RunContext +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.models.test import TestModel +from pydantic_ai.usage import RunUsage + +from .conftest import try_import + +with try_import() as imports_successful: + from fastmcp.client import Client + from fastmcp.client.transports import FastMCPTransport, MCPConfigTransport + from fastmcp.mcp_config import MCPConfig + from fastmcp.server.server import FastMCP + from mcp.types import ( + ImageContent, + AudioContent, + BlobResourceContents, + ) + + # Import the content mapping functions for testing + from pydantic_ai.toolsets.fastmcp import ( + FastMCPToolset, + ToolErrorBehavior, + ) + + +pytestmark = [ + pytest.mark.skipif(not imports_successful(), reason='fastmcp not installed'), + pytest.mark.anyio, +] + + +@pytest.fixture +async def fastmcp_server() -> FastMCP: + """Create a real in-memory FastMCP server for testing.""" + server = FastMCP('test_server') + + @server.tool() + async def test_tool(param1: str, param2: int = 0) -> str: + """A test tool that returns a formatted string.""" + return f'param1={param1}, param2={param2}' + + @server.tool() + async def another_tool(value: float) -> dict[str, Any]: + """Another test tool that returns structured data.""" + return {'result': 'success', 'value': value, 'doubled': value * 2} + + @server.tool() + async def error_tool(should_fail: bool = False) -> str: + """A tool that can fail for testing error handling.""" + if should_fail: + raise ValueError('This is a test error') + return 'success' + + @server.tool() + async def binary_tool() -> ImageContent: + """A tool that returns binary content.""" + fake_image_data = b'fake_image_data' + encoded_data = base64.b64encode(fake_image_data).decode('utf-8') + return ImageContent(type='image', data=encoded_data, mimeType='image/png') + + @server.tool() + async def audio_tool() -> AudioContent: + """A tool that returns audio content.""" + fake_audio_data = b'fake_audio_data' + encoded_data = base64.b64encode(fake_audio_data).decode('utf-8') + return AudioContent(type='audio', data=encoded_data, mimeType='audio/mpeg') + + @server.tool() + async def text_tool(message: str) -> str: + """A tool that returns text content.""" + return f'Echo: {message}' + + @server.tool() + async def text_tool_wo_return_annotation(message: str): + """A tool that returns text content.""" + return f'Echo: {message}' + + @server.tool() + async def json_tool(data: dict[str, Any]) -> str: + """A tool that returns JSON data.""" + import json + + return json.dumps({'received': data, 'processed': True}) + + return server + + +@pytest.fixture +async def fastmcp_client(fastmcp_server: FastMCP) -> Client[FastMCPTransport]: + """Create a real FastMCP client connected to the test server.""" + return Client(transport=fastmcp_server) + + +@pytest.fixture +def run_context() -> RunContext[None]: + """Create a run context for testing.""" + return RunContext( + deps=None, + model=TestModel(), + usage=RunUsage(), + prompt=None, + messages=[], + run_step=0, + ) + + +def get_client_from_toolset(toolset: FastMCPToolset[None]) -> Client[FastMCPTransport]: + """Get the client from the toolset.""" + return toolset._fastmcp_client # pyright: ignore[reportPrivateUsage] + + +class TestFastMCPToolsetInitialization: + """Test FastMCP Toolset initialization and basic functionality.""" + + async def test_init_with_client(self, fastmcp_client: Client[FastMCPTransport]): + """Test initialization with a FastMCP client.""" + toolset = FastMCPToolset(fastmcp_client) + + # Test that the client is accessible via the property + assert toolset.id is None + + async def test_init_with_custom_retries_and_error_behavior(self, fastmcp_client: Client[FastMCPTransport]): + """Test initialization with custom retries and error behavior.""" + toolset = FastMCPToolset(fastmcp_client, tool_retries=5, tool_error_behavior=ToolErrorBehavior.MODEL_RETRY) + + # Test that the toolset was created successfully + assert get_client_from_toolset(toolset) is fastmcp_client + + async def test_id_property(self, fastmcp_client: Client[FastMCPTransport]): + """Test that the id property returns None.""" + toolset = FastMCPToolset(fastmcp_client) + assert toolset.id is None + + +class TestFastMCPToolsetContextManagement: + """Test FastMCP Toolset context management.""" + + async def test_context_manager_single_enter_exit( + self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None] + ): + """Test single enter/exit cycle.""" + toolset = FastMCPToolset(fastmcp_client) + + async with toolset: + # Test that we can get tools when the context is active + tools = await toolset.get_tools(run_context) + assert len(tools) > 0 + assert 'test_tool' in tools + + # After exit, the toolset should still be usable but the client connection is closed + + async def test_context_manager_no_enter( + self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None] + ): + """Test no enter/exit cycle.""" + toolset = FastMCPToolset(fastmcp_client) + + # Test that we can get tools when the context is not active + tools = await toolset.get_tools(run_context) + assert len(tools) > 0 + assert 'test_tool' in tools + + async def test_context_manager_nested_enter_exit( + self, fastmcp_client: Client[FastMCPTransport], run_context: RunContext[None] + ): + """Test nested enter/exit cycles.""" + toolset = FastMCPToolset(fastmcp_client) + + async with toolset: + tools1 = await toolset.get_tools(run_context) + async with toolset: + tools2 = await toolset.get_tools(run_context) + assert tools1 == tools2 + # Should still work after inner context exits + tools3 = await toolset.get_tools(run_context) + assert tools1 == tools3 + + +class TestFastMCPToolsetToolDiscovery: + """Test FastMCP Toolset tool discovery functionality.""" + + async def test_get_tools( + self, + fastmcp_client: Client[FastMCPTransport], + run_context: RunContext[None], + ): + """Test getting tools from the FastMCP client.""" + toolset = FastMCPToolset(fastmcp_client) + + async with toolset: + tools = await toolset.get_tools(run_context) + + # Should have all the tools we defined in the server + expected_tools = {'test_tool', 'another_tool', 'audio_tool', 'error_tool', 'binary_tool', 'text_tool', 'text_tool_wo_return_annotation', 'json_tool'} + assert set(tools.keys()) == expected_tools + + # Check tool definitions + test_tool = tools['test_tool'] + assert test_tool.tool_def.name == 'test_tool' + assert test_tool.tool_def.description is not None + assert 'test tool that returns a formatted string' in test_tool.tool_def.description + assert test_tool.max_retries == 2 + assert test_tool.toolset is toolset + + # Check that the tool has proper schema + schema = test_tool.tool_def.parameters_json_schema + assert schema['type'] == 'object' + assert 'param1' in schema['properties'] + assert 'param2' in schema['properties'] + + async def test_get_tools_with_empty_server(self, run_context: RunContext[None]): + """Test getting tools from an empty FastMCP server.""" + empty_server = FastMCP('empty_server') + empty_client = Client(transport=empty_server) + toolset = FastMCPToolset(empty_client) + + async with toolset: + tools = await toolset.get_tools(run_context) + assert len(tools) == 0 + + +class TestFastMCPToolsetToolCalling: + """Test FastMCP Toolset tool calling functionality.""" + + @pytest.fixture + async def fastmcp_toolset(self, fastmcp_client: Client[FastMCPTransport]) -> FastMCPToolset[None]: + """Create a FastMCP Toolset.""" + return FastMCPToolset(fastmcp_client) + + async def test_call_tool_success( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test successful tool call.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + test_tool = tools['test_tool'] + + result = await fastmcp_toolset.call_tool( + name='test_tool', tool_args={'param1': 'hello', 'param2': 42}, ctx=run_context, tool=test_tool + ) + + assert result == {'result': 'param1=hello, param2=42'} + + async def test_call_tool_with_structured_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call with structured content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + another_tool = tools['another_tool'] + + result = await fastmcp_toolset.call_tool( + name='another_tool', tool_args={'value': 3.14}, ctx=run_context, tool=another_tool + ) + + assert result == {'result': 'success', 'value': 3.14, 'doubled': 6.28} + + async def test_call_tool_with_binary_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call that returns binary content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + binary_tool = tools['binary_tool'] + + result = await fastmcp_toolset.call_tool( + name='binary_tool', tool_args={}, ctx=run_context, tool=binary_tool + ) + + assert isinstance(result, dict) + + assert result == snapshot( + { + 'type': 'image', + 'data': 'ZmFrZV9pbWFnZV9kYXRh', + 'mimeType': 'image/png', + 'annotations': None, + '_meta': None, + } + ) + + async def test_call_tool_with_audio_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call that returns audio content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + audio_tool = tools['audio_tool'] + + result = await fastmcp_toolset.call_tool( + name='audio_tool', tool_args={}, ctx=run_context, tool=audio_tool + ) + + assert isinstance(result, dict) + + assert result == snapshot( + { + 'type': 'audio', + 'data': 'ZmFrZV9hdWRpb19kYXRh', + 'mimeType': 'audio/mpeg', + 'annotations': None, + '_meta': None, + } + ) + + async def test_call_tool_with_text_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call that returns text content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + text_tool = tools['text_tool'] + + result = await fastmcp_toolset.call_tool( + name='text_tool', tool_args={'message': 'Hello World'}, ctx=run_context, tool=text_tool + ) + + assert result == snapshot({'result': 'Echo: Hello World'}) + + async def test_call_tool_with_unknown_text_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call that returns text content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + text_tool = tools['text_tool_wo_return_annotation'] + + result = await fastmcp_toolset.call_tool( + name='text_tool_wo_return_annotation', + tool_args={'message': 'Hello World'}, + ctx=run_context, + tool=text_tool, + ) + + assert result == snapshot('Echo: Hello World') + + async def test_call_tool_with_json_content( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call that returns JSON content.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + json_tool = tools['json_tool'] + + result = await fastmcp_toolset.call_tool( + name='json_tool', tool_args={'data': {'key': 'value'}}, ctx=run_context, tool=json_tool + ) + + # Should parse the JSON string into a dict + assert result == snapshot({'result': '{"received": {"key": "value"}, "processed": true}'}) + + async def test_call_tool_with_error_behavior_raise( + self, + fastmcp_toolset: FastMCPToolset[None], + run_context: RunContext[None], + ): + """Test tool call with error behavior set to raise.""" + async with fastmcp_toolset: + tools = await fastmcp_toolset.get_tools(run_context) + error_tool = tools['error_tool'] + + with pytest.raises(ToolError, match='This is a test error'): + await fastmcp_toolset.call_tool( + name='error_tool', tool_args={'should_fail': True}, ctx=run_context, tool=error_tool + ) + + async def test_call_tool_with_error_behavior_model_retry( + self, + fastmcp_client: Client[FastMCPTransport], + run_context: RunContext[None], + ): + """Test tool call with error behavior set to model retry.""" + toolset = FastMCPToolset(fastmcp_client, tool_error_behavior=ToolErrorBehavior.MODEL_RETRY) + + async with toolset: + tools = await toolset.get_tools(run_context) + error_tool = tools['error_tool'] + + with pytest.raises(ModelRetry, match='This is a test error'): + await toolset.call_tool('error_tool', {'should_fail': True}, run_context, error_tool) + + +class TestFastMCPToolsetFactoryMethods: + """Test FastMCP Toolset factory methods.""" + + async def test_from_fastmcp_server(self, fastmcp_server: FastMCP, run_context: RunContext[None]): + """Test creating toolset from FastMCP server.""" + toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server) + + assert isinstance(toolset, FastMCPToolset) + assert toolset.id is None + + async with toolset: + tools = await toolset.get_tools(run_context) + assert 'test_tool' in tools + + async def test_from_mcp_server(self, run_context: RunContext[None]): + """Test creating toolset from MCP server configuration.""" + + mcp_server_config = {'command': 'python', 'args': ['-c', 'print("test")']} + + toolset = FastMCPToolset.from_mcp_server(name='test_server', mcp_server=mcp_server_config) + assert isinstance(toolset, FastMCPToolset) + + client = get_client_from_toolset(toolset) + assert isinstance(client.transport, MCPConfigTransport) + + async def test_from_mcp_config_dict(self, run_context: RunContext[None]): + """Test creating toolset from MCP config dictionary.""" + + config_dict = {'mcpServers': {'test_server': {'command': 'python', 'args': ['-c', 'print("test")']}}} + + toolset = FastMCPToolset.from_mcp_config(mcp_config=config_dict) + client = get_client_from_toolset(toolset) + assert isinstance(client.transport, MCPConfigTransport) + + async def test_from_mcp_config_mcp_config(self, run_context: RunContext[None]): + """Test creating toolset from MCPConfig object.""" + # Create a real MCPConfig object + config = MCPConfig.from_dict( + {'mcpServers': {'test_server': {'command': 'python', 'args': ['-c', 'print("test")']}}} + ) + + toolset = FastMCPToolset.from_mcp_config(mcp_config=config) + client = get_client_from_toolset(toolset) + assert isinstance(client.transport, MCPConfigTransport) From 9bac437b7507f2b68868af95bae1fbc9603c3ba9 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 3 Sep 2025 19:22:49 -0500 Subject: [PATCH 3/9] PR Clean-up and coverage --- .../pydantic_ai/toolsets/fastmcp.py | 23 +++---- tests/test_fastmcp.py | 61 +++++++++++-------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index 611a17fbf6..e17ab98beb 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -1,21 +1,17 @@ from __future__ import annotations import base64 -import contextlib from asyncio import Lock from contextlib import AsyncExitStack from enum import Enum from typing import TYPE_CHECKING, Any, Self -import pydantic_core from fastmcp.client.transports import MCPConfigTransport from mcp.types import ( AudioContent, ContentBlock, - EmbeddedResource, ImageContent, TextContent, - TextResourceContents, Tool as MCPTool, ) @@ -128,15 +124,15 @@ async def call_tool( else: raise e - # We don't use call_tool_result.data at the moment because it requires the json schema to be translatable - # back into pydantic models otherwise it will be missing data. + # If any of the results are not text content, let's map them to Pydantic AI binary message parts + if any(not isinstance(part, TextContent) for part in call_tool_result.content): + return _map_fastmcp_tool_results(parts=call_tool_result.content) + # Otherwise, if we have structured content, return that if call_tool_result.structured_content: return call_tool_result.structured_content - mapped_results = _map_fastmcp_tool_results(parts=call_tool_result.content) - - return mapped_results[0] if len(mapped_results) == 1 else mapped_results + return _map_fastmcp_tool_results(parts=call_tool_result.content) @classmethod def from_fastmcp_server( @@ -256,9 +252,14 @@ def convert_mcp_tool_to_toolset_tool( ) -def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult]: +def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult] | FastMCPToolResult: """Map FastMCP tool results to toolset tool results.""" - return [_map_fastmcp_tool_result(part) for part in parts] + mapped_results = [_map_fastmcp_tool_result(part) for part in parts] + + if len(mapped_results) == 1: + return mapped_results[0] + + return mapped_results def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index 4324dfc6fa..b68da67b71 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -3,16 +3,16 @@ from __future__ import annotations import base64 -from mcp.types import BlobResourceContents from typing import Any import pytest -from pydantic import AnyUrl from fastmcp.exceptions import ToolError from inline_snapshot import snapshot +from mcp.types import TextContent from pydantic_ai._run_context import RunContext from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.messages import BinaryContent from pydantic_ai.models.test import TestModel from pydantic_ai.usage import RunUsage @@ -24,9 +24,8 @@ from fastmcp.mcp_config import MCPConfig from fastmcp.server.server import FastMCP from mcp.types import ( - ImageContent, AudioContent, - BlobResourceContents, + ImageContent, ) # Import the content mapping functions for testing @@ -77,12 +76,20 @@ async def audio_tool() -> AudioContent: fake_audio_data = b'fake_audio_data' encoded_data = base64.b64encode(fake_audio_data).decode('utf-8') return AudioContent(type='audio', data=encoded_data, mimeType='audio/mpeg') - + @server.tool() async def text_tool(message: str) -> str: """A tool that returns text content.""" return f'Echo: {message}' + @server.tool() + async def text_list_tool(message: str) -> list[TextContent]: + """A tool that returns text content without a return annotation.""" + return [ + TextContent(type='text', text=f'Echo: {message}'), + TextContent(type='text', text=f'Echo: {message} again'), + ] + @server.tool() async def text_tool_wo_return_annotation(message: str): """A tool that returns text content.""" @@ -204,7 +211,17 @@ async def test_get_tools( tools = await toolset.get_tools(run_context) # Should have all the tools we defined in the server - expected_tools = {'test_tool', 'another_tool', 'audio_tool', 'error_tool', 'binary_tool', 'text_tool', 'text_tool_wo_return_annotation', 'json_tool'} + expected_tools = { + 'test_tool', + 'another_tool', + 'audio_tool', + 'error_tool', + 'binary_tool', + 'text_tool', + 'text_list_tool', + 'text_tool_wo_return_annotation', + 'json_tool', + } assert set(tools.keys()) == expected_tools # Check tool definitions @@ -286,16 +303,8 @@ async def test_call_tool_with_binary_content( name='binary_tool', tool_args={}, ctx=run_context, tool=binary_tool ) - assert isinstance(result, dict) - assert result == snapshot( - { - 'type': 'image', - 'data': 'ZmFrZV9pbWFnZV9kYXRh', - 'mimeType': 'image/png', - 'annotations': None, - '_meta': None, - } + BinaryContent(data=b'fake_image_data', media_type='image/png', identifier='427d68') ) async def test_call_tool_with_audio_content( @@ -308,20 +317,10 @@ async def test_call_tool_with_audio_content( tools = await fastmcp_toolset.get_tools(run_context) audio_tool = tools['audio_tool'] - result = await fastmcp_toolset.call_tool( - name='audio_tool', tool_args={}, ctx=run_context, tool=audio_tool - ) - - assert isinstance(result, dict) + result = await fastmcp_toolset.call_tool(name='audio_tool', tool_args={}, ctx=run_context, tool=audio_tool) assert result == snapshot( - { - 'type': 'audio', - 'data': 'ZmFrZV9hdWRpb19kYXRh', - 'mimeType': 'audio/mpeg', - 'annotations': None, - '_meta': None, - } + BinaryContent(data=b'fake_audio_data', media_type='audio/mpeg', identifier='f1220f') ) async def test_call_tool_with_text_content( @@ -340,6 +339,14 @@ async def test_call_tool_with_text_content( assert result == snapshot({'result': 'Echo: Hello World'}) + text_list_tool = tools['text_list_tool'] + + result = await fastmcp_toolset.call_tool( + name='text_list_tool', tool_args={'message': 'Hello World'}, ctx=run_context, tool=text_list_tool + ) + + assert result == snapshot(['Echo: Hello World', 'Echo: Hello World again']) + async def test_call_tool_with_unknown_text_content( self, fastmcp_toolset: FastMCPToolset[None], From edd89f2f75dcbccf2dd78ba341b21f6266df7397 Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 3 Sep 2025 19:27:26 -0500 Subject: [PATCH 4/9] Fix import --- pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index e17ab98beb..c97c2f30e1 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -6,7 +6,6 @@ from enum import Enum from typing import TYPE_CHECKING, Any, Self -from fastmcp.client.transports import MCPConfigTransport from mcp.types import ( AudioContent, ContentBlock, From 9c4fe383534cb28a6153b77890f9d90db184d9ce Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 4 Sep 2025 13:08:09 -0500 Subject: [PATCH 5/9] Fix module import error --- tests/test_fastmcp.py | 2 +- uv.lock | 348 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 345 insertions(+), 5 deletions(-) diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index b68da67b71..fdae2a1dfc 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -6,7 +6,6 @@ from typing import Any import pytest -from fastmcp.exceptions import ToolError from inline_snapshot import snapshot from mcp.types import TextContent @@ -21,6 +20,7 @@ with try_import() as imports_successful: from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport, MCPConfigTransport + from fastmcp.exceptions import ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.server.server import FastMCP from mcp.types import ( diff --git a/uv.lock b/uv.lock index c2ef3431c9..556234c4d4 100644 --- a/uv.lock +++ b/uv.lock @@ -351,6 +351,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918, upload-time = "2024-08-04T21:14:42.803Z" }, ] +[[package]] +name = "authlib" +version = "1.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/c6/d9a9db2e71957827e23a34322bde8091b51cb778dcc38885b84c772a1ba9/authlib-1.6.3.tar.gz", hash = "sha256:9f7a982cc395de719e4c2215c5707e7ea690ecf84f1ab126f28c053f4219e610", size = 160836, upload-time = "2025-08-26T12:13:25.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/2f/efa9d26dbb612b774990741fd8f13c7cf4cfd085b870e4a5af5c82eaf5f1/authlib-1.6.3-py2.py3-none-any.whl", hash = "sha256:7ea0f082edd95a03b7b72edac65ec7f8f68d703017d7e37573aee4fc603f2a48", size = 240105, upload-time = "2025-08-26T12:13:23.889Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -780,6 +792,53 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "45.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/13/3e/e42f1528ca1ea82256b835191eab1be014e0f9f934b60d98b0be8a38ed70/cryptography-45.0.7-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:de58755d723e86175756f463f2f0bddd45cc36fbd62601228a3f8761c9f58252", size = 3572442, upload-time = "2025-09-01T11:14:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/59/aa/e947693ab08674a2663ed2534cd8d345cf17bf6a1facf99273e8ec8986dc/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a20e442e917889d1a6b3c570c9e3fa2fdc398c20868abcea268ea33c024c4083", size = 4142233, upload-time = "2025-09-01T11:14:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/09b6f6a2fc43474a32b8fe259038eef1500ee3d3c141599b57ac6c57612c/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:258e0dff86d1d891169b5af222d362468a9570e2532923088658aa866eb11130", size = 4376202, upload-time = "2025-09-01T11:14:43.047Z" }, + { url = "https://files.pythonhosted.org/packages/00/f2/c166af87e95ce6ae6d38471a7e039d3a0549c2d55d74e059680162052824/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d97cf502abe2ab9eff8bd5e4aca274da8d06dd3ef08b759a8d6143f4ad65d4b4", size = 4141900, upload-time = "2025-09-01T11:14:45.089Z" }, + { url = "https://files.pythonhosted.org/packages/16/b9/e96e0b6cb86eae27ea51fa8a3151535a18e66fe7c451fa90f7f89c85f541/cryptography-45.0.7-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:c987dad82e8c65ebc985f5dae5e74a3beda9d0a2a4daf8a1115f3772b59e5141", size = 4375562, upload-time = "2025-09-01T11:14:47.166Z" }, + { url = "https://files.pythonhosted.org/packages/36/d0/36e8ee39274e9d77baf7d0dafda680cba6e52f3936b846f0d56d64fec915/cryptography-45.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c13b1e3afd29a5b3b2656257f14669ca8fa8d7956d509926f0b130b600b50ab7", size = 3322781, upload-time = "2025-09-01T11:14:48.747Z" }, + { url = "https://files.pythonhosted.org/packages/99/4e/49199a4c82946938a3e05d2e8ad9482484ba48bbc1e809e3d506c686d051/cryptography-45.0.7-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a862753b36620af6fc54209264f92c716367f2f0ff4624952276a6bbd18cbde", size = 3584634, upload-time = "2025-09-01T11:14:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/16/ce/5f6ff59ea9c7779dba51b84871c19962529bdcc12e1a6ea172664916c550/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:06ce84dc14df0bf6ea84666f958e6080cdb6fe1231be2a51f3fc1267d9f3fb34", size = 4149533, upload-time = "2025-09-01T11:14:52.091Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/b3cfbd257ac96da4b88b46372e662009b7a16833bfc5da33bb97dd5631ae/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d0c5c6bac22b177bf8da7435d9d27a6834ee130309749d162b26c3105c0795a9", size = 4385557, upload-time = "2025-09-01T11:14:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c5/8c59d6b7c7b439ba4fc8d0cab868027fd095f215031bc123c3a070962912/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:2f641b64acc00811da98df63df7d59fd4706c0df449da71cb7ac39a0732b40ae", size = 4149023, upload-time = "2025-09-01T11:14:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/05385c86d6ca9ab0b4d5bb442d2e3d85e727939a11f3e163fc776ce5eb40/cryptography-45.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:f5414a788ecc6ee6bc58560e85ca624258a55ca434884445440a810796ea0e0b", size = 4385722, upload-time = "2025-09-01T11:14:57.319Z" }, + { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, +] + [[package]] name = "cssselect2" version = "0.7.0" @@ -793,6 +852,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/3a/e39436efe51894243ff145a37c4f9a030839b97779ebcc4f13b3ba21c54e/cssselect2-0.7.0-py3-none-any.whl", hash = "sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969", size = 15586, upload-time = "2022-09-19T12:55:07.56Z" }, ] +[[package]] +name = "cyclopts" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser", marker = "python_full_version < '4'" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/7a/28b63c43d4c17d6587abcfef648841d39543158bcc47b5d40a03b8831f7a/cyclopts-3.23.1.tar.gz", hash = "sha256:ca6a5e9b326caf156d79f3932e2f88b95629e59fd371c0b3a89732b7619edacb", size = 75161, upload-time = "2025-08-30T17:40:34.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/67/ac57fbef5414ce84fe0bdeb497918ab2c781ff2cbf23c1bd91334b225669/cyclopts-3.23.1-py3-none-any.whl", hash = "sha256:8e57c6ea47d72b4b565c6a6c8a9fd56ed048ab4316627991230f4ad24ce2bc29", size = 85222, upload-time = "2025-08-30T17:40:33.005Z" }, +] + [[package]] name = "datasets" version = "4.0.0" @@ -908,6 +983,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/86/5b41c32ecedcfdb4c77b28b6cb14234f252075f8cdb254531727a35547dd/docutils-0.22.tar.gz", hash = "sha256:ba9d57750e92331ebe7c08a1bbf7a7f8143b86c476acd51528b042216a6aad0f", size = 2277984, upload-time = "2025-07-29T15:20:31.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/57/8db39bc5f98f042e0153b1de9fb88e1a409a33cda4dd7f723c2ed71e01f6/docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e", size = 630709, upload-time = "2025-07-29T15:20:28.335Z" }, +] + [[package]] name = "duckdb" version = "1.3.2" @@ -958,6 +1060,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/21/fc2c821a2c92c021f8f8adf9fb36235d1b49525b7cd953e85624296aab94/duckduckgo_search-7.5.0-py3-none-any.whl", hash = "sha256:6a2d3f12ae29b3e076cd43be61f5f73cd95261e0a0f318fe0ad3648d7a5dff03", size = 20238, upload-time = "2025-02-24T14:50:48.179Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "eval-type-backport" version = "0.2.2" @@ -1054,6 +1169,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/2c/43927e22a2d57587b3aa09765098a6d833246b672d34c10c5f135414745a/fastavro-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:86baf8c9740ab570d0d4d18517da71626fe9be4d1142bea684db52bd5adb078f", size = 483967, upload-time = "2024-12-20T12:57:37.618Z" }, ] +[[package]] +name = "fastmcp" +version = "2.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "openapi-core" }, + { name = "openapi-pydantic" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/8a/c46759bb41a53187191e5b3d963c0bde54783ecc89186a93c4947607b8e4/fastmcp-2.12.2.tar.gz", hash = "sha256:6d13e2f9be57b99763fc22485f9f603daa23bfbca35a8172baa43b283d6fc1ff", size = 5244547, upload-time = "2025-09-03T21:28:09.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/7a8d564b1b9909dbfc36eb93d76410a4acfada6b1e13ee451a753bb6dbc2/fastmcp-2.12.2-py3-none-any.whl", hash = "sha256:0b58d68e819c82078d1fd51989d3d81f2be7382d527308b06df55f4d0a4ec94f", size = 312029, upload-time = "2025-09-03T21:28:08.62Z" }, +] + [[package]] name = "ffmpy" version = "0.5.0" @@ -1494,6 +1631,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/ab/4ad6bb9808f242e659ca8437ee475efaa201f18ff20a0dd5553280c85ae5/inline_snapshot-0.23.0-py3-none-any.whl", hash = "sha256:b1a5feab675aee8d03a51f1b6291f412100ce750d846c2d58eab16c90ee2c4dd", size = 50119, upload-time = "2025-04-25T18:14:34.46Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "jinja2" version = "3.1.5" @@ -1589,6 +1735,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/54/c86cd8e011fe98803d7e382fd67c0df5ceab8d2b7ad8c5a81524f791551c/jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716", size = 89184, upload-time = "2025-07-18T15:39:42.956Z" }, ] +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.4.1" @@ -1601,6 +1762,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, ] +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/2b/d5e8915038acbd6c6a9fcb8aaf923dc184222405d3710285a1fec6e262bc/lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519", size = 26658, upload-time = "2025-08-22T13:42:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/91fc00eeea46ee88b9df67f7c5388e60993341d2a406243d620b2fdfde57/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6", size = 68412, upload-time = "2025-08-22T13:42:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/07/d2/b7189a0e095caedfea4d42e6b6949d2685c354263bdf18e19b21ca9b3cd6/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b", size = 67559, upload-time = "2025-08-22T13:42:25.875Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b013840cc43971582ff1ceaf784d35d3a579650eb6cc348e5e6ed7e34d28/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8", size = 66651, upload-time = "2025-08-22T13:42:27.427Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6f/b7368d301c15612fcc4cd00412b5d6ba55548bde09bdae71930e1a81f2ab/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8", size = 66901, upload-time = "2025-08-22T13:42:28.585Z" }, + { url = "https://files.pythonhosted.org/packages/61/1b/c6b1865445576b2fc5fa0fbcfce1c05fee77d8979fd1aa653dd0f179aefc/lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab", size = 26536, upload-time = "2025-08-22T13:42:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, +] + [[package]] name = "logfire" version = "4.0.0" @@ -2065,6 +2271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c3/4d9fbb14285698b7ae9f64423048ca9d28f5eb08b99f768b978f9118f780/modal-1.0.4-py3-none-any.whl", hash = "sha256:6c0d96bb49b09fa47e407a13e49545e32fe0803803b4330fbeb38de5e71209cc", size = 579637, upload-time = "2025-06-13T14:46:58.712Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "multidict" version = "6.1.0" @@ -2316,6 +2531,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/fb/df274ca10698ee77b07bff952f302ea627cc12dac6b85289485dd77db6de/openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a", size = 786816, upload-time = "2025-08-12T02:31:08.34Z" }, ] +[[package]] +name = "openapi-core" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "more-itertools" }, + { name = "openapi-schema-validator" }, + { name = "openapi-spec-validator" }, + { name = "parse" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/35/1acaa5f2fcc6e54eded34a2ec74b479439c4e469fc4e8d0e803fda0234db/openapi_core-0.19.5.tar.gz", hash = "sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3", size = 103264, upload-time = "2025-03-20T20:17:28.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6f/83ead0e2e30a90445ee4fc0135f43741aebc30cca5b43f20968b603e30b6/openapi_core-0.19.5-py3-none-any.whl", hash = "sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f", size = 106595, upload-time = "2025-03-20T20:17:26.77Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.30.0" @@ -2639,6 +2915,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, ] +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -2953,6 +3247,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-ai" source = { editable = "." } @@ -3146,6 +3445,9 @@ duckduckgo = [ evals = [ { name = "pydantic-evals" }, ] +fastmcp = [ + { name = "fastmcp" }, +] google = [ { name = "google-genai" }, ] @@ -3192,6 +3494,7 @@ requires-dist = [ { name = "eval-type-backport", specifier = ">=0.2.0" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "fasta2a", marker = "extra == 'a2a'", specifier = ">=0.4.1" }, + { name = "fastmcp", marker = "extra == 'fastmcp'", specifier = ">=2.12.0" }, { name = "genai-prices", specifier = ">=0.0.22" }, { name = "google-auth", marker = "extra == 'vertexai'", specifier = ">=2.36.0" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.31.0" }, @@ -3217,7 +3520,7 @@ requires-dist = [ { name = "tenacity", marker = "extra == 'retries'", specifier = ">=8.2.3" }, { name = "typing-inspection", specifier = ">=0.4.0" }, ] -provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "duckduckgo", "evals", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "tavily", "temporal", "vertexai"] +provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "tavily", "temporal", "vertexai"] [[package]] name = "pydantic-core" @@ -3515,11 +3818,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.1.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, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] [[package]] @@ -3716,6 +4019,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + [[package]] name = "rich" version = "13.9.4" @@ -3730,6 +4045,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/69/5514c3a87b5f10f09a34bb011bc0927bc12c596c8dae5915604e71abc386/rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383", size = 13839, upload-time = "2024-04-30T04:40:38.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/bc/cc4e3dbc5e7992398dcb7a8eda0cbcf4fb792a0cdb93f857b478bf3cf884/rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1", size = 11621, upload-time = "2024-04-30T04:40:32.619Z" }, +] + [[package]] name = "rpds-py" version = "0.26.0" @@ -4579,6 +4907,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/c8/d529f8a32ce40d98309f4470780631e971a5a842b60aec864833b3615786/websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b", size = 157416, upload-time = "2025-01-19T21:00:54.843Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/af/d4502dc713b4ccea7175d764718d5183caf8d0867a4f0190d5d4a45cea49/werkzeug-3.1.1.tar.gz", hash = "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4", size = 806453, upload-time = "2024-11-01T16:40:45.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" }, +] + [[package]] name = "wrapt" version = "1.17.2" From 27592c767255e35f1b9248a5d6a8d71143ac207f Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 4 Sep 2025 18:11:49 -0500 Subject: [PATCH 6/9] Trying to fix tests --- .../pydantic_ai/toolsets/fastmcp.py | 67 ++++++++----------- pyproject.toml | 1 + tests/test_fastmcp.py | 12 ++-- uv.lock | 2 + 4 files changed, 36 insertions(+), 46 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index c97c2f30e1..036dece623 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -4,15 +4,9 @@ from asyncio import Lock from contextlib import AsyncExitStack from enum import Enum -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING, Any -from mcp.types import ( - AudioContent, - ContentBlock, - ImageContent, - TextContent, - Tool as MCPTool, -) +from typing_extensions import Self from pydantic_ai.exceptions import ModelRetry from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR, messages @@ -26,6 +20,14 @@ from fastmcp.exceptions import ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.server.server import FastMCP + from mcp.types import ( + AudioContent, + ContentBlock, + ImageContent, + TextContent, + Tool as MCPTool, + ) + except ImportError as _import_error: raise ImportError( 'Please install the `fastmcp` package to use the FastMCP server, ' @@ -70,6 +72,13 @@ class FastMCPToolset(AbstractToolset[AgentDepsT]): def __init__( self, fastmcp_client: Client[Any], tool_retries: int = 2, tool_error_behavior: ToolErrorBehavior | None = None ): + """Build a new FastMCPToolset. + + Args: + fastmcp_client: The FastMCP client to use. + tool_retries: The number of times to retry a tool call. + tool_error_behavior: The behavior to take when a tool error occurs. + """ self._tool_retries = tool_retries self._fastmcp_client = fastmcp_client self._enter_lock = Lock() @@ -150,7 +159,7 @@ def from_fastmcp_server( async def my_tool(a: int, b: int) -> int: return a + b - toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server=fastmcp_server) + FastMCPToolset.from_fastmcp_server(fastmcp_server=fastmcp_server) ``` """ transport = FastMCPTransport(fastmcp_server) @@ -168,23 +177,15 @@ def from_mcp_server( Example: ```python - from pydantic_ai import Agent from pydantic_ai.toolsets.fastmcp import FastMCPToolset time_mcp_server = { - 'command': 'uvx', - 'args': [ - 'mcp-server-time', - ] + 'command': 'uv', + 'args': ['run', 'mcp-run-python', 'stdio'], } - toolset = FastMCPToolset.from_mcp_server(name='time_server', mcp_server=time_mcp_server) - agent = Agent('openai:gpt-4o', toolsets=[toolset]) - async def main(): - async with agent: # (1)! - ... + FastMCPToolset.from_mcp_server(name='time_server', mcp_server=time_mcp_server) ``` - 1. This will start the MCP Server running over stdio. """ mcp_config: MCPConfig = MCPConfig.from_dict(config={name: mcp_server}) @@ -198,35 +199,23 @@ def from_mcp_config( Example: ```python - from pydantic_ai import Agent from pydantic_ai.toolsets.fastmcp import FastMCPToolset mcp_config = { 'mcpServers': { - 'time_server': { - 'command': 'uvx', - 'args': [ - 'mcp-server-time', - ] + 'first_server': { + 'command': 'uv', + 'args': ['run', 'mcp-run-python', 'stdio'], }, - 'fetch_server': { - 'command': 'uvx', - 'args': [ - 'mcp-server-fetch', - ] + 'second_server': { + 'command': 'uv', + 'args': ['run', 'mcp-run-python', 'stdio'], } } } - fastmcp_toolset = FastMCPToolset.from_mcp_config(mcp_config) - - agent = Agent('openai:gpt-4o', toolsets=[fastmcp_toolset]) - async def main(): - async with agent: # (1)! - ... + FastMCPToolset.from_mcp_config(mcp_config) ``` - - 1. This will start both MCP Servers running over stdio`. """ transport: MCPConfigTransport = MCPConfigTransport(config=mcp_config) fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=transport) diff --git a/pyproject.toml b/pyproject.toml index 4237a55b64..3120d0d4a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ dev = [ "coverage[toml]>=7.10.3", "dirty-equals>=0.9.0", "duckduckgo-search>=7.0.0", + "fastmcp>=2.12.0", "inline-snapshot>=0.19.3", "pytest>=8.3.3", "pytest-examples>=0.0.18", diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index fdae2a1dfc..f3032b5233 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -7,7 +7,6 @@ import pytest from inline_snapshot import snapshot -from mcp.types import TextContent from pydantic_ai._run_context import RunContext from pydantic_ai.exceptions import ModelRetry @@ -26,6 +25,7 @@ from mcp.types import ( AudioContent, ImageContent, + TextContent, ) # Import the content mapping functions for testing @@ -57,11 +57,9 @@ async def another_tool(value: float) -> dict[str, Any]: return {'result': 'success', 'value': value, 'doubled': value * 2} @server.tool() - async def error_tool(should_fail: bool = False) -> str: + async def error_tool() -> str: """A tool that can fail for testing error handling.""" - if should_fail: - raise ValueError('This is a test error') - return 'success' + raise ValueError('This is a test error') @server.tool() async def binary_tool() -> ImageContent: @@ -395,7 +393,7 @@ async def test_call_tool_with_error_behavior_raise( with pytest.raises(ToolError, match='This is a test error'): await fastmcp_toolset.call_tool( - name='error_tool', tool_args={'should_fail': True}, ctx=run_context, tool=error_tool + name='error_tool', tool_args={}, ctx=run_context, tool=error_tool ) async def test_call_tool_with_error_behavior_model_retry( @@ -411,7 +409,7 @@ async def test_call_tool_with_error_behavior_model_retry( error_tool = tools['error_tool'] with pytest.raises(ModelRetry, match='This is a test error'): - await toolset.call_tool('error_tool', {'should_fail': True}, run_context, error_tool) + await toolset.call_tool('error_tool', {}, run_context, error_tool) class TestFastMCPToolsetFactoryMethods: diff --git a/uv.lock b/uv.lock index 556234c4d4..8109351330 100644 --- a/uv.lock +++ b/uv.lock @@ -3277,6 +3277,7 @@ dev = [ { name = "diff-cover" }, { name = "dirty-equals" }, { name = "duckduckgo-search" }, + { name = "fastmcp" }, { name = "genai-prices" }, { name = "inline-snapshot" }, { name = "mcp-run-python" }, @@ -3328,6 +3329,7 @@ dev = [ { name = "diff-cover", specifier = ">=9.2.0" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "duckduckgo-search", specifier = ">=7.0.0" }, + { name = "fastmcp", specifier = ">=2.12.0" }, { name = "genai-prices", specifier = ">=0.0.22" }, { name = "inline-snapshot", specifier = ">=0.19.3" }, { name = "mcp-run-python", specifier = ">=0.0.20" }, From 0362fd71d400563fb52ebcd47073d18a33711930 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sat, 6 Sep 2025 07:16:36 -0500 Subject: [PATCH 7/9] Lint --- tests/test_fastmcp.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index f3032b5233..5b74910adb 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -392,9 +392,7 @@ async def test_call_tool_with_error_behavior_raise( error_tool = tools['error_tool'] with pytest.raises(ToolError, match='This is a test error'): - await fastmcp_toolset.call_tool( - name='error_tool', tool_args={}, ctx=run_context, tool=error_tool - ) + await fastmcp_toolset.call_tool(name='error_tool', tool_args={}, ctx=run_context, tool=error_tool) async def test_call_tool_with_error_behavior_model_retry( self, From 4bd0334935cda801500cf4ba254b5788a652611a Mon Sep 17 00:00:00 2001 From: William Easton Date: Wed, 10 Sep 2025 17:12:35 -0500 Subject: [PATCH 8/9] Address most PR Feedback --- docs/install.md | 1 + docs/toolsets.md | 8 +- .../pydantic_ai/toolsets/fastmcp.py | 86 ++++++++++--------- pyproject.toml | 2 +- tests/test_fastmcp.py | 25 +++--- 5 files changed, 66 insertions(+), 56 deletions(-) diff --git a/docs/install.md b/docs/install.md index 1fd12a499d..2dfb39a633 100644 --- a/docs/install.md +++ b/docs/install.md @@ -55,6 +55,7 @@ pip/uv-add "pydantic-ai-slim[openai]" * `tavily` - installs `tavily-python` [PyPI ↗](https://pypi.org/project/tavily-python){:target="_blank"} * `cli` - installs `rich` [PyPI ↗](https://pypi.org/project/rich){:target="_blank"}, `prompt-toolkit` [PyPI ↗](https://pypi.org/project/prompt-toolkit){:target="_blank"}, and `argcomplete` [PyPI ↗](https://pypi.org/project/argcomplete){:target="_blank"} * `mcp` - installs `mcp` [PyPI ↗](https://pypi.org/project/mcp){:target="_blank"} +* `fastmcp` - installs `fastmcp` [PyPI ↗](https://pypi.org/project/fastmcp){:target="_blank"} * `a2a` - installs `fasta2a` [PyPI ↗](https://pypi.org/project/fasta2a){:target="_blank"} * `ag-ui` - installs `ag-ui-protocol` [PyPI ↗](https://pypi.org/project/ag-ui-protocol){:target="_blank"} and `starlette` [PyPI ↗](https://pypi.org/project/starlette){:target="_blank"} diff --git a/docs/toolsets.md b/docs/toolsets.md index 361e06db4f..c99becd5dd 100644 --- a/docs/toolsets.md +++ b/docs/toolsets.md @@ -667,7 +667,7 @@ See the [MCP Client](./mcp/client.md) documentation for how to use MCP servers w If you'd like to use tools from a [FastMCP](https://fastmcp.dev) Server, Client, or JSON MCP Configuration with Pydantic AI, you can use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] [toolset](toolsets.md). -You will need to install the `fastmcp` package and any others required by the tools in question. +To use the `FastMCPToolset`, you will need to install `pydantic-ai-slim[fastmcp]`. ```python {test="skip"} from fastmcp import FastMCP @@ -682,10 +682,10 @@ async def my_tool(a: int, b: int) -> int: toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server) -agent = Agent('openai:gpt-4o', toolsets=[toolset]) +agent = Agent('openai:gpt-5', toolsets=[toolset]) ``` -You can also use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] to create a toolset from a JSON MCP Configuration. +You can also use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] to create a toolset from a JSON MCP Configuration. FastMCP supports additional capabilities on top of the MCP specification, like Tool Transformation in the MCP configuration that you can take advantage of with the `FastMCPToolset`. ```python {test="skip"} from pydantic_ai import Agent @@ -702,7 +702,7 @@ mcp_config = { toolset = FastMCPToolset.from_mcp_config(mcp_config) -agent = Agent('openai:gpt-4o', toolsets=[toolset]) +agent = Agent('openai:gpt-5', toolsets=[toolset]) ``` diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index 036dece623..1f6f4bfaed 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -3,8 +3,7 @@ import base64 from asyncio import Lock from contextlib import AsyncExitStack -from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from typing_extensions import Self @@ -44,59 +43,59 @@ FastMCPToolResult = messages.BinaryContent | dict[str, Any] | str | None -FastMCPToolResults = list[FastMCPToolResult] | FastMCPToolResult - - -class ToolErrorBehavior(str, Enum): - """The behavior to take when a tool error occurs.""" - - MODEL_RETRY = 'model-retry' - """Raise a `ModelRetry` containing the tool error message.""" - - ERROR = 'raise' - """Raise the tool error as an exception.""" +ToolErrorBehavior = Literal['model_retry', 'error'] class FastMCPToolset(AbstractToolset[AgentDepsT]): """A toolset that uses a FastMCP client as the underlying toolset.""" - _fastmcp_client: Client[Any] - _tool_error_behavior: ToolErrorBehavior + tool_error_behavior: Literal['model_retry', 'error'] + fastmcp_client: Client[Any] - _tool_retries: int + max_retries: int + + _id: str | None _enter_lock: Lock _running_count: int _exit_stack: AsyncExitStack | None def __init__( - self, fastmcp_client: Client[Any], tool_retries: int = 2, tool_error_behavior: ToolErrorBehavior | None = None + self, + fastmcp_client: Client[Any], + *, + max_retries: int = 2, + tool_error_behavior: ToolErrorBehavior | None = None, + id: str | None = None, ): """Build a new FastMCPToolset. Args: fastmcp_client: The FastMCP client to use. - tool_retries: The number of times to retry a tool call. + max_retries: The maximum number of retries for each tool during a run. tool_error_behavior: The behavior to take when a tool error occurs. + id: An optional unique ID for the toolset. A toolset needs to have an ID in order to be used in a durable execution environment like Temporal, + in which case the ID will be used to identify the toolset's activities within the workflow. """ - self._tool_retries = tool_retries - self._fastmcp_client = fastmcp_client + self.max_retries = max_retries + self.fastmcp_client = fastmcp_client self._enter_lock = Lock() self._running_count = 0 + self._id = id - self._tool_error_behavior = tool_error_behavior or ToolErrorBehavior.ERROR + self.tool_error_behavior = tool_error_behavior or 'error' super().__init__() @property def id(self) -> str | None: - return None + return self._id async def __aenter__(self) -> Self: async with self._enter_lock: - if self._running_count == 0 and self._fastmcp_client: + if self._running_count == 0 and self.fastmcp_client: self._exit_stack = AsyncExitStack() - await self._exit_stack.enter_async_context(self._fastmcp_client) + await self._exit_stack.enter_async_context(self.fastmcp_client) self._running_count += 1 @@ -113,10 +112,10 @@ async def __aexit__(self, *args: Any) -> bool | None: async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: async with self: - mcp_tools: list[MCPTool] = await self._fastmcp_client.list_tools() + mcp_tools: list[MCPTool] = await self.fastmcp_client.list_tools() return { - tool.name: convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self._tool_retries) + tool.name: _convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self.max_retries) for tool in mcp_tools } @@ -125,10 +124,10 @@ async def call_tool( ) -> Any: async with self: try: - call_tool_result: CallToolResult = await self._fastmcp_client.call_tool(name=name, arguments=tool_args) + call_tool_result: CallToolResult = await self.fastmcp_client.call_tool(name=name, arguments=tool_args) except ToolError as e: - if self._tool_error_behavior == ToolErrorBehavior.MODEL_RETRY: - raise ModelRetry(message=str(object=e)) from e + if self.tool_error_behavior == 'model_retry': + raise ModelRetry(message=str(e)) from e else: raise e @@ -144,7 +143,11 @@ async def call_tool( @classmethod def from_fastmcp_server( - cls, fastmcp_server: FastMCP[Any], tool_error_behavior: ToolErrorBehavior | None = None + cls, + fastmcp_server: FastMCP[Any], + *, + tool_error_behavior: ToolErrorBehavior | None = None, + tool_retries: int = 2, ) -> Self: """Build a FastMCPToolset from a FastMCP server. @@ -164,14 +167,16 @@ async def my_tool(a: int, b: int) -> int: """ transport = FastMCPTransport(fastmcp_server) fastmcp_client: Client[FastMCPTransport] = Client[FastMCPTransport](transport=transport) - return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) + return cls(fastmcp_client=fastmcp_client, max_retries=tool_retries, tool_error_behavior=tool_error_behavior) @classmethod def from_mcp_server( cls, name: str, mcp_server: MCPServerTypes | dict[str, Any], + *, tool_error_behavior: ToolErrorBehavior | None = None, + tool_retries: int = 2, ) -> Self: """Build a FastMCPToolset from an individual MCP server configuration. @@ -189,11 +194,17 @@ def from_mcp_server( """ mcp_config: MCPConfig = MCPConfig.from_dict(config={name: mcp_server}) - return cls.from_mcp_config(mcp_config=mcp_config, tool_error_behavior=tool_error_behavior) + return cls.from_mcp_config( + mcp_config=mcp_config, tool_error_behavior=tool_error_behavior, max_retries=tool_retries + ) @classmethod def from_mcp_config( - cls, mcp_config: MCPConfig | dict[str, Any], tool_error_behavior: ToolErrorBehavior | None = None + cls, + mcp_config: MCPConfig | dict[str, Any], + *, + tool_error_behavior: ToolErrorBehavior | None = None, + max_retries: int = 2, ) -> Self: """Build a FastMCPToolset from an MCP json-derived / dictionary configuration object. @@ -219,10 +230,10 @@ def from_mcp_config( """ transport: MCPConfigTransport = MCPConfigTransport(config=mcp_config) fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=transport) - return cls(fastmcp_client=fastmcp_client, tool_retries=2, tool_error_behavior=tool_error_behavior) + return cls(fastmcp_client=fastmcp_client, max_retries=max_retries, tool_error_behavior=tool_error_behavior) -def convert_mcp_tool_to_toolset_tool( +def _convert_mcp_tool_to_toolset_tool( toolset: FastMCPToolset[AgentDepsT], mcp_tool: MCPTool, retries: int, @@ -254,10 +265,7 @@ def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: if isinstance(part, TextContent): return part.text - if isinstance(part, ImageContent): - return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) - - if isinstance(part, AudioContent): + if isinstance(part, ImageContent | AudioContent): return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) msg = f'Unsupported/Unknown content block type: {type(part)}' # pragma: no cover diff --git a/pyproject.toml b/pyproject.toml index 3120d0d4a7..b660551ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ requires-python = ">=3.10" [tool.hatch.metadata.hooks.uv-dynamic-versioning] dependencies = [ - "pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,evals,ag-ui,retries,temporal,logfire]=={{ version }}", + "pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,fastmcp,evals,ag-ui,retries,temporal,logfire]=={{ version }}", ] [tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies] diff --git a/tests/test_fastmcp.py b/tests/test_fastmcp.py index 5b74910adb..74541ba9ad 100644 --- a/tests/test_fastmcp.py +++ b/tests/test_fastmcp.py @@ -31,7 +31,6 @@ # Import the content mapping functions for testing from pydantic_ai.toolsets.fastmcp import ( FastMCPToolset, - ToolErrorBehavior, ) @@ -122,11 +121,6 @@ def run_context() -> RunContext[None]: ) -def get_client_from_toolset(toolset: FastMCPToolset[None]) -> Client[FastMCPTransport]: - """Get the client from the toolset.""" - return toolset._fastmcp_client # pyright: ignore[reportPrivateUsage] - - class TestFastMCPToolsetInitialization: """Test FastMCP Toolset initialization and basic functionality.""" @@ -137,12 +131,19 @@ async def test_init_with_client(self, fastmcp_client: Client[FastMCPTransport]): # Test that the client is accessible via the property assert toolset.id is None + async def test_init_with_id(self, fastmcp_client: Client[FastMCPTransport]): + """Test initialization with an id.""" + toolset = FastMCPToolset(fastmcp_client, id='test_id') + + # Test that the client is accessible via the property + assert toolset.id == 'test_id' + async def test_init_with_custom_retries_and_error_behavior(self, fastmcp_client: Client[FastMCPTransport]): """Test initialization with custom retries and error behavior.""" - toolset = FastMCPToolset(fastmcp_client, tool_retries=5, tool_error_behavior=ToolErrorBehavior.MODEL_RETRY) + toolset = FastMCPToolset(fastmcp_client, max_retries=5, tool_error_behavior='model_retry') # Test that the toolset was created successfully - assert get_client_from_toolset(toolset) is fastmcp_client + assert toolset.fastmcp_client is fastmcp_client async def test_id_property(self, fastmcp_client: Client[FastMCPTransport]): """Test that the id property returns None.""" @@ -400,7 +401,7 @@ async def test_call_tool_with_error_behavior_model_retry( run_context: RunContext[None], ): """Test tool call with error behavior set to model retry.""" - toolset = FastMCPToolset(fastmcp_client, tool_error_behavior=ToolErrorBehavior.MODEL_RETRY) + toolset = FastMCPToolset(fastmcp_client, tool_error_behavior='model_retry') async with toolset: tools = await toolset.get_tools(run_context) @@ -432,7 +433,7 @@ async def test_from_mcp_server(self, run_context: RunContext[None]): toolset = FastMCPToolset.from_mcp_server(name='test_server', mcp_server=mcp_server_config) assert isinstance(toolset, FastMCPToolset) - client = get_client_from_toolset(toolset) + client = toolset.fastmcp_client assert isinstance(client.transport, MCPConfigTransport) async def test_from_mcp_config_dict(self, run_context: RunContext[None]): @@ -441,7 +442,7 @@ async def test_from_mcp_config_dict(self, run_context: RunContext[None]): config_dict = {'mcpServers': {'test_server': {'command': 'python', 'args': ['-c', 'print("test")']}}} toolset = FastMCPToolset.from_mcp_config(mcp_config=config_dict) - client = get_client_from_toolset(toolset) + client = toolset.fastmcp_client assert isinstance(client.transport, MCPConfigTransport) async def test_from_mcp_config_mcp_config(self, run_context: RunContext[None]): @@ -452,5 +453,5 @@ async def test_from_mcp_config_mcp_config(self, run_context: RunContext[None]): ) toolset = FastMCPToolset.from_mcp_config(mcp_config=config) - client = get_client_from_toolset(toolset) + client = toolset.fastmcp_client assert isinstance(client.transport, MCPConfigTransport) From f2be96d711bc030c28a0239465e815a78b9528c5 Mon Sep 17 00:00:00 2001 From: William Easton Date: Thu, 11 Sep 2025 08:27:58 -0500 Subject: [PATCH 9/9] Address PR Feedback --- .../pydantic_ai/toolsets/fastmcp.py | 4 +- uv.lock | 52 +++++++++---------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py index 1f6f4bfaed..4e904b277e 100644 --- a/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py +++ b/pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py @@ -7,8 +7,8 @@ from typing_extensions import Self +from pydantic_ai import messages from pydantic_ai.exceptions import ModelRetry -from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR, messages from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition from pydantic_ai.toolsets import AbstractToolset from pydantic_ai.toolsets.abstract import ToolsetTool @@ -27,6 +27,8 @@ Tool as MCPTool, ) + from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR + except ImportError as _import_error: raise ImportError( 'Please install the `fastmcp` package to use the FastMCP server, ' diff --git a/uv.lock b/uv.lock index f26aa30fb2..547fccaabf 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -1035,6 +1035,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -1053,20 +1067,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/57/8db39bc5f98f042e0153b1de9fb88e1a409a33cda4dd7f723c2ed71e01f6/docutils-0.22-py3-none-any.whl", hash = "sha256:4ed966a0e96a0477d852f7af31bdcb3adc049fbb35ccba358c2ea8a03287615e", size = 630709, upload-time = "2025-07-29T15:20:28.335Z" }, ] -[[package]] -name = "docker" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "requests" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, -] - [[package]] name = "duckdb" version = "1.3.2" @@ -1824,21 +1824,21 @@ wheels = [ ] [[package]] -name = "isodate" -version = "0.7.2" +name = "invoke" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/42/127e6d792884ab860defc3f4d80a8f9812e48ace584ffc5a346de58cdc6c/invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5", size = 299835, upload-time = "2023-07-12T18:05:17.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" }, ] [[package]] -name = "invoke" -version = "2.2.0" +name = "isodate" +version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/42/127e6d792884ab860defc3f4d80a8f9812e48ace584ffc5a346de58cdc6c/invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5", size = 299835, upload-time = "2023-07-12T18:05:17.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/66/7f8c48009c72d73bc6bbe6eb87ac838d6a526146f7dab14af671121eb379/invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820", size = 160274, upload-time = "2023-07-12T18:05:16.294Z" }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] [[package]] @@ -3549,7 +3549,7 @@ email = [ name = "pydantic-ai" source = { editable = "." } dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "vertexai"] }, + { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "vertexai"] }, ] [package.optional-dependencies] @@ -3611,7 +3611,7 @@ lint = [ requires-dist = [ { name = "fasta2a", marker = "extra == 'a2a'", specifier = ">=0.4.1" }, { name = "pydantic-ai-examples", marker = "extra == 'examples'", editable = "examples" }, - { name = "pydantic-ai-slim", extras = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "vertexai"], editable = "pydantic_ai_slim" }, + { name = "pydantic-ai-slim", extras = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "vertexai"], editable = "pydantic_ai_slim" }, { name = "pydantic-ai-slim", extras = ["dbos"], marker = "extra == 'dbos'", editable = "pydantic_ai_slim" }, ] provides-extras = ["a2a", "dbos", "examples"] @@ -3821,7 +3821,7 @@ requires-dist = [ { name = "tenacity", marker = "extra == 'retries'", specifier = ">=8.2.3" }, { name = "typing-inspection", specifier = ">=0.4.0" }, ] -provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "dbos", "duckduckgo", "evals", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "tavily", "temporal", "vertexai"] +provides-extras = ["a2a", "ag-ui", "anthropic", "bedrock", "cli", "cohere", "dbos", "duckduckgo", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "tavily", "temporal", "vertexai"] [[package]] name = "pydantic-core"