diff --git a/pyproject.toml b/pyproject.toml index 0a11a3b15..dfa8f61db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "sse-starlette>=1.6.1", "pydantic-settings>=2.5.2", "uvicorn>=0.23.1; sys_platform != 'emscripten'", + "jsonschema==4.23.0", ] [project.optional-dependencies] diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 3b7fc3fae..bf320e07d 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,8 +1,11 @@ +import logging +from collections.abc import Awaitable, Callable from datetime import timedelta -from typing import Any, Protocol +from typing import Any, Protocol, TypeAlias import anyio.lowlevel from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from jsonschema import ValidationError, validate from pydantic import AnyUrl, TypeAdapter import mcp.types as types @@ -11,6 +14,8 @@ from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +logger = logging.getLogger(__name__) + DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -44,6 +49,12 @@ async def __call__( ) -> None: ... +class ToolOutputValidationFnT(Protocol): + async def __call__( + self, request: types.CallToolRequest, result: types.CallToolResult + ) -> bool: ... + + async def _default_message_handler( message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification @@ -77,6 +88,25 @@ async def _default_logging_callback( pass +ToolOutputValidatorProvider: TypeAlias = Callable[ + ..., + Awaitable[ToolOutputValidationFnT], +] + + +# this bag of spanners is required in order to +# enable the client session to be parsed to the validator +async def _python_circularity_hell(arg: Any) -> ToolOutputValidationFnT: + # in any sane version of the universe this should never happen + # of course in any sane programming language class circularity + # dependencies shouldn't be this hard to manage + raise RuntimeError( + "Help I'm stuck in python circularity hell, please send biscuits" + ) + + +_default_tool_output_validator: ToolOutputValidatorProvider = _python_circularity_hell + ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter( types.ClientResult | types.ErrorData ) @@ -101,6 +131,7 @@ def __init__( logging_callback: LoggingFnT | None = None, message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, + tool_output_validator_provider: ToolOutputValidatorProvider | None = None, ) -> None: super().__init__( read_stream, @@ -114,6 +145,9 @@ def __init__( self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler + self._tool_output_validator_provider = ( + tool_output_validator_provider or _default_tool_output_validator + ) async def initialize(self) -> types.InitializeResult: sampling = ( @@ -160,6 +194,8 @@ async def initialize(self) -> types.InitializeResult: ) ) + self._tool_output_validator = await self._tool_output_validator_provider(self) + return result async def send_ping(self) -> types.EmptyResult: @@ -281,24 +317,33 @@ async def call_tool( arguments: dict[str, Any] | None = None, read_timeout_seconds: timedelta | None = None, progress_callback: ProgressFnT | None = None, + validate_result: bool = True, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" - return await self.send_request( - types.ClientRequest( - types.CallToolRequest( - method="tools/call", - params=types.CallToolRequestParams( - name=name, - arguments=arguments, - ), - ) + request = types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name=name, + arguments=arguments, ), + ) + + result = await self.send_request( + types.ClientRequest(request), types.CallToolResult, request_read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, ) + if validate_result: + valid = await self._tool_output_validator(request, result) + + if not valid: + raise RuntimeError("Server responded with invalid result: " f"{result}") + # not validating or is valid + return result + async def list_prompts(self, cursor: str | None = None) -> types.ListPromptsResult: """Send a prompts/list request.""" return await self.send_request( @@ -418,3 +463,75 @@ async def _received_notification( await self._logging_callback(params) case _: pass + + +class NoOpToolOutputValidator(ToolOutputValidationFnT): + async def __call__( + self, request: types.CallToolRequest, result: types.CallToolResult + ) -> bool: + return True + + +class SimpleCachingToolOutputValidator(ToolOutputValidationFnT): + _schema_cache: dict[str, dict[str, Any] | bool] + + def __init__(self, session: ClientSession): + self._session = session + self._schema_cache = {} + self._refresh_cache = True + + async def __call__( + self, request: types.CallToolRequest, result: types.CallToolResult + ) -> bool: + if result.isError: + # allow errors to be propagated + return True + else: + if self._refresh_cache: + await self._refresh_schema_cache() + + schema = self._schema_cache.get(request.params.name) + + if schema is None: + raise RuntimeError(f"Unknown tool {request.params.name}") + elif schema is False: + # no schema + logging.debug("No schema found checking structuredContent is empty") + return result.structuredContent is None + else: + try: + # TODO opportunity to build jsonschema.protocol.Validator + # and reuse rather than build every time + validate(result.structuredContent, schema) + return True + except ValidationError as e: + logging.exception(e) + return False + + async def _refresh_schema_cache(self): + cursor = None + first = True + self._schema_cache = {} + while first or cursor is not None: + first = False + tools_result = await self._session.list_tools(cursor) + for tool in tools_result.tools: + # store a flag to be able to later distinguish between + # no schema for tool and unknown tool which can't be verified + schema_or_flag = ( + False if tool.outputSchema is None else tool.outputSchema + ) + self._schema_cache[tool.name] = schema_or_flag + cursor = tools_result.nextCursor + continue + + self._refresh_cache = False + + +async def _escape_from_circular_python_hell( + session: ClientSession, +) -> ToolOutputValidationFnT: + return SimpleCachingToolOutputValidator(session) + + +_default_tool_output_validator = _escape_from_circular_python_hell diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index e5b6c3acc..682e22333 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -242,7 +242,7 @@ def run( def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" self._mcp_server.list_tools()(self.list_tools) - self._mcp_server.call_tool()(self.call_tool) + self._mcp_server.call_tool()(self.call_tool, self._tool_manager.get_schema) self._mcp_server.list_resources()(self.list_resources) self._mcp_server.read_resource()(self.read_resource) self._mcp_server.list_prompts()(self.list_prompts) @@ -257,6 +257,7 @@ async def list_tools(self) -> list[MCPTool]: name=info.name, description=info.description, inputSchema=info.parameters, + outputSchema=info.output, annotations=info.annotations, ) for info in tools @@ -279,7 +280,8 @@ async def call_tool( """Call a tool by name with arguments.""" context = self.get_context() result = await self._tool_manager.call_tool(name, arguments, context=context) - converted_result = _convert_to_content(result) + schema = self._tool_manager.get_schema(name) + converted_result = _convert_to_content(result, schema) return converted_result async def list_resources(self) -> list[MCPResource]: @@ -327,6 +329,7 @@ def add_tool( name: str | None = None, description: str | None = None, annotations: ToolAnnotations | None = None, + output_schema: dict[str, Any] | None = None, ) -> None: """Add a tool to the server. @@ -338,9 +341,15 @@ def add_tool( name: Optional name for the tool (defaults to function name) description: Optional description of what the tool does annotations: Optional ToolAnnotations providing additional tool information + output_schema: Optional json schema that the tool should output. If + not specified the schema will be inferred automatically """ self._tool_manager.add_tool( - fn, name=name, description=description, annotations=annotations + fn, + name=name, + description=description, + annotations=annotations, + output_schema=output_schema, ) def tool( @@ -874,25 +883,33 @@ async def get_prompt( def _convert_to_content( - result: Any, + result: Any, schema: dict[str, Any] | None ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: - """Convert a result to a sequence of content objects.""" - if result is None: - return [] - - if isinstance(result, TextContent | ImageContent | EmbeddedResource): - return [result] - - if isinstance(result, Image): - return [result.to_image_content()] - - if isinstance(result, list | tuple): - return list(chain.from_iterable(_convert_to_content(item) for item in result)) # type: ignore[reportUnknownVariableType] + if schema is None: + """Convert a result to a sequence of content objects.""" + if result is None: + return [] + + if isinstance(result, TextContent | ImageContent | EmbeddedResource): + return [result] + + if isinstance(result, Image): + return [result.to_image_content()] + + if isinstance(result, list | tuple): + return list( + chain.from_iterable( + _convert_to_content(item, schema) + for item in result # type: ignore[reportUnknownVariableType] + ) + ) - if not isinstance(result, str): - result = pydantic_core.to_json(result, fallback=str, indent=2).decode() + if not isinstance(result, str): + result = pydantic_core.to_json(result, fallback=str, indent=2).decode() - return [TextContent(type="text", text=result)] + return [TextContent(type="text", text=result)] + else: + return result class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]): diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index f32eb15bd..8726f9796 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -24,6 +24,10 @@ class Tool(BaseModel): name: str = Field(description="Name of the tool") description: str = Field(description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") + output: dict[str, Any] | None = Field( + description="JSON schema for tool output", + default=None, + ) fn_metadata: FuncMetadata = Field( description="Metadata about the function including a pydantic model for tool" " arguments" @@ -44,6 +48,7 @@ def from_function( description: str | None = None, context_kwarg: str | None = None, annotations: ToolAnnotations | None = None, + output_schema: dict[str, Any] | None = None, ) -> Tool: """Create a Tool from a function.""" from mcp.server.fastmcp.server import Context @@ -68,14 +73,17 @@ def from_function( func_arg_metadata = func_metadata( fn, skip_names=[context_kwarg] if context_kwarg is not None else [], + output_schema=output_schema, ) parameters = func_arg_metadata.arg_model.model_json_schema() + output = func_arg_metadata.output_schema return cls( fn=fn, name=func_name, description=func_doc, parameters=parameters, + output=output, fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=context_kwarg, diff --git a/src/mcp/server/fastmcp/tools/tool_manager.py b/src/mcp/server/fastmcp/tools/tool_manager.py index 153249379..ca82d4b37 100644 --- a/src/mcp/server/fastmcp/tools/tool_manager.py +++ b/src/mcp/server/fastmcp/tools/tool_manager.py @@ -48,10 +48,15 @@ def add_tool( name: str | None = None, description: str | None = None, annotations: ToolAnnotations | None = None, + output_schema: dict[str, Any] | None = None, ) -> Tool: """Add a tool to the server.""" tool = Tool.from_function( - fn, name=name, description=description, annotations=annotations + fn, + name=name, + description=description, + annotations=annotations, + output_schema=output_schema, ) existing = self._tools.get(tool.name) if existing: @@ -73,3 +78,9 @@ async def call_tool( raise ToolError(f"Unknown tool: {name}") return await tool.run(arguments, context=context) + + def get_schema(self, name: str) -> dict[str, Any] | None: + tool = self.get_tool(name) + if not tool: + raise ToolError(f"Unknown tool: {name}") + return tool.output diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 374391325..a0dcecd70 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -7,12 +7,20 @@ ForwardRef, ) -from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model +from pydantic import ( + BaseModel, + ConfigDict, + Field, + TypeAdapter, + WithJsonSchema, + create_model, +) from pydantic._internal._typing_extra import eval_type_backport from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined from mcp.server.fastmcp.exceptions import InvalidSignature +from mcp.server.fastmcp.utilities import types from mcp.server.fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -38,6 +46,7 @@ def model_dump_one_level(self) -> dict[str, Any]: class FuncMetadata(BaseModel): arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] + output_schema: dict[str, Any] | None # We can add things in the future like # - Maybe some args are excluded from attempting to parse from JSON # - Maybe some args are special (like context) for dependency injection @@ -103,7 +112,9 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: def func_metadata( - func: Callable[..., Any], skip_names: Sequence[str] = () + func: Callable[..., Any], + skip_names: Sequence[str] = (), + output_schema: dict[str, Any] | None = None, ) -> FuncMetadata: """Given a function, return metadata including a pydantic model representing its signature. @@ -172,8 +183,18 @@ def func_metadata( **dynamic_pydantic_model_params, __base__=ArgModelBase, ) - resp = FuncMetadata(arg_model=arguments_model) - return resp + + return_output_schema = output_schema + + if return_output_schema is None: + # TODO this could be moved to a constant or passed in as param as per skip_names + ignore = [inspect.Parameter.empty, None, types.Image] + if sig.return_annotation not in ignore: + type_schema = TypeAdapter(sig.return_annotation).json_schema() + if type_schema.get("type", None) == "object": + return_output_schema = type_schema + + return FuncMetadata(arg_model=arguments_model, output_schema=return_output_schema) def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: @@ -210,5 +231,6 @@ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: ) for param in signature.parameters.values() ] - typed_signature = inspect.Signature(typed_params) + typed_return = _get_typed_annotation(signature.return_annotation, globalns) + typed_signature = inspect.Signature(typed_params, return_annotation=typed_return) return typed_signature diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index f6d390c2f..3ce96695b 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -72,11 +72,11 @@ async def main(): import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, Generic +from typing import Any, Generic, cast import anyio from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl +from pydantic import AnyUrl, BaseModel from typing_extensions import TypeVar import mcp.types as types @@ -407,25 +407,116 @@ def decorator( Iterable[ types.TextContent | types.ImageContent | types.EmbeddedResource ] + | BaseModel + | dict[str, Any] ], ], + schema_func: Callable[..., dict[str, Any] | None] | None = None, ): logger.debug("Registering handler for CallToolRequest") - async def handler(req: types.CallToolRequest): - try: - results = await func(req.params.name, (req.params.arguments or {})) + def handle_result_without_schema(req: types.CallToolRequest, result: Any): + if type(result) is dict or isinstance(result, BaseModel): + error = f"""Tool {req.params.name} has no outputSchema and +must return content""" return types.ServerResult( - types.CallToolResult(content=list(results), isError=False) + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=error, + ) + ], + structuredContent=None, + isError=True, + ) + ) + else: + content_result = cast( + Iterable[ + types.TextContent + | types.ImageContent + | types.EmbeddedResource + ], + result, + ) + return types.ServerResult( + types.CallToolResult( + content=list(content_result), + structuredContent=None, + isError=False, + ) + ) + + def handle_result_with_schema( + req: types.CallToolRequest, result: Any, schema: dict[str, Any] + ): + if isinstance(result, BaseModel): + model_result = result.model_dump() + return types.ServerResult( + types.CallToolResult( + content=[], + structuredContent=model_result, + isError=False, + ) ) - except Exception as e: + elif type(result) is dict[str, Any]: return types.ServerResult( types.CallToolResult( - content=[types.TextContent(type="text", text=str(e))], + content=[], structuredContent=result, isError=False + ) + ) + else: + error = f"""Tool {req.params.name} has outputSchema and " +must return structured content""" + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=error, + ) + ], + structuredContent=None, isError=True, ) ) + def handle_error(e: Exception): + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=str(e))], + structuredContent=None, + isError=True, + ) + ) + + if schema_func is None: + + async def handler(req: types.CallToolRequest): + try: + result = await func( + req.params.name, (req.params.arguments or {}) + ) + return handle_result_without_schema(req, result) + except Exception as e: + return handle_error(e) + else: + + async def handler(req: types.CallToolRequest): + try: + result = await func( + req.params.name, (req.params.arguments or {}) + ) + schema = schema_func(req.params.name) + + if schema: + return handle_result_with_schema(req, result, schema) + else: + return handle_result_without_schema(req, result) + except Exception as e: + return handle_error(e) + self.request_handlers[types.CallToolRequest] = handler return func diff --git a/src/mcp/types.py b/src/mcp/types.py index 4f5af27b9..04de657a4 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -772,6 +772,8 @@ class Tool(BaseModel): """A human-readable description of the tool.""" inputSchema: dict[str, Any] """A JSON Schema object defining the expected parameters for the tool.""" + outputSchema: dict[str, Any] | None = None + """A JSON Schema object defining the expected outputs for the tool.""" annotations: ToolAnnotations | None = None """Optional additional tool information.""" model_config = ConfigDict(extra="allow") @@ -802,6 +804,7 @@ class CallToolResult(Result): """The server's response to a tool call.""" content: list[TextContent | ImageContent | EmbeddedResource] + structuredContent: dict[str, Any] | None = None isError: bool = False diff --git a/tests/issues/test_88_random_error.py b/tests/issues/test_88_random_error.py index 88e41d66d..42e3ed9ab 100644 --- a/tests/issues/test_88_random_error.py +++ b/tests/issues/test_88_random_error.py @@ -15,6 +15,7 @@ EmbeddedResource, ImageContent, TextContent, + Tool, ) @@ -55,6 +56,10 @@ async def slow_tool( return [TextContent(type="text", text=f"fast {request_count}")] return [TextContent(type="text", text=f"unknown {request_count}")] + @server.list_tools() + async def list_tools() -> list[Tool]: + return [Tool(name="fast", inputSchema={}), Tool(name="slow", inputSchema={})] + async def server_handler( read_stream, write_stream, diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index b1828ffe9..9799310bd 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -7,11 +7,11 @@ from mcp.server.fastmcp.utilities.func_metadata import func_metadata -class SomeInputModelA(BaseModel): +class SomeModelA(BaseModel): pass -class SomeInputModelB(BaseModel): +class SomeModelB(BaseModel): class InnerModel(BaseModel): x: int @@ -46,15 +46,15 @@ def complex_arguments_fn( int, Field(1) ], unannotated, - my_model_a: SomeInputModelA, - my_model_a_forward_ref: "SomeInputModelA", - my_model_b: SomeInputModelB, + my_model_a: SomeModelA, + my_model_a_forward_ref: "SomeModelA", + my_model_b: SomeModelB, an_int_annotated_with_field_default: Annotated[ int, Field(1, description="An int with a field"), ], unannotated_with_default=5, - my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008 + my_model_a_with_default: SomeModelA = SomeModelA(), # noqa: B008 an_int_with_default: int = 1, must_be_none_with_default: None = None, an_int_with_equals_field: int = Field(1, ge=0), @@ -85,6 +85,34 @@ def complex_arguments_fn( return "ok!" +def simple_no_annotation_fun(): + return "ok" + + +def simple_str_fun() -> str: + return "ok" + + +def simple_list_str_fun() -> list[str]: + return ["ok"] + + +def simple_bool_fun() -> bool: + return True + + +def simple_int_fun() -> int: + return 1 + + +def simple_float_fun() -> float: + return 1.0 + + +def complex_model_fun() -> SomeModelB: + return SomeModelB(how_many_shrimp=1, ok=SomeModelB.InnerModel(x=2), y=None) + + @pytest.mark.anyio async def test_complex_function_runtime_arg_validation_non_json(): """Test that basic non-JSON arguments are validated correctly""" @@ -269,7 +297,7 @@ def test_complex_function_json_schema(): # Normalize the my_model_a_with_default field to handle both pydantic formats if "allOf" in actual_schema["properties"]["my_model_a_with_default"]: normalized_schema["properties"]["my_model_a_with_default"] = { - "$ref": "#/$defs/SomeInputModelA", + "$ref": "#/$defs/SomeModelA", "default": {}, } @@ -281,12 +309,12 @@ def test_complex_function_json_schema(): "title": "InnerModel", "type": "object", }, - "SomeInputModelA": { + "SomeModelA": { "properties": {}, - "title": "SomeInputModelA", + "title": "SomeModelA", "type": "object", }, - "SomeInputModelB": { + "SomeModelB": { "properties": { "how_many_shrimp": { "description": "How many shrimp in the tank???", @@ -297,7 +325,7 @@ def test_complex_function_json_schema(): "y": {"title": "Y", "type": "null"}, }, "required": ["how_many_shrimp", "ok", "y"], - "title": "SomeInputModelB", + "title": "SomeModelB", "type": "object", }, }, @@ -341,9 +369,9 @@ def test_complex_function_json_schema(): "type": "integer", }, "unannotated": {"title": "unannotated", "type": "string"}, - "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, + "my_model_a": {"$ref": "#/$defs/SomeModelA"}, + "my_model_a_forward_ref": {"$ref": "#/$defs/SomeModelA"}, + "my_model_b": {"$ref": "#/$defs/SomeModelB"}, "an_int_annotated_with_field_default": { "default": 1, "description": "An int with a field", @@ -356,7 +384,7 @@ def test_complex_function_json_schema(): "type": "string", }, "my_model_a_with_default": { - "$ref": "#/$defs/SomeInputModelA", + "$ref": "#/$defs/SomeModelA", "default": {}, }, "an_int_with_default": { @@ -414,3 +442,46 @@ def func_with_str_and_int(a: str, b: int): result = meta.pre_parse_json({"a": "123", "b": 123}) assert result["a"] == "123" assert result["b"] == 123 + + +def test_simple_function_output_schema(): + """Test JSON schema generation for simple return types.""" + + assert func_metadata(simple_no_annotation_fun).output_schema is None + assert func_metadata(simple_str_fun).output_schema is None + assert func_metadata( + simple_str_fun, output_schema={"type": "string"} + ).output_schema == {"type": "string"} + assert func_metadata(simple_bool_fun).output_schema is None + assert func_metadata(simple_int_fun).output_schema is None + assert func_metadata(simple_float_fun).output_schema is None + assert func_metadata(simple_list_str_fun).output_schema is None + + +def test_complex_function_output_schema(): + """Test JSON schema generation for complex return types.""" + + assert func_metadata(complex_model_fun).output_schema == { + "type": "object", + "$defs": { + "InnerModel": { + "properties": {"x": {"title": "X", "type": "integer"}}, + "required": [ + "x", + ], + "title": "InnerModel", + "type": "object", + } + }, + "properties": { + "how_many_shrimp": { + "description": "How many shrimp in the tank???", + "title": "How Many Shrimp", + "type": "integer", + }, + "ok": {"$ref": "#/$defs/InnerModel"}, + "y": {"title": "Y", "type": "null"}, + }, + "required": ["how_many_shrimp", "ok", "y"], + "title": "SomeModelB", + } diff --git a/tests/server/fastmcp/test_tool_manager.py b/tests/server/fastmcp/test_tool_manager.py index b45c7ac38..7cf2aa1fc 100644 --- a/tests/server/fastmcp/test_tool_manager.py +++ b/tests/server/fastmcp/test_tool_manager.py @@ -40,7 +40,7 @@ class AddArguments(ArgModelBase): a: int b: int - fn_metadata = FuncMetadata(arg_model=AddArguments) + fn_metadata = FuncMetadata(arg_model=AddArguments, output_schema=None) original_tool = Tool( name="add", diff --git a/uv.lock b/uv.lock index 180d5a9c1..ae000534e 100644 --- a/uv.lock +++ b/uv.lock @@ -446,6 +446,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513, upload-time = "2025-04-23T12:34:07.418Z" } +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 = "markdown" version = "3.7" @@ -532,6 +559,7 @@ dependencies = [ { name = "anyio" }, { name = "httpx" }, { name = "httpx-sse" }, + { name = "jsonschema" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -576,6 +604,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27" }, { name = "httpx-sse", specifier = ">=0.4" }, + { name = "jsonschema", specifier = "==4.23.0" }, { name = "pydantic", specifier = ">=2.7.2,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, @@ -1404,6 +1433,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911, upload-time = "2020-11-12T02:38:24.638Z" }, ] +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, +] + [[package]] name = "regex" version = "2024.11.6" @@ -1502,6 +1545,102 @@ 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 = "rpds-py" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/d2/7bed8453e53f6c9dea7ff4c19ee980fd87be607b2caf023d62c6579e6c30/rpds_py-0.25.0.tar.gz", hash = "sha256:4d97661bf5848dd9e5eb7ded480deccf9d32ce2cd500b88a26acbf7bd2864985", size = 26822, upload-time = "2025-05-15T13:42:03.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/07/c4ec43c36b68dcf9006481f731df018e5b0ad0c35dff529eb92af4e2764a/rpds_py-0.25.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c146a24a8f0dc4a7846fb4640b88b3a68986585b8ce8397af15e66b7c5817439", size = 373212, upload-time = "2025-05-15T13:38:11.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/1ddadccc90840f7d7f7d71ef41e535ddd7facb15413963e0f3d6aa613fc9/rpds_py-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:77814c7a4e1dc43fba73aeb4c1ef0fe37d901f3aa869a4823de5ea843a283fd0", size = 358891, upload-time = "2025-05-15T13:38:13.822Z" }, + { url = "https://files.pythonhosted.org/packages/bb/36/ec715be797ab99b28a309fbeb39d493ecd2670c48312b23042737558a946/rpds_py-0.25.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5afbff2822016db3c696cb0c1432e6b1f0e34aa9280bc5184dc216812a24e70d", size = 388829, upload-time = "2025-05-15T13:38:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/8a8c8563876f47f1e0c4da7d3d603ae87ceb2be51e0b4b1c2758b729fb37/rpds_py-0.25.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffae52cd76837a5c16409359d236b1fced79e42e0792e8adf375095a5e855368", size = 392759, upload-time = "2025-05-15T13:38:17.321Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/303b5c18744406b9afa6a66740297d3e20a91ee0df46003da05bd14faa5d/rpds_py-0.25.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddf9426b740a7047b2b0dddcba775211542e8053ce1e509a1759b665fe573508", size = 449645, upload-time = "2025-05-15T13:38:19.277Z" }, + { url = "https://files.pythonhosted.org/packages/18/9b/bb308301eddd3ea81b68b77426691f7476671dca40a45a54a2b178294109/rpds_py-0.25.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cad834f1a8f51eb037c3c4dc72c884c9e1e0644d900e2d45aa76450e4aa6282", size = 444905, upload-time = "2025-05-15T13:38:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/bf/03/d8a23a4610dc1ce7853bdb5c099de8050dae93cc8e7550ad6854073fbcb7/rpds_py-0.25.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c46bd76986e05689376d28fdc2b97d899576ce3e3aaa5a5f80f67a8300b26eb3", size = 386801, upload-time = "2025-05-15T13:38:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/e7/85/3ea010f1fe8d64c44e3e5b6c60fa81db96752e7a0a8f86fe72cb02d72673/rpds_py-0.25.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3353a2d7eb7d5e0af8a7ca9fc85a34ba12619119bcdee6b8a28a6373cda65ce", size = 419799, upload-time = "2025-05-15T13:38:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/08/d4/ab18f94d77687facf39fabb58c81bb0c176d2e56d42b9198e954b9d1e5a0/rpds_py-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdc648d4e81eef5ac4bb35d731562dffc28358948410f3274d123320e125d613", size = 565732, upload-time = "2025-05-15T13:38:25.938Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2d/c21b92fc82d7197a9616528fc3dca3efb7b297d5154be754497cfbccb019/rpds_py-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:098d446d76d26e394b440d73921b49c1c90274d46ccbaadf346b1b78f9fdd4b1", size = 591454, upload-time = "2025-05-15T13:38:27.88Z" }, + { url = "https://files.pythonhosted.org/packages/82/f4/e75a6cd71cecb418edd39746627a06665c44c72de05d2c77480851cfa759/rpds_py-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c624c82e645f6b5465d08cdc802fb0cd53aa1478782fb2992b9e09f2c9426865", size = 557622, upload-time = "2025-05-15T13:38:29.418Z" }, + { url = "https://files.pythonhosted.org/packages/03/8a/ffb53d59ea1890471d2397efa2dd02df5292c40e123a97542d2bd2089a76/rpds_py-0.25.0-cp310-cp310-win32.whl", hash = "sha256:9d0041bd9e2d2ef803b32d84a0c8115d178132da5691346465953a2a966ba8ca", size = 219802, upload-time = "2025-05-15T13:38:31.33Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d8/853d97fd9b9c192e54dc73bc864e348e34b642a9161f55c0adf08f06ca21/rpds_py-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:d8b41195a6b03280ab00749a438fbce761e7acfd5381051a570239d752376f27", size = 231224, upload-time = "2025-05-15T13:38:33.148Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/505b4de3e7011fba218cfdf78bb80754194e9a5af469a96900923c535bf5/rpds_py-0.25.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6587ece9f205097c62d0e3d3cb7c06991eb0083ab6a9cf48951ec49c2ab7183c", size = 373387, upload-time = "2025-05-15T13:38:34.624Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/f2a9e4929cbe4162ccc126292f58558358607ded1f435148a83ea86f082c/rpds_py-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0a5651e350997cebcdc23016dca26c4d1993d29015a535284da3159796e30b6", size = 359136, upload-time = "2025-05-15T13:38:36.302Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/7fcd34dc325b453066b7445d79ec15da2273c1365a3b2222ad16abaf475c/rpds_py-0.25.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3752a015db89ea3e9c04d5e185549be4aa29c1882150e094c614c0de8e788feb", size = 388972, upload-time = "2025-05-15T13:38:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f3/76e0aefb6713951288b28070bd7cc9ccb2a2440d6bd425d4f23d28152260/rpds_py-0.25.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a05b199c11d2f39c72de8c30668734b5d20974ad44b65324ea3e647a211f135d", size = 393360, upload-time = "2025-05-15T13:38:39.949Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e1/9189e5f81a5209f61bbd35780f038c771a986da19995d8b89072d6f833e3/rpds_py-0.25.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f91902fc0c95dd1fa6b30ebd2af83ace91e592f7fd6340a375588a9d4b9341b", size = 449744, upload-time = "2025-05-15T13:38:41.442Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fe/7e9d920aeff117a5def4ef6f3cfbae84b504d9d6f3254104c7d8aeeea06a/rpds_py-0.25.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98c729193e7abe498565266933c125780fb646e977e94289cadbb36e4eeeb370", size = 444403, upload-time = "2025-05-15T13:38:42.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/61/c5485bfa5b7abd55af0c1fe5a7af98682f6b16207e4f980f40d73b84682c/rpds_py-0.25.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36a7564deaac3f372e8b8b701eb982ea3113516e8e08cd87e3dc6ccf29bad14b", size = 387082, upload-time = "2025-05-15T13:38:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/63/b0/a7cd764be9cd0f9425e5a817d41b202f64f524df22f9deb966b69079598a/rpds_py-0.25.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b0c0f671a53c129ea48f9481e95532579cc489ab5a0ffe750c9020787181c48", size = 419891, upload-time = "2025-05-15T13:38:46.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f0/2ee00623c5e8ab504457c681c3fcac3ea3ddc7e51733cc3f451ef1edce02/rpds_py-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d21408eaa157063f56e58ca50da27cad67c4395a85fb44cc7a31253ea4e58918", size = 565856, upload-time = "2025-05-15T13:38:53.212Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/9815253c416c9005973371001f15ba354bc04a7fc8bbb2ad602470d50fe4/rpds_py-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a413674eb2bd2ecb2b93fcc928871b19f7220ee04bca4af3375c50a2b32b5a50", size = 591473, upload-time = "2025-05-15T13:38:54.774Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7f/69a32888306e7b700aa7433ddf0c1c92a20bde31a94c63131b0dd5689f61/rpds_py-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:94f89161a3e358db33310a8a064852a6eb119ed1aa1a3dba927b4e5140e65d00", size = 557659, upload-time = "2025-05-15T13:38:56.909Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/d4edaea1f305c866970e940a31600e493920830a2d3712866b1ec2284c03/rpds_py-0.25.0-cp311-cp311-win32.whl", hash = "sha256:540cd89d256119845b7f8f56c4bb80cad280cab92d9ca473be49ea13e678fd44", size = 219592, upload-time = "2025-05-15T13:38:59.281Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e8/c94eb1678b3cd51023ab855f8c2adcb28dfb2a51d045a1228fc306e09387/rpds_py-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:2649ff19291928243f90c86e4dc9cd86c8c4c6a73c3693ba2e23bc2fbcd8338c", size = 231344, upload-time = "2025-05-15T13:39:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b5/819fd819dd66a65951749a2a475a0b4455fa3ad0b4f84eba5a7d785ac07b/rpds_py-0.25.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:89260601d497fa5957c3e46f10b16cfa2a4808ad4dd46cddc0b997461923a7d9", size = 364544, upload-time = "2025-05-15T13:39:02.1Z" }, + { url = "https://files.pythonhosted.org/packages/bb/66/aea9c48e9f6d8f88b8ecf4ac7f6c6d742e005c33e0bdd46ce0d9f12aee27/rpds_py-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:637ec39f97e342a3f76af739eda96800549d92f3aa27a2170b6dcbdffd49f480", size = 350634, upload-time = "2025-05-15T13:39:03.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/93/e5ee11a1b139f0064d82fff24265de881949e8be96453ec7cc26511e2216/rpds_py-0.25.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd08c82336412a39a598e5baccab2ee2d7bd54e9115c8b64f2febb45da5c368", size = 392993, upload-time = "2025-05-15T13:39:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/3e/46/751eb56baa015486dd353d22dcc12252c69ad30845bd87322702431fe519/rpds_py-0.25.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:837fd066f974e5b98c69ac83ec594b79a2724a39a92a157b8651615e5032e530", size = 399671, upload-time = "2025-05-15T13:39:07.01Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/8c2fe58710e1af0d730173078365cfbea217af7a50e4d4c15d8c125c2bf5/rpds_py-0.25.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:653a066d2a4a332d4f8a11813e8124b643fa7b835b78468087a9898140469eee", size = 452889, upload-time = "2025-05-15T13:39:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/e1/60/5192ddcde55bc19055994c19cb294fb62494fe3b19f707d3572311a06057/rpds_py-0.25.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91a51499be506022b9f09facfc42f0c3a1c45969c0fc8f0bbebc8ff23ab9e531", size = 441069, upload-time = "2025-05-15T13:39:10.689Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/0cbcef1144cd9ed9e30bbcbfb98a823904fefa12b8ebc1e5a0426d8d6a7e/rpds_py-0.25.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb91471640390a82744b164f8a0be4d7c89d173b1170713f9639c6bad61e9e64", size = 391281, upload-time = "2025-05-15T13:39:12.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/509a90ae0496af514c9f00fcbf8952cf3f9279e1c9a78738baa0e5c42b7a/rpds_py-0.25.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28bd2969445acc2d6801a22f97a43134ae3cb18e7495d668bfaa8d82b8526cdc", size = 425308, upload-time = "2025-05-15T13:39:13.787Z" }, + { url = "https://files.pythonhosted.org/packages/e3/61/248102bcc5f3943f337693131a07ad36fac3915d66edcd7d7c74df0770d0/rpds_py-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f933b35fa563f047896a70b69414dfb3952831817e4c4b3a6faa96737627f363", size = 570074, upload-time = "2025-05-15T13:39:15.488Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a1/34d1286b1b655fd2219e56587862f4a894f98d025cde58ae7bf9ed3d54be/rpds_py-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:80b37b37525492250adc7cbca20ae7084f86eb3eb62414b624d2a400370853b1", size = 595438, upload-time = "2025-05-15T13:39:17.209Z" }, + { url = "https://files.pythonhosted.org/packages/be/4a/413b8f664ffdbfa3b711e03328212ee26db9c2710f8148bcb21f379fb9b5/rpds_py-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:864573b6440b770db5a8693547a8728d7fd32580d4903010a8eee0bb5b03b130", size = 561950, upload-time = "2025-05-15T13:39:18.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/25/7c1a6461b704b1408591d9c3739a0cfa05f08a9bf3afc3f5f8cd8a86f5d5/rpds_py-0.25.0-cp312-cp312-win32.whl", hash = "sha256:ad4a896896346adab86d52b31163c39d49e4e94c829494b96cc064bff82c5851", size = 222692, upload-time = "2025-05-15T13:39:22.916Z" }, + { url = "https://files.pythonhosted.org/packages/f2/43/891aeac02896d5a7eaa720c30cc2b960e6e5b9b6364db067a57a29597a99/rpds_py-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:4fbec54cc42fa90ca69158d75f125febc4116b2d934e71c78f97de1388a8feb2", size = 235489, upload-time = "2025-05-15T13:39:24.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d9/6534d5a9d00038261894551ee8043267f17c019e6c0df3c7d822fb5914f1/rpds_py-0.25.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4e5fe366fa53bd6777cf5440245366705338587b2cf8d61348ddaad744eb591a", size = 364375, upload-time = "2025-05-15T13:39:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/af/9d/f90c079635017cc50350cbbbf2c4fea7b2a75a24bea92211da1b0c52d55f/rpds_py-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54f925ff8d4443b7cae23a5215954abbf4736a3404188bde53c4d744ac001d89", size = 350284, upload-time = "2025-05-15T13:39:27.336Z" }, + { url = "https://files.pythonhosted.org/packages/f9/04/b54c5b3abdccf03ca3ec3317bd68caaa7907a61fea063096ee08d128b6ed/rpds_py-0.25.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d58258a66255b2500ddaa4f33191ada5ec983a429c09eb151daf81efbb9aa115", size = 392107, upload-time = "2025-05-15T13:39:30.99Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/001bc3ab81c1798ee4c7bba7950134258d899e566d6839b6696b47248f71/rpds_py-0.25.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f3a57f08c558d0983a708bfe6d1265f47b5debff9b366b2f2091690fada055c", size = 398612, upload-time = "2025-05-15T13:39:32.505Z" }, + { url = "https://files.pythonhosted.org/packages/00/e1/e22893e1043938811a50c857a5780e0a4e2da02dd10ac041ecca1044906a/rpds_py-0.25.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7d60d42f1b9571341ad2322e748f7a60f9847546cd801a3a0eb72a1b54c6519", size = 452190, upload-time = "2025-05-15T13:39:34.024Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6c/7071e6d27e784ac33ab4ca048eb550b5fc4f381b29e9ba33254bc6e7eaf6/rpds_py-0.25.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a54b94b0e4de95aa92618906fb631779d9fde29b4bf659f482c354a3a79fd025", size = 440634, upload-time = "2025-05-15T13:39:36.048Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/7343ea3ec906ee8c2b64a956d702de5067e0058b5d2869fbfb4b11625112/rpds_py-0.25.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af1c2241919304cc2f90e7dcb3eb1c1df6fb4172dd338e629dd6410e48b3d1a0", size = 391000, upload-time = "2025-05-15T13:39:37.802Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ad/9b3c3e950108073448834f0548077e598588efa413ba8dcc91e7ad6ff59d/rpds_py-0.25.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d34547810bfd61acf8a441e8a3651e7a919e8e8aed29850be14a1b05cfc6f41", size = 424621, upload-time = "2025-05-15T13:39:39.409Z" }, + { url = "https://files.pythonhosted.org/packages/57/06/bd99ca30a6e539c18c6175501c1dd7f9ef0640f7b1dc0b14b094785b509a/rpds_py-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66568caacf18542f0cf213db7adf3de2da6ad58c7bf2c4fafec0d81ae557443b", size = 569529, upload-time = "2025-05-15T13:39:41.011Z" }, + { url = "https://files.pythonhosted.org/packages/c5/79/93381a25668466502adc082d3ce2a9ff35f8116e5e2711cedda0bfcfd699/rpds_py-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e49e4c3e899c32884d7828c91d6c3aff08d2f18857f50f86cc91187c31a4ca58", size = 594638, upload-time = "2025-05-15T13:39:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/91/ee/371ecc045d65af518e2210ad018892b1f7a7a21cd64661156b4d29dfd839/rpds_py-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:20af08b0b2d5b196a2bcb70becf0b97ec5af579cee0ae6750b08a2eea3b6c77d", size = 561413, upload-time = "2025-05-15T13:39:45.3Z" }, + { url = "https://files.pythonhosted.org/packages/34/c4/85e9853312b7e5de3c98f100280fbfd903e63936f49f6f11e4cd4eb53299/rpds_py-0.25.0-cp313-cp313-win32.whl", hash = "sha256:d3dc8d6ce8f001c80919bdb49d8b0b815185933a0b8e9cdeaea42b0b6f27eeb0", size = 222326, upload-time = "2025-05-15T13:39:46.777Z" }, + { url = "https://files.pythonhosted.org/packages/65/c6/ac744cc5752b6f291b2cf13e19cd7ea3cafe68922239a3b95f05f39287b7/rpds_py-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:113d134dc5a8d2503630ca2707b58a1bf5b1b3c69b35c7dab8690ee650c111b8", size = 234772, upload-time = "2025-05-15T13:39:48.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/aa/dabab50a2fb321a12ffe4668087e5d0f9b06286ccb260d345bf01c79b07c/rpds_py-0.25.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:6c72a4a8fab10bc96720ad40941bb471e3b1150fb8d62dab205d495511206cf1", size = 359693, upload-time = "2025-05-15T13:39:53.913Z" }, + { url = "https://files.pythonhosted.org/packages/11/3d/acda0095fe54ee6c553d222fb3d275506f8db4198b6a72a69eef826d63c1/rpds_py-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb979162323f3534dce84b59f86e689a0761a2a300e0212bfaedfa80d4eb8100", size = 345911, upload-time = "2025-05-15T13:39:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/fba9b387077f9b305fce27fe22bdb731b75bfe208ae005fd09a127eced05/rpds_py-0.25.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35c8cb5dcf7d36d3adf2ae0730b60fb550a8feb6e432bee7ef84162a0d15714b", size = 387669, upload-time = "2025-05-15T13:39:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a7/b8dbcdc9a8f1e96b5abc58bdfc22f2845178279694a9294fe4feb66ae330/rpds_py-0.25.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:673ba018df5ae5e7b6c9a021d51ffe39c0ae1daa0041611ed27a0bca634b2d2e", size = 392202, upload-time = "2025-05-15T13:39:59.456Z" }, + { url = "https://files.pythonhosted.org/packages/60/60/2d46ad24207114cdb341490387d5a77c845827ac03f2a37182a19d072738/rpds_py-0.25.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16fb28d3a653f67c871a47c5ca0be17bce9fab8adb8bcf7bd09f3771b8c4d860", size = 450080, upload-time = "2025-05-15T13:40:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/85/ae/b1966ca161942f2edf0b2c4fb448b88c19bdb37e982d0907c4b484eb0bbc/rpds_py-0.25.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12a84c3851f9e68633d883c01347db3cb87e6160120a489f9c47162cd276b0a5", size = 438189, upload-time = "2025-05-15T13:40:02.816Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/0a8bff40865e27fc8cd7bdf667958981794ccf5e7989890ae96c89112920/rpds_py-0.25.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b5f457afffb45d3804728a54083e31fbaf460e902e3f7d063e56d0d0814301e", size = 387925, upload-time = "2025-05-15T13:40:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5d/62abbc77e18f9e67556ead54c84a7c662f39236b7a41cf1a39a24bf5e79f/rpds_py-0.25.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9442cbff21122e9a529b942811007d65eabe4182e7342d102caf119b229322c6", size = 417682, upload-time = "2025-05-15T13:40:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/5d/eb/2f65e4332e3566d06c5ccad64441b1eaaf58a6c5999d533720f1f47d3118/rpds_py-0.25.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:383cf0d4288baf5a16812ed70d54ecb7f2064e255eb7fe42c38e926adeae4534", size = 565244, upload-time = "2025-05-15T13:40:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/02/3a/ae5f68ab4879d6fbe3abec3139eab1664c3372d8b42864ab940a4940a61c/rpds_py-0.25.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dcdee07ebf76223092666c72a9552db276fbe46b98830ecd1bb836cc98adc81", size = 590459, upload-time = "2025-05-15T13:40:10.375Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f6/ada6c3d9b803a9eb7bc9c8b3f3cebf7d779bbbb056cd7e3fc150e4c74c00/rpds_py-0.25.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5bbfbd9c74c4dd74815bd532bf29bedea6d27d38f35ef46f9754172a14e4c655", size = 558335, upload-time = "2025-05-15T13:40:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/9a/7d269e8f1bfe3143e699334ca0b578e16b37e6505bf10dca8c02aa8addc8/rpds_py-0.25.0-cp313-cp313t-win32.whl", hash = "sha256:90dbd2c42cb6463c07020695800ae8f347e7dbeff09da2975a988e467b624539", size = 218761, upload-time = "2025-05-15T13:40:16.043Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/f5843b19b7bfd16d63b960cf4c646953010886cc62dd41b00854d77b0eed/rpds_py-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8c2ad59c4342a176cb3e0d5753e1c911eabc95c210fc6d0e913c32bf560bf012", size = 232634, upload-time = "2025-05-15T13:40:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/e2862b6cde99696440f70f71f34cfc5f883c6c93204d945220d223c94c3a/rpds_py-0.25.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:57e9616a2a9da08fe0994e37a0c6f578fbaf6d35911bcba31e99660542d60c45", size = 373739, upload-time = "2025-05-15T13:40:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/1e/58/f419062ee1fdb4cddf790933e14b1620096e95ef924c0509eca83a6ce100/rpds_py-0.25.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6d95521901896a90a858993bfa3ec0f9160d3d97e8c8fefc279b3306cdadfee0", size = 359440, upload-time = "2025-05-15T13:40:49.13Z" }, + { url = "https://files.pythonhosted.org/packages/b4/20/321cbc4d68b6fbb6f72d80438d1af4216b300a3dbff1e9a687625641e79a/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33aef3914a5b49db12ed3f24d214ffa50caefc8f4b0c7c7b9485bd4b231a898", size = 389607, upload-time = "2025-05-15T13:40:52.406Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d2/cda336d67bee9b936559245da63b21dd7d622220ceda231ecb6ae62e9379/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4acbe2349a3baac9cc212005b6cb4bbb7e5b34538886cde4f55dfc29173da1d6", size = 393540, upload-time = "2025-05-15T13:40:55.398Z" }, + { url = "https://files.pythonhosted.org/packages/65/14/f59bd89270a384349b3beb5c7fa636e20c0f719a55a227e6872236a68d71/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b75b5d3416b00d064a5e6f4814fdfb18a964a7cf38dc00b5c2c02fa30a7dd0b", size = 450675, upload-time = "2025-05-15T13:40:57.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/52/7567da6cc8293bcf4572a895bdcb4fbd9b23f7c2ebbcf943b8a8caf78ff2/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:542a6f1d0f400b9ce1facb3e30dd3dc84e4affc60353509b00a7bdcd064be91e", size = 444899, upload-time = "2025-05-15T13:40:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/169498c962ea9752d809c9505dee23000a8370cc15bb6a88dcef6a58f3a8/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60ba9d104f4e8496107b1cb86e45a68a16d13511dc3986e0780e9f85c2136f9", size = 387855, upload-time = "2025-05-15T13:41:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/80/4c/05888641972cac3dbb17de60ee07cbcb85c80a462f3b0eb61d8cf8921ccf/rpds_py-0.25.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6065a489b7b284efb29d57adffae2b9b5e9403d3c8d95cfa04e04e024e6b4e77", size = 420539, upload-time = "2025-05-15T13:41:02.687Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/95d3a8cecb7f31ea4ce98096431cc93295543ba8dd5b23fe006b762fc16a/rpds_py-0.25.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6bcca4d0d24d8c37bfe0cafdaaf4346b6c516db21ccaad5c7fba0a0df818dfc9", size = 566610, upload-time = "2025-05-15T13:41:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/cc8f2615df4bf97316aa03f7b5f1acccd9b2fa871a652e8a961b06486e9c/rpds_py-0.25.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:8155e21203161e5c78791fc049b99f0bbbf14d1d1839c8c93c8344957f9e8e1e", size = 591499, upload-time = "2025-05-15T13:41:07.956Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/f6fb6a91ed0b8e5b7a4e27f8c959bfcfaad7b57341ef7d99e248165de188/rpds_py-0.25.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a1eda14db1ac7a2ab4536dfe69e4d37fdd765e8e784ae4451e61582ebb76012", size = 558441, upload-time = "2025-05-15T13:41:09.656Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/40057d99358d7bf116eea1cb4ffe33e66294392d4ade3db6d3ee56817597/rpds_py-0.25.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:de34a7d1893be76cb015929690dce3bde29f4de08143da2e9ad1cedb11dbf80e", size = 231644, upload-time = "2025-05-15T13:41:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/73/80/e28624a339aea0634da115fe520f44703cce2f0b07191fb010d606cd9839/rpds_py-0.25.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0d63a86b457069d669c423f093db4900aa102f0e5a626973eff4db8355c0fd96", size = 374033, upload-time = "2025-05-15T13:41:14.668Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5a/4d7eba630368fb7183bf18eb7d11090048e6e756dec1d71dc228815eb002/rpds_py-0.25.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89bb2b20829270aca28b1e5481be8ee24cb9aa86e6c0c81cb4ada2112c9588c5", size = 359591, upload-time = "2025-05-15T13:41:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/97/a7/7a9d5bdd68c3741ebe094861793fce58136455ef708e440f0aef1dd0fb50/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e103b48e63fd2b8a8e2b21ab5b5299a7146045626c2ed4011511ea8122d217", size = 389565, upload-time = "2025-05-15T13:41:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1e/53e9f85d7c859122b46d60052473719b449d653ba8a125d62533dc7a72d6/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fccd24c080850715c58a80200d367bc62b4bff6c9fb84e9564da1ebcafea6418", size = 393572, upload-time = "2025-05-15T13:41:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/f2ea6864c782da253e433bd9538710fc501e41f7edda580b54bf498c203b/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b42790c91e0041a98f0ec04244fb334696938793e785a5d4c7e56ca534d7da", size = 450905, upload-time = "2025-05-15T13:41:22.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/75/45c1c8be90c909732d47a6b354c4b2c45c7d2e868c9da90dceb71a30938c/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc907ea12216cfc5560148fc42459d86740fc739981c6feb94230dab09362679", size = 444337, upload-time = "2025-05-15T13:41:25.953Z" }, + { url = "https://files.pythonhosted.org/packages/d8/07/cff35d166814454dfe2cd5aec0960e717711ebb39e857ede5cdac65a3fa7/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e11065b759c38c4945f8c9765ed2910e31fa5b2f7733401eb7d966f468367a2", size = 387925, upload-time = "2025-05-15T13:41:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/8c/33/f5ddeb28300ab062985e389bb3974793bb07be37bf9ab0c2dff42dc6b1ea/rpds_py-0.25.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8abc1a3e29b599bf8bb5ad455256a757e8b0ed5621e7e48abe8209932dc6d11e", size = 420658, upload-time = "2025-05-15T13:41:30.097Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ef/1806d0f8060a85c3561526f2019fbde5b082af07b99fc8aeea001acdf7ab/rpds_py-0.25.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:cd36b71f9f3bf195b2dd9be5eafbfc9409e6c8007aebc38a4dc051f522008033", size = 566601, upload-time = "2025-05-15T13:41:33.47Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/de2e0a8de964cf7e8d5ed9b51e9be74e485d3a34d7f0ec27005c787ca96d/rpds_py-0.25.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:805a0dff0674baa3f360c21dcbc622ae544f2bb4753d87a4a56a1881252a477e", size = 591728, upload-time = "2025-05-15T13:41:35.312Z" }, + { url = "https://files.pythonhosted.org/packages/43/da/6bc93116657c720d0843ed4ed5b1c3c127ca56e6c048e9ebd402496f0649/rpds_py-0.25.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:96742796f499ac23b59856db734e65b286d1214a0d9b57bcd7bece92d9201fa4", size = 558441, upload-time = "2025-05-15T13:41:38.029Z" }, +] + [[package]] name = "ruff" version = "0.8.5"