Skip to content

Commit bdf3cef

Browse files
committed
Return invalid params for unknown requested tools
1 parent 754254c commit bdf3cef

14 files changed

Lines changed: 206 additions & 68 deletions

File tree

‎docs/client/index.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,11 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
138138
some other exception, `content` would say only `Error executing tool lookup_book`.) Always look at
139139
`is_error` before you trust `structured_content`.
140140

141-
!!! warning
142-
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
143-
(`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back,
144-
`is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises
145-
`MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and
146-
**[Handling errors](../servers/handling-errors.md)** covers when a server produces which.
141+
!!! warning "Unknown tools raise protocol errors"
142+
Ask for a tool the server does not have and `Client.call_tool()` raises `MCPError` with
143+
code `-32602` and `Unknown tool: <name>` as its message. Argument-validation failures and
144+
tool execution failures return `is_error=True` instead.
145+
**[Handling errors](../servers/handling-errors.md)** covers both paths.
147146

148147
## Resources
149148

‎docs/handlers/context.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,10 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs
108108

109109
!!! check
110110
Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it
111-
anyway and the result is an error the model can read:
111+
anyway and `Client.call_tool()` raises `MCPError` with code `-32602`:
112112

113113
```text
114-
Unknown tool: recommend_book
114+
mcp.shared.exceptions.MCPError: Unknown tool: recommend_book
115115
```
116116

117117
Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely

‎docs/servers/handling-errors.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ Now swap `ToolError` for `MCPError`.
6060
* The **host** application gets the error instead, the same way it would if the tool didn't exist at all.
6161
* `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.
6262

63+
A request for an unknown tool name also raises `MCPError` with code `-32602`. Direct
64+
`MCPServer.call_tool()` calls raise `ToolNotFoundError`, a subclass of `ToolError`, for a
65+
missing name. If an existing tool calls a missing tool, that failure remains an execution
66+
error with `is_error=True`.
67+
6368
!!! check
6469
Same lookup, same miss, but now the call *raises* on the client side instead of returning:
6570

‎docs/troubleshooting.md‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def main() -> None:
8282

8383
`__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.
8484

85-
## `Error executing tool <name>: <message>`, `Error executing tool <name>`, and `Unknown tool: <name>`
85+
## `Error executing tool <name>: <message>` and `Error executing tool <name>`
8686

8787
You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool.
8888

@@ -94,12 +94,38 @@ result.content # [TextContent(text="Error executing tool forecast: No forecast
9494
result.structured_content # None
9595
```
9696

97-
`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.
97+
A bad argument is rejected the same way, against the tool's input schema, before your function ever runs.
9898

9999
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.
100100

101101
The bare form, `Error executing tool <name>` 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 '<name>' raised an unexpected exception`.
102102

103+
## `Unknown tool: <name>`
104+
105+
```python
106+
import anyio
107+
from mcp import Client, MCPError
108+
from mcp.server import MCPServer
109+
from mcp_types import INVALID_PARAMS
110+
111+
112+
async def main() -> None:
113+
server = MCPServer("example")
114+
async with Client(server) as client:
115+
try:
116+
await client.call_tool("get_forecast", {"city": "London"})
117+
except MCPError as error:
118+
assert error.code == INVALID_PARAMS
119+
assert error.message == "Unknown tool: get_forecast"
120+
121+
122+
anyio.run(main)
123+
```
124+
125+
The requested tool does not exist. The server returns a JSON-RPC error with code `-32602`,
126+
and `Client.call_tool()` raises `MCPError`. Refresh the tool list and check the name you sent.
127+
An existing tool that calls a missing tool still returns an execution error with `is_error=True`.
128+
103129
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
104130

105131
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
426452
## Recap
427453

428454
* `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.
429-
* `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.
455+
* `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.
430456
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
431457
* `has an invalid x-mcp-header annotation` -> only `str`, `int` and `bool` arguments can be marked.
432458
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.

‎src/mcp/server/mcpserver/exceptions.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,23 @@ class ToolError(MCPServerError):
5555
The SDK raises it too, for an unknown tool name and for arguments that fail
5656
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
5757
around `MCPServer.call_tool()` catches every tool failure, crash or not.
58+
For a missing name, the SDK raises `ToolNotFoundError`; the request boundary
59+
translates that lookup miss to a JSON-RPC `INVALID_PARAMS` error.
5860
"""
5961

6062

63+
class ToolNotFoundError(ToolError):
64+
"""The requested tool does not exist.
65+
66+
Direct calls remain catchable as `ToolError`. At the request boundary, a
67+
missing requested tool becomes a JSON-RPC `INVALID_PARAMS` error.
68+
"""
69+
70+
def __init__(self, name: str) -> None:
71+
self.name = name
72+
super().__init__(f"Unknown tool: {name}")
73+
74+
6175
class UnexpectedToolError(ToolError):
6276
"""A tool call failed with something other than `ToolError`, `ResourceError`, or `MCPError`.
6377

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
ResourceError,
7676
ResourceNotFoundError,
7777
ToolError,
78+
ToolNotFoundError,
7879
UnexpectedResourceError,
7980
UnexpectedToolError,
8081
)
@@ -443,6 +444,8 @@ async def _handle_call_tool(
443444
except MCPError:
444445
raise
445446
except Exception as exc:
447+
if isinstance(exc, ToolNotFoundError) and exc.name == params.name:
448+
raise MCPError(code=INVALID_PARAMS, message=str(exc)) from exc
446449
if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError):
447450
if isinstance(exc.__cause__, ValidationError):
448451
# Field names only: the rejected values are the caller's data.
@@ -536,7 +539,8 @@ async def call_tool(
536539
"""Call a tool by name with arguments.
537540
538541
Raises:
539-
ToolError: If the tool is unknown, the arguments fail validation, or the
542+
ToolNotFoundError: If the tool is unknown. This is a subclass of `ToolError`.
543+
ToolError: If the arguments fail validation, or the
540544
tool (or a resolver) raises `ToolError` or `ResourceError`.
541545
UnexpectedToolError: If the tool (or a resolver) raises anything else, or
542546
its return value fails output conversion. `__cause__` is the original

‎src/mcp/server/mcpserver/tools/tool_manager.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from mcp_types import Icon, ToolAnnotations
77

8-
from mcp.server.mcpserver.exceptions import ToolError
8+
from mcp.server.mcpserver.exceptions import ToolError, ToolNotFoundError
99
from mcp.server.mcpserver.tools.base import Tool
1010
from mcp.server.mcpserver.utilities.logging import get_logger
1111

@@ -82,6 +82,6 @@ async def call_tool(
8282
"""Call a tool by name with arguments."""
8383
tool = self.get_tool(name)
8484
if not tool:
85-
raise ToolError(f"Unknown tool: {name}")
85+
raise ToolNotFoundError(name)
8686

8787
return await tool.run(arguments, context, convert_result=convert_result)

‎tests/docs_src/test_client.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88
from inline_snapshot import snapshot
9-
from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool
9+
from mcp_types import INVALID_PARAMS, Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool
1010

1111
from docs_src.client import tutorial001, tutorial007
1212
from mcp import Client, MCPDeprecationWarning, MCPError
@@ -94,15 +94,13 @@ async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
9494
assert result.structured_content is None
9595

9696

97-
async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None:
98-
"""The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError."""
97+
async def test_an_unknown_tool_name_raises_invalid_params() -> None:
98+
"""The client receives a protocol error for a tool the server does not have."""
9999
async with Client(tutorial001.mcp) as client:
100-
result = await client.call_tool("does_not_exist", {})
101-
assert result.is_error
102-
(block,) = result.content
103-
assert isinstance(block, TextContent)
104-
assert block.text == "Unknown tool: does_not_exist"
105-
assert result.structured_content is None
100+
with pytest.raises(MCPError) as exc_info:
101+
await client.call_tool("does_not_exist", {})
102+
assert exc_info.value.code == INVALID_PARAMS
103+
assert exc_info.value.message == snapshot("Unknown tool: does_not_exist")
106104

107105

108106
async def test_resources_and_templates_are_two_separate_lists() -> None:

‎tests/docs_src/test_context.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
import pytest
66
from inline_snapshot import snapshot
7-
from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification
7+
from mcp_types import INVALID_PARAMS, TextContent, TextResourceContents, ToolListChangedNotification
88

99
from docs_src.context import tutorial001, tutorial002, tutorial003
10-
from mcp import Client
10+
from mcp import Client, MCPError
1111

1212
# See test_index.py for why this is a per-module mark and not a conftest hook.
1313
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@@ -70,9 +70,10 @@ async def collect(message: object) -> None:
7070
async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client:
7171
assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"]
7272

73-
missing = await client.call_tool("recommend_book", {"genre": "fiction"})
74-
assert missing.is_error
75-
assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")]
73+
with pytest.raises(MCPError) as exc_info:
74+
await client.call_tool("recommend_book", {"genre": "fiction"})
75+
assert exc_info.value.code == INVALID_PARAMS
76+
assert exc_info.value.message == snapshot("Unknown tool: recommend_book")
7677

7778
enabled = await client.call_tool("enable_recommendations", {})
7879
assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")]

‎tests/docs_src/test_troubleshooting.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ def forecast(city: str) -> str:
108108
assert record.exc_info is not None
109109

110110

111-
async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
112-
"""`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
111+
async def test_an_unknown_tool_is_a_protocol_error() -> None:
112+
"""`Unknown tool: <name>` is an invalid params error, rather than an execution failure."""
113113
async with Client(tutorial001.mcp) as client:
114-
result = await client.call_tool("get_forecast", {"city": "London"})
115-
assert result.is_error
116-
assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]
114+
with pytest.raises(MCPError) as exc_info:
115+
await client.call_tool("get_forecast", {"city": "London"})
116+
assert exc_info.value.code == INVALID_PARAMS
117+
assert exc_info.value.message == snapshot("Unknown tool: get_forecast")
117118

118119

119120
async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:

0 commit comments

Comments
 (0)