diff --git a/docs/client/index.md b/docs/client/index.md index 9433e703b0..a29631a321 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -138,12 +138,11 @@ A tool that raises does **not** raise in your client. It comes back as an ordina some other exception, `content` would say only `Error executing tool lookup_book`.) Always look at `is_error` before you trust `structured_content`. -!!! warning - `is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have - (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, - `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises - `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and - **[Handling errors](../servers/handling-errors.md)** covers when a server produces which. +!!! warning "Unknown tools raise protocol errors" + Ask for a tool the server does not have and `Client.call_tool()` raises `MCPError` with + code `-32602` and `Unknown tool: ` as its message. Argument-validation failures and + tool execution failures return `is_error=True` instead. + **[Handling errors](../servers/handling-errors.md)** covers both paths. ## Resources diff --git a/docs/handlers/context.md b/docs/handlers/context.md index f43521aa05..0520a6acb0 100644 --- a/docs/handlers/context.md +++ b/docs/handlers/context.md @@ -108,10 +108,10 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs !!! check Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it - anyway and the result is an error the model can read: + anyway and `Client.call_tool()` raises `MCPError` with code `-32602`: ```text - Unknown tool: recommend_book + mcp.shared.exceptions.MCPError: Unknown tool: recommend_book ``` Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md index e1f6fffba6..1a0dd3e50d 100644 --- a/docs/servers/handling-errors.md +++ b/docs/servers/handling-errors.md @@ -60,6 +60,11 @@ Now swap `ToolError` for `MCPError`. * The **host** application gets the error instead, the same way it would if the tool didn't exist at all. * `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp.types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number. +A request for an unknown tool name also raises `MCPError` with code `-32602`. Direct +`MCPServer.call_tool()` calls raise `ToolNotFoundError`, a subclass of `ToolError`, for a +missing name. If an existing tool calls a missing tool, that failure remains an execution +error with `is_error=True`. + !!! check Same lookup, same miss, but now the call *raises* on the client side instead of returning: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e23b5b16e1..f0a49740d1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -82,7 +82,7 @@ async def main() -> None: `__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern. -## `Error executing tool : `, `Error executing tool `, and `Unknown tool: ` +## `Error executing tool : ` and `Error executing tool ` You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool. @@ -94,12 +94,38 @@ result.content # [TextContent(text="Error executing tool forecast: No forecast result.structured_content # None ``` -`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs. +A bad argument is rejected the same way, against the tool's input schema, before your function ever runs. The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise. The bare form, `Error executing tool ` with no message, means the tool **crashed**: an exception it didn't anticipate escaped it (or its return value failed the output schema), and that exception's text is kept off the wire. The traceback is in the **server's log** at `ERROR`, as `Tool '' raised an unexpected exception`. +## `Unknown tool: ` + +```python +import anyio +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp_types import INVALID_PARAMS + + +async def main() -> None: + server = MCPServer("example") + async with Client(server) as client: + try: + await client.call_tool("get_forecast", {"city": "London"}) + except MCPError as error: + assert error.code == INVALID_PARAMS + assert error.message == "Unknown tool: get_forecast" + + +anyio.run(main) +``` + +The requested tool does not exist. The server returns a JSON-RPC error with code `-32602`, +and `Client.call_tool()` raises `MCPError`. Refresh the tool list and check the name you sent. +An existing tool that calls a missing tool still returns an execution error with `is_error=True`. + ## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter. @@ -426,7 +452,7 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key ## Recap * `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely. -* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. No message after the tool name means it crashed, and the traceback is in the server log. +* `Error executing tool ...` is a result: check `result.is_error`. `Unknown tool: ...` for the requested name raises `MCPError` with code `-32602`. A bare execution-error message means the tool crashed; its traceback is in the server log. * `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses. * `has an invalid x-mcp-header annotation` -> only `str`, `int` and `bool` arguments can be marked. * `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one. diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 5e831b1238..da2db8cbf8 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -55,9 +55,26 @@ class ToolError(MCPServerError): The SDK raises it too, for an unknown tool name and for arguments that fail the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError` around `MCPServer.call_tool()` catches every tool failure, crash or not. + For a missing name, the SDK raises `ToolNotFoundError`; the request boundary + translates that lookup miss to a JSON-RPC `INVALID_PARAMS` error. """ +class ToolNotFoundError(ToolError): + """The requested tool does not exist. + + Direct calls remain catchable as `ToolError`. At the request boundary, a + missing requested tool becomes a JSON-RPC `INVALID_PARAMS` error. + """ + + def __init__(self, name: str) -> None: + self.name = name + super().__init__(f"Unknown tool: {name}") + + def __reduce__(self) -> tuple[type["ToolNotFoundError"], tuple[str], dict[str, object]]: + return type(self), (self.name,), self.__dict__ + + class UnexpectedToolError(ToolError): """A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 7efa29108d..5fc9e7a54f 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -75,6 +75,7 @@ ResourceError, ResourceNotFoundError, ToolError, + ToolNotFoundError, UnexpectedResourceError, UnexpectedToolError, ) @@ -443,6 +444,8 @@ async def _handle_call_tool( except MCPError: raise except Exception as exc: + if isinstance(exc, ToolNotFoundError) and exc.name == params.name: + raise MCPError(code=INVALID_PARAMS, message=str(exc)) from exc if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError): if isinstance(exc.__cause__, ValidationError): # Field names only: the rejected values are the caller's data. @@ -536,7 +539,8 @@ async def call_tool( """Call a tool by name with arguments. Raises: - ToolError: If the tool is unknown, the arguments fail validation, or the + ToolNotFoundError: If the tool is unknown. This is a subclass of `ToolError`. + ToolError: If the arguments fail validation, or the tool (or a resolver) raises `ToolError` or `ResourceError`. UnexpectedToolError: If the tool (or a resolver) raises anything else, or its return value fails output conversion. `__cause__` is the original diff --git a/src/mcp/server/mcpserver/tools/tool_manager.py b/src/mcp/server/mcpserver/tools/tool_manager.py index 9e7910ea93..4b2cf4d0d8 100644 --- a/src/mcp/server/mcpserver/tools/tool_manager.py +++ b/src/mcp/server/mcpserver/tools/tool_manager.py @@ -5,7 +5,7 @@ from mcp_types import Icon, ToolAnnotations -from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.exceptions import ToolError, ToolNotFoundError from mcp.server.mcpserver.tools.base import Tool from mcp.server.mcpserver.utilities.logging import get_logger @@ -82,6 +82,6 @@ async def call_tool( """Call a tool by name with arguments.""" tool = self.get_tool(name) if not tool: - raise ToolError(f"Unknown tool: {name}") + raise ToolNotFoundError(name) return await tool.run(arguments, context, convert_result=convert_result) diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index 7121e98d02..b3ad1d7dbd 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -6,7 +6,7 @@ import pytest from inline_snapshot import snapshot -from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool +from mcp_types import INVALID_PARAMS, Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool from docs_src.client import tutorial001, tutorial007 from mcp import Client, MCPDeprecationWarning, MCPError @@ -94,15 +94,13 @@ async def test_a_raising_tool_is_a_result_not_an_exception() -> None: assert result.structured_content is None -async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: - """The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError.""" +async def test_an_unknown_tool_name_raises_invalid_params() -> None: + """The client receives a protocol error for a tool the server does not have.""" async with Client(tutorial001.mcp) as client: - result = await client.call_tool("does_not_exist", {}) - assert result.is_error - (block,) = result.content - assert isinstance(block, TextContent) - assert block.text == "Unknown tool: does_not_exist" - assert result.structured_content is None + with pytest.raises(MCPError) as exc_info: + await client.call_tool("does_not_exist", {}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown tool: does_not_exist") async def test_resources_and_templates_are_two_separate_lists() -> None: diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py index 617d113b2b..38ffc93db5 100644 --- a/tests/docs_src/test_context.py +++ b/tests/docs_src/test_context.py @@ -4,10 +4,10 @@ import pytest from inline_snapshot import snapshot -from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification +from mcp_types import INVALID_PARAMS, TextContent, TextResourceContents, ToolListChangedNotification from docs_src.context import tutorial001, tutorial002, tutorial003 -from mcp import Client +from mcp import Client, MCPError # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -70,9 +70,10 @@ async def collect(message: object) -> None: async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client: assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"] - missing = await client.call_tool("recommend_book", {"genre": "fiction"}) - assert missing.is_error - assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")] + with pytest.raises(MCPError) as exc_info: + await client.call_tool("recommend_book", {"genre": "fiction"}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown tool: recommend_book") enabled = await client.call_tool("enable_recommendations", {}) assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")] diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py index e39a78050e..6fe386d879 100644 --- a/tests/docs_src/test_troubleshooting.py +++ b/tests/docs_src/test_troubleshooting.py @@ -108,12 +108,13 @@ def forecast(city: str) -> str: assert record.exc_info is not None -async def test_an_unknown_tool_is_the_same_kind_of_result() -> None: - """`Unknown tool: ` travels the same `is_error=True` path as a failing tool.""" +async def test_an_unknown_tool_is_a_protocol_error() -> None: + """`Unknown tool: ` is an invalid params error, rather than an execution failure.""" async with Client(tutorial001.mcp) as client: - result = await client.call_tool("get_forecast", {"city": "London"}) - assert result.is_error - assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")] + with pytest.raises(MCPError) as exc_info: + await client.call_tool("get_forecast", {"city": "London"}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown tool: get_forecast") async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None: diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 1d9033b307..d8de030a84 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1068,13 +1068,6 @@ def __post_init__(self) -> None: "mcpserver:tool:unknown-name": Requirement( source=f"{SPEC_BASE_URL}/server/tools#error-handling", behavior="tools/call for a name that was never registered returns a JSON-RPC error.", - divergence=Divergence( - note=( - "The spec classifies unknown tools as a protocol error (its example uses -32602 Invalid " - "params); MCPServer reports a tool execution error (isError true) instead. The low-level " - "path follows the spec example (see tools:call:unknown-name)." - ), - ), ), "mcpserver:tool:url-elicitation-error": Requirement( source="sdk", diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index dcf239d407..0a4641a552 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -6,6 +6,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + INVALID_PARAMS, URL_ELICITATION_REQUIRED, CallToolResult, ElicitRequestURLParams, @@ -120,24 +121,21 @@ def flux() -> str: @requirement("mcpserver:tool:unknown-name") -async def test_call_tool_unknown_name_returns_error_result(connect: Connect, unstamped: Unstamp) -> None: - """Calling a tool name that was never registered is reported as an is_error result. - - The spec classifies unknown tools as a protocol error; see the divergence note on the - requirement. - """ +async def test_call_tool_unknown_name_raises_invalid_params(connect: Connect) -> None: + """The spec classifies an unknown tool name as a JSON-RPC invalid params error.""" mcp = MCPServer("errors") @mcp.tool() def add() -> None: """A registered tool; the test calls a different name.""" + raise NotImplementedError async with connect(mcp) as client: - result = await client.call_tool("nope", {}) + with pytest.raises(MCPError) as exc_info: + await client.call_tool("nope", {}) - assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True) - ) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.error == snapshot(ErrorData(code=-32602, message="Unknown tool: nope")) @requirement("mcpserver:tool:output-schema:model") diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index a30e4defba..0a26c66cee 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,11 +1,13 @@ import base64 import logging +import pickle from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from copy import copy, deepcopy from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace -from typing import Annotated, Any, NotRequired +from typing import Annotated, Any, Literal, NotRequired from unittest.mock import AsyncMock, MagicMock, patch import anyio @@ -60,6 +62,7 @@ ResourceError, ResourceNotFoundError, ToolError, + ToolNotFoundError, UnexpectedResourceError, UnexpectedToolError, ) @@ -78,6 +81,7 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import InvalidUriTemplate +from tests._stamp import unstamped pytestmark = pytest.mark.anyio @@ -287,13 +291,14 @@ async def test_list_tools(self): tools = await client.list_tools() assert len(tools.tools) == 1 - async def test_call_tool(self): + async def test_call_tool(self) -> None: mcp = MCPServer() mcp.add_tool(tool_fn) async with Client(mcp) as client: - result = await client.call_tool("my_tool", {"arg1": "value"}) - assert not hasattr(result, "error") - assert len(result.content) > 0 + result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert unstamped(result) == snapshot( + CallToolResult(content=[TextContent(text="3")], structured_content={"result": 3}) + ) async def test_tool_exception_handling(self): mcp = MCPServer() @@ -723,11 +728,10 @@ async def test_remove_tool_and_call(self): # Verify calling removed tool returns an error async with Client(mcp) as client: - result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert result.is_error - content = result.content[0] - assert isinstance(content, TextContent) - assert "Unknown tool" in content.text + with pytest.raises(MCPError) as exc_info: + await client.call_tool("tool_fn", {"x": 1, "y": 2}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown tool: tool_fn") async def test_typeddict_tool_omitting_optional_keys_passes_client_validation(): @@ -2643,17 +2647,18 @@ def get_weather() -> Weather: assert _server_records(caplog) == snapshot([("ERROR", "Tool 'get_weather' raised an unexpected exception", True)]) -async def test_unknown_tool_is_logged_at_info_without_traceback(caplog: pytest.LogCaptureFixture): - """SDK-defined: a call to a name that was never registered is the caller's mistake, logged - as one INFO record alongside the is_error result.""" +async def test_unknown_tool_raises_invalid_params_without_logging(caplog: pytest.LogCaptureFixture) -> None: + """An unknown tool is a protocol error, so MCPServer does not log it as an execution failure.""" mcp = MCPServer() caplog.set_level(logging.INFO) async with Client(mcp) as client: - result = await client.call_tool("nope", {}) + with pytest.raises(MCPError) as exc_info: + await client.call_tool("nope", {}) - assert result.is_error is True - assert _server_records(caplog) == snapshot([("INFO", "Tool 'nope' failed: 'Unknown tool: nope'", False)]) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown tool: nope") + assert _server_records(caplog) == [] assert not [r for r in caplog.records if r.levelno >= logging.WARNING] @@ -3322,3 +3327,112 @@ async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) -> pass # pragma: no cover - the refusal precedes the stream assert exc_info.value.error.code == INVALID_REQUEST assert exc_info.value.error.message == "not permitted to watch the requested resources" + + +async def test_missing_tool_direct_call_remains_a_tool_error() -> None: + """Direct lookup failures remain catchable as `ToolError` and carry the missing name.""" + server = MCPServer() + name = "missing" + + with pytest.raises(ToolError) as exc_info: + await server.call_tool(name, {}) + + assert isinstance(exc_info.value, ToolNotFoundError) + assert exc_info.value.name == name + assert str(exc_info.value) == snapshot("Unknown tool: missing") + + +async def test_missing_tool_error_survives_copying_and_pickling() -> None: + """SDK-defined: reconstructing a lookup error preserves its name, message, and exception notes.""" + server = MCPServer() + with pytest.raises(ToolNotFoundError) as exc_info: + await server.call_tool("missing", {}) + original = exc_info.value + original.add_note("Requested by a background worker") + + for cloned in (copy(original), deepcopy(original), pickle.loads(pickle.dumps(original))): + assert isinstance(cloned, ToolNotFoundError) + assert cloned is not original + assert cloned.name == original.name + assert cloned.args == original.args + assert str(cloned) == str(original) + assert cloned.__notes__ == original.__notes__ + + +@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) +async def test_nested_missing_tool_returns_an_execution_error(mode: Literal["legacy", "2026-07-28"]) -> None: + """A missing tool called while executing an existing tool remains an execution failure.""" + server = MCPServer() + + @server.tool() + async def outer(ctx: Context) -> str: + return str(await ctx.mcp_server.call_tool("missing", {})) + + async with Client(server, mode=mode) as client: + result = await client.call_tool("outer", {}) + + assert (unstamped(result) if mode == "2026-07-28" else result) == snapshot( + CallToolResult(content=[TextContent(text="Error executing tool outer: Unknown tool: missing")], is_error=True) + ) + + +@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) +async def test_call_tool_override_can_serve_an_unregistered_name(mode: Literal["legacy", "2026-07-28"]) -> None: + """An override can serve a dynamic tool without registering it with MCPServer.""" + result = CallToolResult(content=[TextContent(text="dynamic result")]) + + class DynamicServer(MCPServer): + async def call_tool( + self, name: str, arguments: dict[str, Any], context: Context[Any, Any] | None = None + ) -> CallToolResult | InputRequiredResult: + assert name == "dynamic" + return result + + server = DynamicServer() + async with Client(server, mode=mode) as client: + received = await client.call_tool("dynamic", {}) + + assert received.content == result.content + assert not received.is_error + + +@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) +async def test_call_tool_override_tool_error_remains_an_execution_error(mode: Literal["legacy", "2026-07-28"]) -> None: + """A deliberate ToolError remains an execution failure even when its message resembles a lookup miss.""" + + class DynamicServer(MCPServer): + async def call_tool( + self, name: str, arguments: dict[str, Any], context: Context[Any, Any] | None = None + ) -> CallToolResult | InputRequiredResult: + assert name == "dynamic" + raise ToolError("Unknown tool: dynamic") + + server = DynamicServer() + async with Client(server, mode=mode) as client: + result = await client.call_tool("dynamic", {}) + + assert (unstamped(result) if mode == "2026-07-28" else result) == snapshot( + CallToolResult(content=[TextContent(text="Unknown tool: dynamic")], is_error=True) + ) + + +@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"]) +async def test_call_tool_override_missing_delegate_remains_an_execution_error( + mode: Literal["legacy", "2026-07-28"], +) -> None: + """A missing delegated name does not imply that the requested tool is unknown.""" + + class DynamicServer(MCPServer): + async def call_tool( + self, name: str, arguments: dict[str, Any], context: Context[Any, Any] | None = None + ) -> CallToolResult | InputRequiredResult: + assert name == "dynamic" + return await super().call_tool("missing", arguments, context) + + server = DynamicServer() + async with Client(server, mode=mode) as client: + result = await client.call_tool("dynamic", {}) + + assert (unstamped(result) if mode == "2026-07-28" else result) == snapshot( + CallToolResult(content=[TextContent(text="Unknown tool: missing")], is_error=True) + ) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 5d473550b9..7cefa41abc 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -14,6 +14,7 @@ import anyio import httpx2 import pytest +from inline_snapshot import snapshot from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -1042,8 +1043,11 @@ def search(region: Annotated[str, Field(json_schema_extra={"x-mcp-header": "Regi assert (mismatched.status_code, mismatched.json()["error"]["code"]) == (400, HEADER_MISMATCH) assert (missing.status_code, missing.json()["error"]["code"]) == (400, HEADER_MISMATCH) # An unregistered tool has no schema to validate against; dispatch owns the unknown-tool answer. - assert unknown.status_code == 200 - assert unknown.json()["result"]["isError"] is True + assert unknown.status_code == 400 + assert unknown.json()["error"]["code"] == INVALID_PARAMS + assert unknown.json() == snapshot( + {"jsonrpc": "2.0", "id": 1, "error": {"code": -32602, "message": "Unknown tool: unregistered"}} + ) assert dispatched == ["tools/call", "tools/call"]