Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name>` as its message. Argument-validation failures and
tool execution failures return `is_error=True` instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This overstates the result path: a tool that raises MCPError is re-raised, so Client.call_tool() raises instead of returning is_error=True. Limit this to ordinary tool errors or call out the MCPError exception.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/client/index.md, line 144:

<comment>This overstates the result path: a tool that raises `MCPError` is re-raised, so `Client.call_tool()` raises instead of returning `is_error=True`. Limit this to ordinary tool errors or call out the `MCPError` exception.</comment>

<file context>
@@ -138,12 +138,11 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
+!!! 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: <name>` as its message. Argument-validation failures and
+    tool execution failures return `is_error=True` instead.
+    **[Handling errors](../servers/handling-errors.md)** covers both paths.
 
</file context>
Suggested change
tool execution failures return `is_error=True` instead.
ordinary tool errors return `is_error=True`; a tool that raises `MCPError` still produces a protocol error.

**[Handling errors](../servers/handling-errors.md)** covers both paths.

## Resources

Expand Down
4 changes: 2 additions & 2 deletions docs/handlers/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/servers/handling-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
32 changes: 29 additions & 3 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>: <message>`, `Error executing tool <name>`, and `Unknown tool: <name>`
## `Error executing tool <name>: <message>` and `Error executing tool <name>`

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

Expand All @@ -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 <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`.

## `Unknown tool: <name>`

```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.
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Comment thread
claude[bot] marked this conversation as resolved.

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`.

Expand Down
6 changes: 5 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
ResourceError,
ResourceNotFoundError,
ToolError,
ToolNotFoundError,
UnexpectedResourceError,
UnexpectedToolError,
)
Expand Down Expand Up @@ -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
Comment on lines +447 to +448

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): AGENTS.md says any change to a released v2 API's observable behaviour is an explicit maintainer design decision that should generally be avoided: server.py now answers a tools/call for an unregistered name with a JSON-RPC INVALID_PARAMS error (HTTP 400) instead of the previously pinned CallToolResult(is_error=True, "Unknown tool: <name>"), so Client.call_tool() raises MCPError where it used to return. Fix: confirm this is the explicit maintainer decision the rule asks for (the PR cites #3659) and keep the change scoped to that decision; otherwise retain the is_error result and keep the divergence entry in tests/interaction/_requirements.py.

Why this was flagged

Nothing fails at runtime in the SDK itself. The rule guards 2.x compatibility: existing clients and hosts that handled unknown-tool misses by inspecting result.is_error (the behaviour the previous docs, troubleshooting page and the mcpserver:tool:unknown-name divergence note explicitly promised) will now get an unhandled MCPError from Client.call_tool(), and HTTP integrations expecting a 200 result will see a 400 error body. Mitigating facts for the maintainer to weigh: the PR is authored by Kludex and closes #3659, it aligns MCPServer with the spec example and with the low-level server path, and direct MCPServer.call_tool() callers keep catching ToolError because ToolNotFoundError subclasses it. The divergence between a top-level miss (protocol error) and a nested/override miss (execution error) rests on exc.name == params.name string equality.

Verification: AGENTS.md (base commit) "Branching Model": "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/mcpserver/tools/tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
16 changes: 7 additions & 9 deletions tests/docs_src/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions tests/docs_src/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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.")]
Expand Down
11 changes: 6 additions & 5 deletions tests/docs_src/test_troubleshooting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <name>` travels the same `is_error=True` path as a failing tool."""
async def test_an_unknown_tool_is_a_protocol_error() -> None:
"""`Unknown tool: <name>` 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:
Expand Down
7 changes: 0 additions & 7 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 8 additions & 10 deletions tests/interaction/mcpserver/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitRequestURLParams,
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading