|
| 1 | +"""`ClientSession.register_tool_schema` for tools absent from `list_tools`.""" |
| 2 | + |
| 3 | +import logging |
| 4 | + |
| 5 | +import pytest |
| 6 | +from mcp_types import ( |
| 7 | + CallToolRequestParams, |
| 8 | + CallToolResult, |
| 9 | + ListToolsResult, |
| 10 | + PaginatedRequestParams, |
| 11 | + Tool, |
| 12 | +) |
| 13 | + |
| 14 | +from mcp.client.client import Client |
| 15 | +from mcp.server import Server, ServerRequestContext |
| 16 | + |
| 17 | +_SCORE_SCHEMA: dict[str, object] = { |
| 18 | + "type": "object", |
| 19 | + "properties": {"score": {"type": "integer"}}, |
| 20 | + "required": ["score"], |
| 21 | +} |
| 22 | +_SCORE_AS_STRING_SCHEMA: dict[str, object] = { |
| 23 | + "type": "object", |
| 24 | + "properties": {"score": {"type": "string"}}, |
| 25 | + "required": ["score"], |
| 26 | +} |
| 27 | + |
| 28 | + |
| 29 | +def _dynamic_tool_server(*, structured_content: dict[str, object]) -> Server: |
| 30 | + """`list_tools` advertises only a search meta-tool; `analyze` is callable but unlisted.""" |
| 31 | + |
| 32 | + async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: |
| 33 | + return ListToolsResult(tools=[Tool(name="search", input_schema={"type": "object"})]) |
| 34 | + |
| 35 | + async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: |
| 36 | + assert params.name == "analyze" |
| 37 | + return CallToolResult(content=[], structured_content=structured_content) |
| 38 | + |
| 39 | + return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool) |
| 40 | + |
| 41 | + |
| 42 | +@pytest.mark.anyio |
| 43 | +async def test_register_tool_schema_lets_call_tool_validate_an_unlisted_tool() -> None: |
| 44 | + """SDK-defined: a schema registered for a tool absent from list_tools is used by call_tool.""" |
| 45 | + server = _dynamic_tool_server(structured_content={"score": 1}) |
| 46 | + async with Client(server) as client: |
| 47 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 48 | + result = await client.call_tool("analyze", {}) |
| 49 | + assert result.structured_content == {"score": 1} |
| 50 | + |
| 51 | + |
| 52 | +@pytest.mark.anyio |
| 53 | +async def test_register_tool_schema_makes_call_tool_reject_nonconforming_structured_content() -> None: |
| 54 | + """Without registration, an unlisted tool skips validation; with it, mismatches raise.""" |
| 55 | + server = _dynamic_tool_server(structured_content={"score": "no"}) |
| 56 | + async with Client(server) as client: |
| 57 | + # Unregistered: validation is skipped (tool never appears in list_tools). |
| 58 | + skipped = await client.call_tool("analyze", {}) |
| 59 | + assert skipped.structured_content == {"score": "no"} |
| 60 | + |
| 61 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 62 | + # Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency. |
| 63 | + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"): |
| 64 | + await client.call_tool("analyze", {}) |
| 65 | + |
| 66 | + |
| 67 | +@pytest.mark.anyio |
| 68 | +async def test_register_tool_schema_with_none_suppresses_the_unlisted_warning( |
| 69 | + caplog: pytest.LogCaptureFixture, |
| 70 | +) -> None: |
| 71 | + """SDK-defined: registering None marks the tool known without validating structuredContent.""" |
| 72 | + server = _dynamic_tool_server(structured_content={"anything": True}) |
| 73 | + async with Client(server) as client: |
| 74 | + client.register_tool_schema("analyze", None) |
| 75 | + with caplog.at_level(logging.WARNING, logger="client"): |
| 76 | + result = await client.call_tool("analyze", {}) |
| 77 | + assert result.structured_content == {"anything": True} |
| 78 | + assert "not listed by server" not in caplog.text |
| 79 | + |
| 80 | + |
| 81 | +@pytest.mark.anyio |
| 82 | +async def test_register_tool_schema_evicts_the_compiled_validator_when_the_schema_changes() -> None: |
| 83 | + """SDK-defined: a changed registration must not reuse a validator compiled for the old schema.""" |
| 84 | + server = _dynamic_tool_server(structured_content={"score": 1}) |
| 85 | + async with Client(server) as client: |
| 86 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 87 | + await client.session.validate_tool_result( |
| 88 | + "analyze", CallToolResult(content=[], structured_content={"score": 1}) |
| 89 | + ) |
| 90 | + compiled = client.session._tool_output_validators["analyze"] |
| 91 | + |
| 92 | + client.register_tool_schema("analyze", _SCORE_AS_STRING_SCHEMA) |
| 93 | + assert "analyze" not in client.session._tool_output_validators |
| 94 | + |
| 95 | + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"): |
| 96 | + await client.session.validate_tool_result( |
| 97 | + "analyze", CallToolResult(content=[], structured_content={"score": 1}) |
| 98 | + ) |
| 99 | + assert client.session._tool_output_validators["analyze"] is not compiled |
| 100 | + |
| 101 | + |
| 102 | +@pytest.mark.anyio |
| 103 | +async def test_register_tool_schema_keeps_the_validator_when_the_schema_is_unchanged() -> None: |
| 104 | + """SDK-defined: re-registering an equal schema keeps the compiled validator.""" |
| 105 | + server = _dynamic_tool_server(structured_content={"score": 1}) |
| 106 | + async with Client(server) as client: |
| 107 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 108 | + result = CallToolResult(content=[], structured_content={"score": 1}) |
| 109 | + await client.session.validate_tool_result("analyze", result) |
| 110 | + compiled = client.session._tool_output_validators["analyze"] |
| 111 | + |
| 112 | + client.register_tool_schema("analyze", dict(_SCORE_SCHEMA)) |
| 113 | + await client.session.validate_tool_result("analyze", result) |
| 114 | + assert client.session._tool_output_validators["analyze"] is compiled |
| 115 | + |
| 116 | + |
| 117 | +@pytest.mark.anyio |
| 118 | +async def test_a_complete_list_tools_prunes_a_manually_registered_schema() -> None: |
| 119 | + """SDK-defined: a complete listing is still the full tool universe for prune — a registered |
| 120 | + tool omitted from that listing is dropped, same as listing-absorbed schemas.""" |
| 121 | + server = _dynamic_tool_server(structured_content={"score": 1}) |
| 122 | + async with Client(server) as client: |
| 123 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 124 | + assert "analyze" in client.session._tool_output_schemas |
| 125 | + |
| 126 | + await client.session.list_tools() |
| 127 | + assert "analyze" not in client.session._tool_output_schemas |
| 128 | + assert set(client.session._tool_output_schemas) == {"search"} |
| 129 | + |
| 130 | + |
| 131 | +@pytest.mark.anyio |
| 132 | +async def test_list_tools_that_includes_a_registered_name_replaces_the_registered_schema() -> None: |
| 133 | + """SDK-defined: when the same name later appears in a listing, the listed schema wins.""" |
| 134 | + |
| 135 | + async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: |
| 136 | + return ListToolsResult( |
| 137 | + tools=[ |
| 138 | + Tool(name="analyze", input_schema={"type": "object"}, output_schema=_SCORE_AS_STRING_SCHEMA), |
| 139 | + ] |
| 140 | + ) |
| 141 | + |
| 142 | + async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: |
| 143 | + raise NotImplementedError |
| 144 | + |
| 145 | + server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool) |
| 146 | + async with Client(server) as client: |
| 147 | + client.register_tool_schema("analyze", _SCORE_SCHEMA) |
| 148 | + await client.session.validate_tool_result( |
| 149 | + "analyze", CallToolResult(content=[], structured_content={"score": 1}) |
| 150 | + ) |
| 151 | + |
| 152 | + await client.session.list_tools() |
| 153 | + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"): |
| 154 | + await client.session.validate_tool_result( |
| 155 | + "analyze", CallToolResult(content=[], structured_content={"score": 1}) |
| 156 | + ) |
0 commit comments