From c0cbc9ee3a1f6030939f1d849ae119ffad5ef903 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 23 Jun 2025 14:03:29 -0400 Subject: [PATCH 1/5] Add inputSchema validation to lowlevel server - Add jsonschema dependency for schema validation - Implement tool definition cache in Server class that gets refreshed when list_tools is called - Add _validate_tool_arguments helper method to validate tool arguments against inputSchema - Update call_tool handler to validate arguments before execution - Log warning and skip validation for tools not found in cache - Add comprehensive tests for validation scenarios This ensures tool arguments are validated against their JSON schemas before execution, providing better error messages and preventing invalid tool calls from reaching handlers. --- pyproject.toml | 1 + src/mcp/server/lowlevel/server.py | 46 ++- .../server/test_lowlevel_input_validation.py | 311 ++++++++++++++++++ uv.lock | 142 ++++++++ 4 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 tests/server/test_lowlevel_input_validation.py diff --git a/pyproject.toml b/pyproject.toml index 9ad50ab58..31871340c 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.20.0", ] [project.optional-dependencies] diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 0a8ab7f97..44e1bcdfe 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -75,6 +75,7 @@ async def main(): from typing import Any, Generic import anyio +import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from pydantic import AnyUrl from typing_extensions import TypeVar @@ -143,6 +144,7 @@ def __init__( } self.notification_handlers: dict[type, Callable[..., Awaitable[None]]] = {} self.notification_options = NotificationOptions() + self._tool_cache: dict[str, types.Tool] = {} logger.debug("Initializing server %r", name) def create_initialization_options( @@ -373,6 +375,10 @@ def decorator(func: Callable[[], Awaitable[list[types.Tool]]]): async def handler(_: Any): tools = await func() + # Refresh the tool cache + self._tool_cache.clear() + for tool in tools: + self._tool_cache[tool.name] = tool return types.ServerResult(types.ListToolsResult(tools=tools)) self.request_handlers[types.ListToolsRequest] = handler @@ -380,6 +386,31 @@ async def handler(_: Any): return decorator + async def _validate_tool_arguments(self, tool_name: str, arguments: dict[str, Any]) -> str | None: + """Validate tool arguments against inputSchema. + + Returns None if validation passes, or an error message if validation fails. + """ + # Check if tool is in cache + if tool_name not in self._tool_cache: + # Try to refresh the cache by calling list_tools + if types.ListToolsRequest in self.request_handlers: + logger.debug("Tool cache miss for %s, refreshing cache", tool_name) + await self.request_handlers[types.ListToolsRequest](None) + + # Check again after potential refresh + if tool_name in self._tool_cache: + tool = self._tool_cache[tool_name] + try: + # Validate arguments against inputSchema + jsonschema.validate(instance=arguments, schema=tool.inputSchema) + return None + except jsonschema.ValidationError as e: + return f"Input validation error: {e.message}" + else: + logger.warning("Tool '%s' not found in cache, validation will not be performed", tool_name) + return None + def call_tool(self): def decorator( func: Callable[ @@ -391,7 +422,20 @@ def decorator( async def handler(req: types.CallToolRequest): try: - results = await func(req.params.name, (req.params.arguments or {})) + tool_name = req.params.name + arguments = req.params.arguments or {} + + # Validate arguments + validation_error = await self._validate_tool_arguments(tool_name, arguments) + if validation_error: + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=validation_error)], + isError=True, + ) + ) + + results = await func(tool_name, arguments) return types.ServerResult(types.CallToolResult(content=list(results), isError=False)) except Exception as e: return types.ServerResult( diff --git a/tests/server/test_lowlevel_input_validation.py b/tests/server/test_lowlevel_input_validation.py new file mode 100644 index 000000000..a4a48ffa9 --- /dev/null +++ b/tests/server/test_lowlevel_input_validation.py @@ -0,0 +1,311 @@ +"""Test input schema validation for lowlevel server.""" + +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool + + +async def run_tool_test( + tools: list[Tool], + call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[list[TextContent]]], + test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], +) -> CallToolResult: + """Helper to run a tool test with minimal boilerplate. + + Args: + tools: List of tools to register + call_tool_handler: Handler function for tool calls + test_callback: Async function that performs the test using the client session + + Returns: + The result of the tool call + """ + server = Server("test") + + @server.list_tools() + async def list_tools(): + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + return await call_tool_handler(name, arguments) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Message handler for client + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + raise message + + # Server task + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + # Run the test + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + # Initialize the session + await client_session.initialize() + + # Run the test callback + result = await test_callback(client_session) + + # Cancel the server task + tg.cancel_scope.cancel() + + return result + + +def create_add_tool() -> Tool: + """Create a standard 'add' tool for testing.""" + return Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + "additionalProperties": False, + }, + ) + + +@pytest.mark.anyio +async def test_valid_tool_call(): + """Test that valid arguments pass validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "add": + result = arguments["a"] + arguments["b"] + return [TextContent(type="text", text=f"Result: {result}")] + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": 5, "b": 3}) + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Result: 8" + + +@pytest.mark.anyio +async def test_invalid_tool_call_missing_required(): + """Test that missing required arguments fail validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This should not be reached due to validation + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": 5}) # missing 'b' + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'b' is a required property" in result.content[0].text + + +@pytest.mark.anyio +async def test_invalid_tool_call_wrong_type(): + """Test that wrong argument types fail validation.""" + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This should not be reached due to validation + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("add", {"a": "five", "b": 3}) # 'a' should be number + + result = await run_tool_test([create_add_tool()], call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'five' is not of type 'number'" in result.content[0].text + + +@pytest.mark.anyio +async def test_cache_refresh_on_missing_tool(): + """Test that tool cache is refreshed when tool is not found.""" + tools = [ + Tool( + name="multiply", + description="Multiply two numbers", + inputSchema={ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "multiply": + result = arguments["x"] * arguments["y"] + return [TextContent(type="text", text=f"Result: {result}")] + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + # Call tool without first listing tools (cache should be empty) + # The cache should be refreshed automatically + return await client_session.call_tool("multiply", {"x": 10, "y": 20}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results - should work because cache will be refreshed + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Result: 200" + + +@pytest.mark.anyio +async def test_enum_constraint_validation(): + """Test that enum constraints are validated.""" + tools = [ + Tool( + name="greet", + description="Greet someone", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "title": {"type": "string", "enum": ["Mr", "Ms", "Dr"]}, + }, + "required": ["name"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This should not be reached due to validation failure + raise RuntimeError("Should not reach here") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("greet", {"name": "Smith", "title": "Prof"}) # Invalid title + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Input validation error" in result.content[0].text + assert "'Prof' is not one of" in result.content[0].text + + +@pytest.mark.anyio +async def test_tool_not_in_list_logs_warning(caplog): + """Test that calling a tool not in list_tools logs a warning and skips validation.""" + tools = [ + Tool( + name="add", + description="Add two numbers", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This should be reached since validation is skipped for unknown tools + if name == "unknown_tool": + # Even with invalid arguments, this should execute since validation is skipped + return [TextContent(type="text", text="Unknown tool executed without validation")] + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + # Call a tool that's not in the list with invalid arguments + # This should trigger the warning about validation not being performed + return await client_session.call_tool("unknown_tool", {"invalid": "args"}) + + with caplog.at_level(logging.WARNING): + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results - should succeed because validation is skipped for unknown tools + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Unknown tool executed without validation" + + # Verify warning was logged + assert any( + "Tool 'unknown_tool' not found in cache, validation will not be performed" in record.message + for record in caplog.records + ) diff --git a/uv.lock b/uv.lock index 180d5a9c1..b4b3e158d 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.20.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/a8/74/77bf12d3dd32b764692a71d4200f03429c41eee2e8a9225d344d91c03aff/jsonschema-4.20.0.tar.gz", hash = "sha256:4f614fd46d8d61258610998997743ec5492a648b33cf478c1ddc23ed4598a5fa", size = 320243, upload-time = "2023-11-16T17:08:05.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/ed/0058234d8dd2b1fc6beeea8eab945191a05e9d391a63202f49fe23327586/jsonschema-4.20.0-py3-none-any.whl", hash = "sha256:ed6231f0429ecf966f5bc8dfef245998220549cbbcf140f913b7464c52c3b6b3", size = 84663, upload-time = "2023-11-16T17:08:03.186Z" }, +] + +[[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.20.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,105 @@ 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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/09/e1158988e50905b7f8306487a576b52d32aa9a87f79f7ab24ee8db8b6c05/rpds_py-0.25.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f4ad628b5174d5315761b67f212774a32f5bad5e61396d38108bd801c0a8f5d9", size = 373140, upload-time = "2025-05-21T12:42:38.834Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/a284321fb3c45c02fc74187171504702b2934bfe16abab89713eedfe672e/rpds_py-0.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c742af695f7525e559c16f1562cf2323db0e3f0fbdcabdf6865b095256b2d40", size = 358860, upload-time = "2025-05-21T12:42:41.394Z" }, + { url = "https://files.pythonhosted.org/packages/4e/46/8ac9811150c75edeae9fc6fa0e70376c19bc80f8e1f7716981433905912b/rpds_py-0.25.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:605ffe7769e24b1800b4d024d24034405d9404f0bc2f55b6db3362cd34145a6f", size = 386179, upload-time = "2025-05-21T12:42:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/87eb42d83e859bce91dcf763eb9f2ab117142a49c9c3d17285440edb5b69/rpds_py-0.25.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc6f3ddef93243538be76f8e47045b4aad7a66a212cd3a0f23e34469473d36b", size = 400282, upload-time = "2025-05-21T12:42:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/68/c8/2a38e0707d7919c8c78e1d582ab15cf1255b380bcb086ca265b73ed6db23/rpds_py-0.25.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f70316f760174ca04492b5ab01be631a8ae30cadab1d1081035136ba12738cfa", size = 521824, upload-time = "2025-05-21T12:42:46.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/6a92790243569784dde84d144bfd12bd45102f4a1c897d76375076d730ab/rpds_py-0.25.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1dafef8df605fdb46edcc0bf1573dea0d6d7b01ba87f85cd04dc855b2b4479e", size = 411644, upload-time = "2025-05-21T12:42:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/eb/76/66b523ffc84cf47db56efe13ae7cf368dee2bacdec9d89b9baca5e2e6301/rpds_py-0.25.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701942049095741a8aeb298a31b203e735d1c61f4423511d2b1a41dcd8a16da", size = 386955, upload-time = "2025-05-21T12:42:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b9/a362d7522feaa24dc2b79847c6175daa1c642817f4a19dcd5c91d3e2c316/rpds_py-0.25.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e87798852ae0b37c88babb7f7bbbb3e3fecc562a1c340195b44c7e24d403e380", size = 421039, upload-time = "2025-05-21T12:42:52.348Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c4/b5b6f70b4d719b6584716889fd3413102acf9729540ee76708d56a76fa97/rpds_py-0.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bcce0edc1488906c2d4c75c94c70a0417e83920dd4c88fec1078c94843a6ce9", size = 563290, upload-time = "2025-05-21T12:42:54.404Z" }, + { url = "https://files.pythonhosted.org/packages/87/a3/2e6e816615c12a8f8662c9d8583a12eb54c52557521ef218cbe3095a8afa/rpds_py-0.25.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e2f6a2347d3440ae789505693a02836383426249d5293541cd712e07e7aecf54", size = 592089, upload-time = "2025-05-21T12:42:55.976Z" }, + { url = "https://files.pythonhosted.org/packages/c0/08/9b8e1050e36ce266135994e2c7ec06e1841f1c64da739daeb8afe9cb77a4/rpds_py-0.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4fd52d3455a0aa997734f3835cbc4c9f32571345143960e7d7ebfe7b5fbfa3b2", size = 558400, upload-time = "2025-05-21T12:42:58.032Z" }, + { url = "https://files.pythonhosted.org/packages/f2/df/b40b8215560b8584baccd839ff5c1056f3c57120d79ac41bd26df196da7e/rpds_py-0.25.1-cp310-cp310-win32.whl", hash = "sha256:3f0b1798cae2bbbc9b9db44ee068c556d4737911ad53a4e5093d09d04b3bbc24", size = 219741, upload-time = "2025-05-21T12:42:59.479Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/e4c58be18cf5d8b40b8acb4122bc895486230b08f978831b16a3916bd24d/rpds_py-0.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3ebd879ab996537fc510a2be58c59915b5dd63bccb06d1ef514fee787e05984a", size = 231553, upload-time = "2025-05-21T12:43:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" }, + { url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" }, + { url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" }, + { url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" }, + { url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" }, + { url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" }, + { url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" }, + { url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" }, + { url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" }, + { url = "https://files.pythonhosted.org/packages/2b/da/323848a2b62abe6a0fec16ebe199dc6889c5d0a332458da8985b2980dffe/rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559", size = 364498, upload-time = "2025-05-21T12:43:54.841Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b4/4d3820f731c80fd0cd823b3e95b9963fec681ae45ba35b5281a42382c67d/rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1", size = 350083, upload-time = "2025-05-21T12:43:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b1/3a8ee1c9d480e8493619a437dec685d005f706b69253286f50f498cbdbcf/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c", size = 389023, upload-time = "2025-05-21T12:43:57.995Z" }, + { url = "https://files.pythonhosted.org/packages/3b/31/17293edcfc934dc62c3bf74a0cb449ecd549531f956b72287203e6880b87/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb", size = 403283, upload-time = "2025-05-21T12:43:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ca/e0f0bc1a75a8925024f343258c8ecbd8828f8997ea2ac71e02f67b6f5299/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40", size = 524634, upload-time = "2025-05-21T12:44:01.087Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/5d0be919037178fff33a6672ffc0afa04ea1cfcb61afd4119d1b5280ff0f/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79", size = 416233, upload-time = "2025-05-21T12:44:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/05/7c/8abb70f9017a231c6c961a8941403ed6557664c0913e1bf413cbdc039e75/rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325", size = 390375, upload-time = "2025-05-21T12:44:04.162Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ac/a87f339f0e066b9535074a9f403b9313fd3892d4a164d5d5f5875ac9f29f/rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295", size = 424537, upload-time = "2025-05-21T12:44:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8d5c1567eaf8c8afe98a838dd24de5013ce6e8f53a01bd47fe8bb06b5533/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b", size = 566425, upload-time = "2025-05-21T12:44:08.242Z" }, + { url = "https://files.pythonhosted.org/packages/95/33/03016a6be5663b389c8ab0bbbcca68d9e96af14faeff0a04affcb587e776/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98", size = 595197, upload-time = "2025-05-21T12:44:10.449Z" }, + { url = "https://files.pythonhosted.org/packages/33/8d/da9f4d3e208c82fda311bff0cf0a19579afceb77cf456e46c559a1c075ba/rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd", size = 561244, upload-time = "2025-05-21T12:44:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b3/39d5dcf7c5f742ecd6dbc88f6f84ae54184b92f5f387a4053be2107b17f1/rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31", size = 222254, upload-time = "2025-05-21T12:44:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/19/2d6772c8eeb8302c5f834e6d0dfd83935a884e7c5ce16340c7eaf89ce925/rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500", size = 234741, upload-time = "2025-05-21T12:44:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/145ada26cfaf86018d0eb304fe55eafdd4f0b6b84530246bb4a7c4fb5c4b/rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5", size = 224830, upload-time = "2025-05-21T12:44:17.749Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ca/d435844829c384fd2c22754ff65889c5c556a675d2ed9eb0e148435c6690/rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129", size = 359668, upload-time = "2025-05-21T12:44:19.322Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/b056f21db3a09f89410d493d2f6614d87bb162499f98b649d1dbd2a81988/rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d", size = 345649, upload-time = "2025-05-21T12:44:20.962Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0f/e0d00dc991e3d40e03ca36383b44995126c36b3eafa0ccbbd19664709c88/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72", size = 384776, upload-time = "2025-05-21T12:44:22.516Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a2/59374837f105f2ca79bde3c3cd1065b2f8c01678900924949f6392eab66d/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34", size = 395131, upload-time = "2025-05-21T12:44:24.147Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/48e8d84887627a0fe0bac53f0b4631e90976fd5d35fff8be66b8e4f3916b/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9", size = 520942, upload-time = "2025-05-21T12:44:25.915Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f5/ee056966aeae401913d37befeeab57a4a43a4f00099e0a20297f17b8f00c/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5", size = 411330, upload-time = "2025-05-21T12:44:27.638Z" }, + { url = "https://files.pythonhosted.org/packages/ab/74/b2cffb46a097cefe5d17f94ede7a174184b9d158a0aeb195f39f2c0361e8/rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194", size = 387339, upload-time = "2025-05-21T12:44:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9a/0ff0b375dcb5161c2b7054e7d0b7575f1680127505945f5cabaac890bc07/rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6", size = 418077, upload-time = "2025-05-21T12:44:30.877Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a1/fda629bf20d6b698ae84c7c840cfb0e9e4200f664fc96e1f456f00e4ad6e/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78", size = 562441, upload-time = "2025-05-21T12:44:32.541Z" }, + { url = "https://files.pythonhosted.org/packages/20/15/ce4b5257f654132f326f4acd87268e1006cc071e2c59794c5bdf4bebbb51/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72", size = 590750, upload-time = "2025-05-21T12:44:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ab/e04bf58a8d375aeedb5268edcc835c6a660ebf79d4384d8e0889439448b0/rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66", size = 558891, upload-time = "2025-05-21T12:44:37.358Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cb8c6028a6ef6cd2b7991e2e4ced01c854b6236ecf51e81b64b569c43d73/rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523", size = 218718, upload-time = "2025-05-21T12:44:38.969Z" }, + { url = "https://files.pythonhosted.org/packages/b6/97/5a4b59697111c89477d20ba8a44df9ca16b41e737fa569d5ae8bff99e650/rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763", size = 232218, upload-time = "2025-05-21T12:44:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/78/ff/566ce53529b12b4f10c0a348d316bd766970b7060b4fd50f888be3b3b281/rpds_py-0.25.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b24bf3cd93d5b6ecfbedec73b15f143596c88ee249fa98cefa9a9dc9d92c6f28", size = 373931, upload-time = "2025-05-21T12:45:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/83/5d/deba18503f7c7878e26aa696e97f051175788e19d5336b3b0e76d3ef9256/rpds_py-0.25.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:0eb90e94f43e5085623932b68840b6f379f26db7b5c2e6bcef3179bd83c9330f", size = 359074, upload-time = "2025-05-21T12:45:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/0d/74/313415c5627644eb114df49c56a27edba4d40cfd7c92bd90212b3604ca84/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d50e4864498a9ab639d6d8854b25e80642bd362ff104312d9770b05d66e5fb13", size = 387255, upload-time = "2025-05-21T12:45:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c8/c723298ed6338963d94e05c0f12793acc9b91d04ed7c4ba7508e534b7385/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c9409b47ba0650544b0bb3c188243b83654dfe55dcc173a86832314e1a6a35d", size = 400714, upload-time = "2025-05-21T12:45:10.39Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/51f1f6aa653c2e110ed482ef2ae94140d56c910378752a1b483af11019ee/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:796ad874c89127c91970652a4ee8b00d56368b7e00d3477f4415fe78164c8000", size = 523105, upload-time = "2025-05-21T12:45:12.273Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a4/7873d15c088ad3bff36910b29ceb0f178e4b3232c2adbe9198de68a41e63/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85608eb70a659bf4c1142b2781083d4b7c0c4e2c90eff11856a9754e965b2540", size = 411499, upload-time = "2025-05-21T12:45:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/90/f3/0ce1437befe1410766d11d08239333ac1b2d940f8a64234ce48a7714669c/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4feb9211d15d9160bc85fa72fed46432cdc143eb9cf6d5ca377335a921ac37b", size = 387918, upload-time = "2025-05-21T12:45:15.649Z" }, + { url = "https://files.pythonhosted.org/packages/94/d4/5551247988b2a3566afb8a9dba3f1d4a3eea47793fd83000276c1a6c726e/rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccfa689b9246c48947d31dd9d8b16d89a0ecc8e0e26ea5253068efb6c542b76e", size = 421705, upload-time = "2025-05-21T12:45:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/25/5960f28f847bf736cc7ee3c545a7e1d2f3b5edaf82c96fb616c2f5ed52d0/rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3c5b317ecbd8226887994852e85de562f7177add602514d4ac40f87de3ae45a8", size = 564489, upload-time = "2025-05-21T12:45:19.466Z" }, + { url = "https://files.pythonhosted.org/packages/02/66/1c99884a0d44e8c2904d3c4ec302f995292d5dde892c3bf7685ac1930146/rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:454601988aab2c6e8fd49e7634c65476b2b919647626208e376afcd22019eeb8", size = 592557, upload-time = "2025-05-21T12:45:21.362Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/4aeac84ebeffeac14abb05b3bb1d2f728d00adb55d3fb7b51c9fa772e760/rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1c0c434a53714358532d13539272db75a5ed9df75a4a090a753ac7173ec14e11", size = 558691, upload-time = "2025-05-21T12:45:23.084Z" }, + { url = "https://files.pythonhosted.org/packages/41/b3/728a08ff6f5e06fe3bb9af2e770e9d5fd20141af45cff8dfc62da4b2d0b3/rpds_py-0.25.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f73ce1512e04fbe2bc97836e89830d6b4314c171587a99688082d090f934d20a", size = 231651, upload-time = "2025-05-21T12:45:24.72Z" }, + { url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" }, + { url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" }, + { url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" }, +] + [[package]] name = "ruff" version = "0.8.5" From 35b7efb116a84fd0f073621ccc6ba6e1c81ad9ad Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 23 Jun 2025 15:31:25 -0400 Subject: [PATCH 2/5] Add outputSchema validation to lowlevel server - Refactor code to extract _get_tool_definition helper and simplify validation - Update call_tool to support three return types: content only, dict only, or both - Add outputSchema validation that checks structured content matches the schema - Serialize dict-only results to JSON text content - Factor error result construction into _make_error_result helper - Add comprehensive tests for all output validation scenarios The server now validates tool outputs against their defined schemas, providing better error messages and ensuring tool responses match their contracts. --- src/mcp/server/lowlevel/server.py | 108 +++-- src/mcp/types.py | 7 + .../server/test_lowlevel_input_validation.py | 3 +- .../server/test_lowlevel_output_validation.py | 433 ++++++++++++++++++ 4 files changed, 515 insertions(+), 36 deletions(-) create mode 100644 tests/server/test_lowlevel_output_validation.py diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 44e1bcdfe..e26323f74 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -68,11 +68,12 @@ async def main(): from __future__ import annotations as _annotations import contextvars +import json import logging 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 import jsonschema @@ -386,36 +387,48 @@ async def handler(_: Any): return decorator - async def _validate_tool_arguments(self, tool_name: str, arguments: dict[str, Any]) -> str | None: - """Validate tool arguments against inputSchema. + def _make_error_result(self, error_message: str) -> types.ServerResult: + """Create a ServerResult with an error CallToolResult.""" + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=error_message)], + isError=True, + ) + ) + + async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None: + """Get tool definition from cache, refreshing if necessary. - Returns None if validation passes, or an error message if validation fails. + Returns the Tool object if found, None otherwise. """ - # Check if tool is in cache if tool_name not in self._tool_cache: - # Try to refresh the cache by calling list_tools if types.ListToolsRequest in self.request_handlers: logger.debug("Tool cache miss for %s, refreshing cache", tool_name) await self.request_handlers[types.ListToolsRequest](None) - # Check again after potential refresh - if tool_name in self._tool_cache: - tool = self._tool_cache[tool_name] - try: - # Validate arguments against inputSchema - jsonschema.validate(instance=arguments, schema=tool.inputSchema) - return None - except jsonschema.ValidationError as e: - return f"Input validation error: {e.message}" - else: - logger.warning("Tool '%s' not found in cache, validation will not be performed", tool_name) - return None + tool = self._tool_cache.get(tool_name) + if tool is None: + logger.warning("Tool '%s' not listed, no validation will be performed", tool_name) + + return tool def call_tool(self): + """Register a tool call handler. + + The handler validates input against inputSchema, calls the tool function, and processes results: + - Content only: returns as-is + - Dict only: serializes to JSON text and returns as content with structuredContent + - Both: returns content and structuredContent + + If outputSchema is defined, validates structuredContent or errors if missing. + """ + def decorator( func: Callable[ ..., - Awaitable[Iterable[types.ContentBlock]], + Awaitable[ + Iterable[types.ContentBlock] | dict[str, Any] | tuple[Iterable[types.ContentBlock], dict[str, Any]] + ], ], ): logger.debug("Registering handler for CallToolRequest") @@ -424,26 +437,53 @@ async def handler(req: types.CallToolRequest): try: tool_name = req.params.name arguments = req.params.arguments or {} + tool = await self._get_cached_tool_definition(tool_name) - # Validate arguments - validation_error = await self._validate_tool_arguments(tool_name, arguments) - if validation_error: - return types.ServerResult( - types.CallToolResult( - content=[types.TextContent(type="text", text=validation_error)], - isError=True, - ) - ) + # input validation + if tool: + try: + jsonschema.validate(instance=arguments, schema=tool.inputSchema) + except jsonschema.ValidationError as e: + return self._make_error_result(f"Input validation error: {e.message}") + # tool call results = await func(tool_name, arguments) - return types.ServerResult(types.CallToolResult(content=list(results), isError=False)) - except Exception as e: + + # output normalization + content: list[types.ContentBlock] + structured_content: dict[str, Any] | None + + if isinstance(results, tuple) and len(results) == 2: + # tool returned both content and structured content + structured_content = cast(dict[str, Any], results[1]) + content = list(cast(Iterable[types.ContentBlock], results[0])) + elif isinstance(results, dict): + # tool returned structured content only + structured_content = cast(dict[str, Any], results) + content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] + else: + # tool returned content only + structured_content = None + content = list(cast(Iterable[types.ContentBlock], results)) + + # output validation + if tool and tool.outputSchema is not None: + if structured_content is None: + return self._make_error_result( + "Output validation error: outputSchema defined but no structured output returned" + ) + else: + try: + jsonschema.validate(instance=structured_content, schema=tool.outputSchema) + except jsonschema.ValidationError as e: + return self._make_error_result(f"Output validation error: {e.message}") + + # result return types.ServerResult( - types.CallToolResult( - content=[types.TextContent(type="text", text=str(e))], - isError=True, - ) + types.CallToolResult(content=content, structuredContent=structured_content, isError=False) ) + except Exception as e: + return self._make_error_result(str(e)) self.request_handlers[types.CallToolRequest] = handler return func diff --git a/src/mcp/types.py b/src/mcp/types.py index d5663dad6..ef5e5dad8 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -839,6 +839,11 @@ class Tool(BaseMetadata): """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 + """ + An optional JSON Schema object defining the structure of the tool's output + returned in the structuredContent field of a CallToolResult. + """ annotations: ToolAnnotations | None = None """Optional additional tool information.""" meta: dict[str, Any] | None = Field(alias="_meta", default=None) @@ -874,6 +879,8 @@ class CallToolResult(Result): """The server's response to a tool call.""" content: list[ContentBlock] + structuredContent: dict[str, Any] | None = None + """An optional JSON object that represents the structured result of the tool call.""" isError: bool = False diff --git a/tests/server/test_lowlevel_input_validation.py b/tests/server/test_lowlevel_input_validation.py index a4a48ffa9..250159733 100644 --- a/tests/server/test_lowlevel_input_validation.py +++ b/tests/server/test_lowlevel_input_validation.py @@ -306,6 +306,5 @@ async def test_callback(client_session: ClientSession) -> CallToolResult: # Verify warning was logged assert any( - "Tool 'unknown_tool' not found in cache, validation will not be performed" in record.message - for record in caplog.records + "Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records ) diff --git a/tests/server/test_lowlevel_output_validation.py b/tests/server/test_lowlevel_output_validation.py new file mode 100644 index 000000000..39f0d970d --- /dev/null +++ b/tests/server/test_lowlevel_output_validation.py @@ -0,0 +1,433 @@ +"""Test output schema validation for lowlevel server.""" + +import json +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import pytest + +from mcp.client.session import ClientSession +from mcp.server import Server +from mcp.server.lowlevel import NotificationOptions +from mcp.server.models import InitializationOptions +from mcp.server.session import ServerSession +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from mcp.types import CallToolResult, ClientResult, ServerNotification, ServerRequest, TextContent, Tool + + +async def run_tool_test( + tools: list[Tool], + call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[Any]], + test_callback: Callable[[ClientSession], Awaitable[CallToolResult]], +) -> CallToolResult: + """Helper to run a tool test with minimal boilerplate. + + Args: + tools: List of tools to register + call_tool_handler: Handler function for tool calls + test_callback: Async function that performs the test using the client session + + Returns: + The result of the tool call + """ + server = Server("test") + + @server.list_tools() + async def list_tools(): + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + return await call_tool_handler(name, arguments) + + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](10) + + # Message handler for client + async def message_handler( + message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception, + ) -> None: + if isinstance(message, Exception): + raise message + + # Server task + async def run_server(): + async with ServerSession( + client_to_server_receive, + server_to_client_send, + InitializationOptions( + server_name="test-server", + server_version="1.0.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) as server_session: + async with anyio.create_task_group() as tg: + + async def handle_messages(): + async for message in server_session.incoming_messages: + await server._handle_message(message, server_session, {}, False) + + tg.start_soon(handle_messages) + await anyio.sleep_forever() + + # Run the test + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + + async with ClientSession( + server_to_client_receive, + client_to_server_send, + message_handler=message_handler, + ) as client_session: + # Initialize the session + await client_session.initialize() + + # Run the test callback + result = await test_callback(client_session) + + # Cancel the server task + tg.cancel_scope.cancel() + + return result + + +@pytest.mark.anyio +async def test_content_only_without_output_schema(): + """Test returning content only when no outputSchema is defined.""" + tools = [ + Tool( + name="echo", + description="Echo a message", + inputSchema={ + "type": "object", + "properties": { + "message": {"type": "string"}, + }, + "required": ["message"], + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + if name == "echo": + return [TextContent(type="text", text=f"Echo: {arguments['message']}")] + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("echo", {"message": "Hello"}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Echo: Hello" + assert result.structuredContent is None + + +@pytest.mark.anyio +async def test_dict_only_without_output_schema(): + """Test returning dict only when no outputSchema is defined.""" + tools = [ + Tool( + name="get_info", + description="Get structured information", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "get_info": + return {"status": "ok", "data": {"value": 42}} + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("get_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + # Check that the content is the JSON serialization + assert json.loads(result.content[0].text) == {"status": "ok", "data": {"value": 42}} + assert result.structuredContent == {"status": "ok", "data": {"value": 42}} + + +@pytest.mark.anyio +async def test_both_content_and_dict_without_output_schema(): + """Test returning both content and dict when no outputSchema is defined.""" + tools = [ + Tool( + name="process", + description="Process data", + inputSchema={ + "type": "object", + "properties": {}, + }, + # No outputSchema defined + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: + if name == "process": + content = [TextContent(type="text", text="Processing complete")] + data = {"result": "success", "count": 10} + return (content, data) + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("process", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Processing complete" + assert result.structuredContent == {"result": "success", "count": 10} + + +@pytest.mark.anyio +async def test_content_only_with_output_schema_error(): + """Test error when outputSchema is defined but only content is returned.""" + tools = [ + Tool( + name="structured_tool", + description="Tool expecting structured output", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "result": {"type": "string"}, + }, + "required": ["result"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]: + # This returns only content, but outputSchema expects structured data + return [TextContent(type="text", text="This is not structured")] + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("structured_tool", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Output validation error: outputSchema defined but no structured output returned" in result.content[0].text + + +@pytest.mark.anyio +async def test_valid_dict_with_output_schema(): + """Test valid dict output matching outputSchema.""" + tools = [ + Tool( + name="calc", + description="Calculate result", + inputSchema={ + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], + }, + outputSchema={ + "type": "object", + "properties": { + "sum": {"type": "number"}, + "product": {"type": "number"}, + }, + "required": ["sum", "product"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "calc": + x = arguments["x"] + y = arguments["y"] + return {"sum": x + y, "product": x * y} + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("calc", {"x": 3, "y": 4}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + # Check JSON serialization + assert json.loads(result.content[0].text) == {"sum": 7, "product": 12} + assert result.structuredContent == {"sum": 7, "product": 12} + + +@pytest.mark.anyio +async def test_invalid_dict_with_output_schema(): + """Test dict output that doesn't match outputSchema.""" + tools = [ + Tool( + name="user_info", + description="Get user information", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name", "age"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "user_info": + # Missing required 'age' field + return {"name": "Alice"} + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("user_info", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert isinstance(result.content[0], TextContent) + assert "Output validation error:" in result.content[0].text + assert "'age' is a required property" in result.content[0].text + + +@pytest.mark.anyio +async def test_both_content_and_valid_dict_with_output_schema(): + """Test returning both content and valid dict with outputSchema.""" + tools = [ + Tool( + name="analyze", + description="Analyze data", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string"}, + }, + "required": ["text"], + }, + outputSchema={ + "type": "object", + "properties": { + "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, + "required": ["sentiment", "confidence"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> tuple[list[TextContent], dict[str, Any]]: + if name == "analyze": + content = [TextContent(type="text", text=f"Analysis of: {arguments['text']}")] + data = {"sentiment": "positive", "confidence": 0.95} + return (content, data) + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("analyze", {"text": "Great job!"}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify results + assert result is not None + assert not result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "Analysis of: Great job!" + assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95} + + +@pytest.mark.anyio +async def test_output_schema_type_validation(): + """Test outputSchema validates types correctly.""" + tools = [ + Tool( + name="stats", + description="Get statistics", + inputSchema={ + "type": "object", + "properties": {}, + }, + outputSchema={ + "type": "object", + "properties": { + "count": {"type": "integer"}, + "average": {"type": "number"}, + "items": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["count", "average", "items"], + }, + ) + ] + + async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if name == "stats": + # Wrong type for 'count' - should be integer + return {"count": "five", "average": 2.5, "items": ["a", "b"]} + else: + raise ValueError(f"Unknown tool: {name}") + + async def test_callback(client_session: ClientSession) -> CallToolResult: + return await client_session.call_tool("stats", {}) + + result = await run_tool_test(tools, call_tool_handler, test_callback) + + # Verify error + assert result is not None + assert result.isError + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert "Output validation error:" in result.content[0].text + assert "'five' is not of type 'integer'" in result.content[0].text From 3c30ba456c9ca2666a070d698b71c94222d36d4a Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Mon, 23 Jun 2025 15:55:23 -0400 Subject: [PATCH 3/5] update README.md and add example --- README.md | 63 +++++ .../servers/structured_output_lowlevel.py | 244 ++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 examples/servers/structured_output_lowlevel.py diff --git a/README.md b/README.md index 8a009108b..dd4988012 100644 --- a/README.md +++ b/README.md @@ -829,6 +829,69 @@ if __name__ == "__main__": Caution: The `mcp run` and `mcp dev` tool doesn't support low-level server. +#### Structured Output Support + +The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: + +```python +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="calculate", + description="Perform mathematical calculations", + inputSchema={ + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Math expression"} + }, + "required": ["expression"], + }, + outputSchema={ + "type": "object", + "properties": { + "result": {"type": "number"}, + "expression": {"type": "string"}, + }, + "required": ["result", "expression"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> tuple[list[types.TextContent], dict]: + if name == "calculate": + expression = arguments["expression"] + try: + result = eval(expression) # Note: Use a safe math parser in production + + # Return both human-readable content and structured data + content = [ + types.TextContent( + type="text", text=f"The result of {expression} is {result}" + ) + ] + structured = {"result": result, "expression": expression} + + return (content, structured) + except Exception as e: + raise ValueError(f"Calculation error: {str(e)}") +``` + +Tools can return data in three ways: +1. **Content only**: Return a list of content blocks (default behavior) +2. **Structured data only**: Return a dictionary that will be serialized to JSON +3. **Both**: Return a tuple of (content, structured_data) + +When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. + ### Writing MCP Clients The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): diff --git a/examples/servers/structured_output_lowlevel.py b/examples/servers/structured_output_lowlevel.py new file mode 100644 index 000000000..1a17449ac --- /dev/null +++ b/examples/servers/structured_output_lowlevel.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Example low-level MCP server demonstrating structured output support. + +This example shows how to use the low-level server API to return both +human-readable content and machine-readable structured data from tools, +with automatic validation against output schemas. + +The low-level API provides direct control over request handling and +allows tools to return different types of responses: +1. Content only (list of content blocks) +2. Structured data only (dict that gets serialized to JSON) +3. Both content and structured data (tuple) +""" + +import asyncio +from datetime import datetime +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create low-level server instance +server = Server("structured-output-lowlevel-example") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with their schemas.""" + return [ + types.Tool( + name="analyze_text", + description="Analyze text and return structured insights", + inputSchema={ + "type": "object", + "properties": {"text": {"type": "string", "description": "Text to analyze"}}, + "required": ["text"], + }, + outputSchema={ + "type": "object", + "properties": { + "word_count": {"type": "integer"}, + "char_count": {"type": "integer"}, + "sentence_count": {"type": "integer"}, + "most_common_words": { + "type": "array", + "items": { + "type": "object", + "properties": {"word": {"type": "string"}, "count": {"type": "integer"}}, + "required": ["word", "count"], + }, + }, + }, + "required": ["word_count", "char_count", "sentence_count", "most_common_words"], + }, + ), + types.Tool( + name="get_weather", + description="Get weather information (simulated)", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"}, + "humidity": {"type": "integer", "minimum": 0, "maximum": 100}, + "wind_speed": {"type": "number"}, + "timestamp": {"type": "string", "format": "date-time"}, + }, + "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], + }, + ), + types.Tool( + name="calculate_statistics", + description="Calculate statistics for a list of numbers", + inputSchema={ + "type": "object", + "properties": { + "numbers": { + "type": "array", + "items": {"type": "number"}, + "description": "List of numbers to analyze", + } + }, + "required": ["numbers"], + }, + outputSchema={ + "type": "object", + "properties": { + "mean": {"type": "number"}, + "median": {"type": "number"}, + "min": {"type": "number"}, + "max": {"type": "number"}, + "sum": {"type": "number"}, + "count": {"type": "integer"}, + }, + "required": ["mean", "median", "min", "max", "sum", "count"], + }, + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> Any: + """ + Handle tool calls with structured output. + + This low-level handler demonstrates the three ways to return data: + 1. Return a list of content blocks (traditional approach) + 2. Return a dict (gets serialized to JSON and included as structuredContent) + 3. Return a tuple of (content, structured_data) for both + """ + + if name == "analyze_text": + text = arguments["text"] + + # Analyze the text + words = text.split() + word_count = len(words) + char_count = len(text) + sentences = text.replace("?", ".").replace("!", ".").split(".") + sentence_count = len([s for s in sentences if s.strip()]) + + # Count word frequencies + word_freq = {} + for word in words: + word_lower = word.lower().strip('.,!?;:"') + if word_lower: + word_freq[word_lower] = word_freq.get(word_lower, 0) + 1 + + # Get top 5 most common words + most_common = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5] + most_common_words = [{"word": word, "count": count} for word, count in most_common] + + # Example 3: Return both content and structured data + # The low-level server will validate the structured data against outputSchema + content = [ + types.TextContent( + type="text", + text=f"Text analysis complete:\n" + f"- {word_count} words\n" + f"- {char_count} characters\n" + f"- {sentence_count} sentences\n" + f"- Most common words: {', '.join(w['word'] for w in most_common_words)}", + ) + ] + + structured = { + "word_count": word_count, + "char_count": char_count, + "sentence_count": sentence_count, + "most_common_words": most_common_words, + } + + return (content, structured) + + elif name == "get_weather": + # city = arguments["city"] # Would be used with real weather API + + # Simulate weather data (in production, call a real weather API) + import random + + weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] + + weather_data = { + "temperature": round(random.uniform(0, 35), 1), + "conditions": random.choice(weather_conditions), + "humidity": random.randint(30, 90), + "wind_speed": round(random.uniform(0, 30), 1), + "timestamp": datetime.now().isoformat(), + } + + # Example 2: Return structured data only + # The low-level server will serialize this to JSON content automatically + return weather_data + + elif name == "calculate_statistics": + numbers = arguments["numbers"] + + if not numbers: + raise ValueError("Cannot calculate statistics for empty list") + + sorted_nums = sorted(numbers) + count = len(numbers) + + # Calculate statistics + mean = sum(numbers) / count + + if count % 2 == 0: + median = (sorted_nums[count // 2 - 1] + sorted_nums[count // 2]) / 2 + else: + median = sorted_nums[count // 2] + + stats = { + "mean": mean, + "median": median, + "min": sorted_nums[0], + "max": sorted_nums[-1], + "sum": sum(numbers), + "count": count, + } + + # Example 3: Return both content and structured data + content = [ + types.TextContent( + type="text", + text=f"Statistics for {count} numbers:\n" + f"Mean: {stats['mean']:.2f}, Median: {stats['median']:.2f}\n" + f"Range: {stats['min']} to {stats['max']}\n" + f"Sum: {stats['sum']}", + ) + ] + + return (content, stats) + + else: + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the low-level server using stdio transport.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="structured-output-lowlevel-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) From e69fd7830db31d2446c5cb4ec49af0d6d7dab6d9 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 24 Jun 2025 13:23:18 -0400 Subject: [PATCH 4/5] address comments --- README.md | 24 ++- .../servers/structured_output_lowlevel.py | 158 +----------------- src/mcp/server/lowlevel/server.py | 52 +++--- 3 files changed, 47 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index dd4988012..6f2bbc8b5 100644 --- a/README.md +++ b/README.md @@ -834,6 +834,8 @@ Caution: The `mcp run` and `mcp dev` tool doesn't support low-level server. The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: ```python +from types import Any + import mcp.types as types from mcp.server.lowlevel import Server @@ -866,29 +868,25 @@ async def list_tools() -> list[types.Tool]: @server.call_tool() -async def call_tool(name: str, arguments: dict) -> tuple[list[types.TextContent], dict]: +async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: if name == "calculate": expression = arguments["expression"] try: - result = eval(expression) # Note: Use a safe math parser in production - - # Return both human-readable content and structured data - content = [ - types.TextContent( - type="text", text=f"The result of {expression} is {result}" - ) - ] + result = eval(expression) # Use a safe math parser structured = {"result": result, "expression": expression} - return (content, structured) + # low-level server will validate structured output against the tool's + # output schema, and automatically serialize it into a TextContent block + # for backwards compatibility with pre-2025-06-18 clients. + return structured except Exception as e: raise ValueError(f"Calculation error: {str(e)}") ``` Tools can return data in three ways: -1. **Content only**: Return a list of content blocks (default behavior) -2. **Structured data only**: Return a dictionary that will be serialized to JSON -3. **Both**: Return a tuple of (content, structured_data) +1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) +2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) +3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. diff --git a/examples/servers/structured_output_lowlevel.py b/examples/servers/structured_output_lowlevel.py index 1a17449ac..7f102ff8b 100644 --- a/examples/servers/structured_output_lowlevel.py +++ b/examples/servers/structured_output_lowlevel.py @@ -2,15 +2,9 @@ """ Example low-level MCP server demonstrating structured output support. -This example shows how to use the low-level server API to return both -human-readable content and machine-readable structured data from tools, -with automatic validation against output schemas. - -The low-level API provides direct control over request handling and -allows tools to return different types of responses: -1. Content only (list of content blocks) -2. Structured data only (dict that gets serialized to JSON) -3. Both content and structured data (tuple) +This example shows how to use the low-level server API to return +structured data from tools, with automatic validation against output +schemas. """ import asyncio @@ -30,32 +24,6 @@ async def list_tools() -> list[types.Tool]: """List available tools with their schemas.""" return [ - types.Tool( - name="analyze_text", - description="Analyze text and return structured insights", - inputSchema={ - "type": "object", - "properties": {"text": {"type": "string", "description": "Text to analyze"}}, - "required": ["text"], - }, - outputSchema={ - "type": "object", - "properties": { - "word_count": {"type": "integer"}, - "char_count": {"type": "integer"}, - "sentence_count": {"type": "integer"}, - "most_common_words": { - "type": "array", - "items": { - "type": "object", - "properties": {"word": {"type": "string"}, "count": {"type": "integer"}}, - "required": ["word", "count"], - }, - }, - }, - "required": ["word_count", "char_count", "sentence_count", "most_common_words"], - }, - ), types.Tool( name="get_weather", description="Get weather information (simulated)", @@ -76,91 +44,16 @@ async def list_tools() -> list[types.Tool]: "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], }, ), - types.Tool( - name="calculate_statistics", - description="Calculate statistics for a list of numbers", - inputSchema={ - "type": "object", - "properties": { - "numbers": { - "type": "array", - "items": {"type": "number"}, - "description": "List of numbers to analyze", - } - }, - "required": ["numbers"], - }, - outputSchema={ - "type": "object", - "properties": { - "mean": {"type": "number"}, - "median": {"type": "number"}, - "min": {"type": "number"}, - "max": {"type": "number"}, - "sum": {"type": "number"}, - "count": {"type": "integer"}, - }, - "required": ["mean", "median", "min", "max", "sum", "count"], - }, - ), ] @server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> Any: """ - Handle tool calls with structured output. - - This low-level handler demonstrates the three ways to return data: - 1. Return a list of content blocks (traditional approach) - 2. Return a dict (gets serialized to JSON and included as structuredContent) - 3. Return a tuple of (content, structured_data) for both + Handle tool call with structured output. """ - if name == "analyze_text": - text = arguments["text"] - - # Analyze the text - words = text.split() - word_count = len(words) - char_count = len(text) - sentences = text.replace("?", ".").replace("!", ".").split(".") - sentence_count = len([s for s in sentences if s.strip()]) - - # Count word frequencies - word_freq = {} - for word in words: - word_lower = word.lower().strip('.,!?;:"') - if word_lower: - word_freq[word_lower] = word_freq.get(word_lower, 0) + 1 - - # Get top 5 most common words - most_common = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:5] - most_common_words = [{"word": word, "count": count} for word, count in most_common] - - # Example 3: Return both content and structured data - # The low-level server will validate the structured data against outputSchema - content = [ - types.TextContent( - type="text", - text=f"Text analysis complete:\n" - f"- {word_count} words\n" - f"- {char_count} characters\n" - f"- {sentence_count} sentences\n" - f"- Most common words: {', '.join(w['word'] for w in most_common_words)}", - ) - ] - - structured = { - "word_count": word_count, - "char_count": char_count, - "sentence_count": sentence_count, - "most_common_words": most_common_words, - } - - return (content, structured) - - elif name == "get_weather": + if name == "get_weather": # city = arguments["city"] # Would be used with real weather API # Simulate weather data (in production, call a real weather API) @@ -176,49 +69,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "timestamp": datetime.now().isoformat(), } - # Example 2: Return structured data only + # Return structured data only # The low-level server will serialize this to JSON content automatically return weather_data - elif name == "calculate_statistics": - numbers = arguments["numbers"] - - if not numbers: - raise ValueError("Cannot calculate statistics for empty list") - - sorted_nums = sorted(numbers) - count = len(numbers) - - # Calculate statistics - mean = sum(numbers) / count - - if count % 2 == 0: - median = (sorted_nums[count // 2 - 1] + sorted_nums[count // 2]) / 2 - else: - median = sorted_nums[count // 2] - - stats = { - "mean": mean, - "median": median, - "min": sorted_nums[0], - "max": sorted_nums[-1], - "sum": sum(numbers), - "count": count, - } - - # Example 3: Return both content and structured data - content = [ - types.TextContent( - type="text", - text=f"Statistics for {count} numbers:\n" - f"Mean: {stats['mean']:.2f}, Median: {stats['median']:.2f}\n" - f"Range: {stats['min']} to {stats['max']}\n" - f"Sum: {stats['sum']}", - ) - ] - - return (content, stats) - else: raise ValueError(f"Unknown tool: {name}") diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index e26323f74..81e55f54d 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -73,7 +73,7 @@ async def main(): import warnings from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, Generic, cast +from typing import Any, Generic, TypeAlias, cast import anyio import jsonschema @@ -96,6 +96,11 @@ async def main(): LifespanResultT = TypeVar("LifespanResultT") RequestT = TypeVar("RequestT", default=Any) +# type aliases for tool call results +StructuredContent: TypeAlias = dict[str, Any] +UnstructuredContent: TypeAlias = Iterable[types.ContentBlock] +CombinationContent: TypeAlias = tuple[UnstructuredContent, StructuredContent] + # This will be properly typed in each Server instance's context request_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar("request_ctx") @@ -415,10 +420,11 @@ async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None def call_tool(self): """Register a tool call handler. - The handler validates input against inputSchema, calls the tool function, and processes results: - - Content only: returns as-is - - Dict only: serializes to JSON text and returns as content with structuredContent - - Both: returns content and structuredContent + The handler validates input against inputSchema, calls the tool function, + and builds a CallToolResult with the results: + - Unstructured content (iterable of ContentBlock): returned in content + - Structured content (dict): returned in structuredContent, serialized JSON text returned in content + - Both: returned in content and structuredContent If outputSchema is defined, validates structuredContent or errors if missing. """ @@ -426,9 +432,7 @@ def call_tool(self): def decorator( func: Callable[ ..., - Awaitable[ - Iterable[types.ContentBlock] | dict[str, Any] | tuple[Iterable[types.ContentBlock], dict[str, Any]] - ], + Awaitable[UnstructuredContent | StructuredContent | CombinationContent], ], ): logger.debug("Registering handler for CallToolRequest") @@ -450,37 +454,41 @@ async def handler(req: types.CallToolRequest): results = await func(tool_name, arguments) # output normalization - content: list[types.ContentBlock] - structured_content: dict[str, Any] | None - + unstructured_content: UnstructuredContent + maybe_structured_content: StructuredContent | None if isinstance(results, tuple) and len(results) == 2: - # tool returned both content and structured content - structured_content = cast(dict[str, Any], results[1]) - content = list(cast(Iterable[types.ContentBlock], results[0])) + # tool returned both structured and unstructured content + unstructured_content, maybe_structured_content = cast(CombinationContent, results) elif isinstance(results, dict): # tool returned structured content only - structured_content = cast(dict[str, Any], results) - content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] + maybe_structured_content = cast(StructuredContent, results) + unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))] + elif hasattr(results, "__iter__"): + # tool returned unstructured content only + unstructured_content = cast(UnstructuredContent, results) + maybe_structured_content = None else: - # tool returned content only - structured_content = None - content = list(cast(Iterable[types.ContentBlock], results)) + return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}") # output validation if tool and tool.outputSchema is not None: - if structured_content is None: + if maybe_structured_content is None: return self._make_error_result( "Output validation error: outputSchema defined but no structured output returned" ) else: try: - jsonschema.validate(instance=structured_content, schema=tool.outputSchema) + jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema) except jsonschema.ValidationError as e: return self._make_error_result(f"Output validation error: {e.message}") # result return types.ServerResult( - types.CallToolResult(content=content, structuredContent=structured_content, isError=False) + types.CallToolResult( + content=list(unstructured_content), + structuredContent=maybe_structured_content, + isError=False, + ) ) except Exception as e: return self._make_error_result(str(e)) From 193b28fa344092d17f56f17e3e333f62af3be0f5 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Tue, 24 Jun 2025 13:38:20 -0400 Subject: [PATCH 5/5] add validate_input flag to @server.call_tool --- src/mcp/server/lowlevel/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 81e55f54d..faad95aca 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -417,10 +417,13 @@ async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None return tool - def call_tool(self): + def call_tool(self, *, validate_input: bool = True): """Register a tool call handler. - The handler validates input against inputSchema, calls the tool function, + Args: + validate_input: If True, validates input against inputSchema. Default is True. + + The handler validates input against inputSchema (if validate_input=True), calls the tool function, and builds a CallToolResult with the results: - Unstructured content (iterable of ContentBlock): returned in content - Structured content (dict): returned in structuredContent, serialized JSON text returned in content @@ -444,7 +447,7 @@ async def handler(req: types.CallToolRequest): tool = await self._get_cached_tool_definition(tool_name) # input validation - if tool: + if validate_input and tool: try: jsonschema.validate(instance=arguments, schema=tool.inputSchema) except jsonschema.ValidationError as e: