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
10 changes: 5 additions & 5 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,17 @@ A tool that raises does **not** raise in your client. It comes back as an ordina

!!! check
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
`ValueError`. The call still returns normally:
`ToolError`. The call still returns normally:

```python
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.content # [TextContent(type='text', text="No book titled 'Solaris' in the catalog.")]
result.structured_content # None
```

The exception's message landed in `content`, where the **model** can read it and try again. That
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
before you trust `structured_content`.
The deliberate `ToolError` message landed in `content`, where the **model** can read it and try
again. Unexpected exceptions are logged on the server and replaced with a generic message. 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
Expand Down
2 changes: 1 addition & 1 deletion docs/deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
`old_log` that still calls `ctx.info()` stops passing and starts reporting:

```text
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
An unexpected error occurred while executing tool old_log
```

One line of pytest configuration, and a deprecated call can never sneak back into your
Expand Down
7 changes: 4 additions & 3 deletions docs/get-started/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ There you go! You can now extend your tests to cover more scenarios.

Two different things can go wrong, and this flag only touches one of them.

An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
An explicit `ToolError` inside one of **your tools** is not a protocol failure. It becomes a normal
result with `is_error=True` and its safe message in the content. An unexpected exception is logged
server-side and becomes a normal result with a generic message. `raise_exceptions` doesn't change that:
with or without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
**[Handling errors](../servers/handling-errors.md)**.

A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
Expand Down
2 changes: 1 addition & 1 deletion docs/handlers/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ And if the user won't answer at all - declines the question, or cancels it?
result the model can read:

```text
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
Resolver for parameter 'backorder' could not resolve: elicitation was decline
```

That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation.
Expand Down
8 changes: 5 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -992,8 +992,10 @@ its behavior is unchanged.
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
when the request itself should be rejected (missing client capability,
elicitation required, invalid parameters). For tool *execution* failures the
calling LLM should see and react to, raise any other exception or return
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
calling LLM should see and react to, raise `ToolError` with a safe message or
return `CallToolResult(is_error=True, ...)` directly. Unexpected exceptions are
logged server-side and returned as a generic `is_error=True` result instead of
exposing their values to the client.

The client sees this change too. `Client.call_tool()` and
`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
Expand Down Expand Up @@ -2737,7 +2739,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve

Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement.

Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...: The logging capability is deprecated as of 2026-07-28 (SEP-2577).`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:
Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is logged and returned as `CallToolResult(is_error=True)` (`An unexpected error occurred while executing tool old_log`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with:

```toml
[tool.pytest.ini_options]
Expand Down
42 changes: 28 additions & 14 deletions docs/servers/handling-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

A tool can fail in two ways, and the SDK treats them very differently.

Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
Raise `ToolError` when the **model** should see a safe, actionable message. Raise `MCPError` when the
**protocol** should see the failure. Unexpected exceptions are logged server-side and replaced with a
generic tool error.

This page is about choosing.

Expand All @@ -14,32 +16,39 @@ Take a tool that looks something up, and let the lookup miss:
--8<-- "docs_src/handling_errors/tutorial001.py"
```

There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would.
`get_author` raises `ToolError` with the message the model is allowed to see. Use this exception for
expected, recoverable failures such as a missing catalog entry.

Call it with a title that isn't in the catalog and look at the result:

```python
result.is_error # True
result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")]
result.content # [TextContent(text="No book titled 'Nothing' in the catalog.")]
result.structured_content # None
```

* The request **succeeded**. There is a result; nothing was raised at the caller.
* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads.
* `is_error` is `True`, and the `ToolError` message is in `content`, exactly where the model reads.
* `structured_content` is `None`. A failed call has no return value to structure.

This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want.
This is a **tool error**. The message is explicit and safe because the tool author chose to raise
`ToolError`.

The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent.
The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise ToolError(...)` and got a self-correcting agent.

!!! warning
If an unexpected exception escapes the tool, the SDK logs the traceback on the server and returns
`An unexpected error occurred while executing tool <name>`. It never sends the exception value to
the client. Use `ToolError` when the model needs a specific recovery hint.

!!! tip
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
model (and to every client UI) it looks like the tool worked and that string was the answer.
`raise`. The flag is the signal.
`raise ToolError(...)`. The flag is the signal.

## An error the model cannot fix

Now swap `ValueError` for `MCPError`.
Now swap `ToolError` for `MCPError`.

```python title="server.py" hl_lines="1 3 14"
--8<-- "docs_src/handling_errors/tutorial002.py"
Expand Down Expand Up @@ -72,12 +81,16 @@ Now swap `ValueError` for `MCPError`.

The two paths answer two different questions.

* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors.
* **Raise `ToolError`** for an expected failure of *execution* that the model can recover from. Include only information that is safe for the client to see: a misspelled title, a row that doesn't exist, or a user-facing validation message.
* Let **unexpected exceptions** propagate when the details are for server operators. The SDK logs the traceback and returns a generic `is_error=True` result.
* **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message.

One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`.
One question decides it: **does the model need a safe recovery hint?** Yes -> `ToolError`. No, because
the failure is unexpected or internal -> let the original exception be logged and sanitized. If the
request itself is invalid or unsupported -> `MCPError`.

By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it.
By that test, `get_author` uses `ToolError`: a better title fixes the problem, so the model deserves
to see the message.

!!! info
`MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional
Expand Down Expand Up @@ -110,7 +123,7 @@ Notice there is no `is_error=True` half-result here. A resource read either retu

A bad argument never reaches your function.

Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint.
Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, returning a generic `is_error=True` tool result. The validation details stay in the server log while the model can use the advertised schema to correct its arguments. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint.

It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.

Expand All @@ -122,9 +135,10 @@ It means a whole class of `raise` statements you don't write: don't re-validate

## Recap

* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default.
* Raise **`ToolError`** in a tool -> the call returns `is_error=True` with your safe message in `content`. The model reads it and can retry.
* Let an **unexpected exception** escape -> the server logs the traceback and the call returns `is_error=True` with a generic message.
* Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact.
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
* The deciding question: *does the model need a safe recovery hint?* Yes -> `ToolError`. No -> let the SDK sanitize the unexpected error, or raise `MCPError` if the request itself should fail.
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
* `from mcp import MCPError`; the error-code constants come from `mcp.types`.
Expand Down
10 changes: 4 additions & 6 deletions docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,14 @@ The annotation promises `WeatherData`. The upstream response stopped sending `hu

!!! check
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails,
and the first lines of the error name the field:
while the validation details stay in the server log:

```text
Error executing tool get_weather: 1 validation error for WeatherData
humidity
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
An unexpected error occurred while executing tool get_weather
```

That text comes back as the tool result with `is_error=True`, so the model knows the call failed
instead of confidently reading weather that isn't there.
The generic text comes back as the tool result with `is_error=True`, so the model knows the call
failed instead of receiving internal schema details.

Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type.

Expand Down
13 changes: 7 additions & 6 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ Three new things, all on the parameters:
* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those.

!!! check
Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a
tool error **before your function runs**:
Constraints are not decoration. Call the tool with `limit=999` and the SDK rejects it
**before your function runs**:

```text
Input should be less than or equal to 50
An unexpected error occurred while executing tool search_books
```

That error goes back to the model as the tool result, and the model reads it and retries with
a valid value. You wrote `le=50` once and got self-correcting agents for free.
The validation details stay in the server log; the client never receives Pydantic's internal
model name, version-specific wording, or documentation URL. The model already has the
constraint in the input schema and can retry with a valid value.

!!! info
If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`,
Expand Down Expand Up @@ -166,7 +167,7 @@ A well-behaved client uses them to decide things like *"do I need to ask the use
* Type hints **are** the input schema. Defaults make arguments optional.
* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums.
* A Pydantic model parameter is how you take a structured "body".
* Bad arguments are rejected for you, with an error the model can read and recover from.
* Bad arguments are rejected for you before the function runs; validation details stay server-side.
* `async def` for I/O, plain `def` for everything else.

**[Structured Output](structured-output.md)** is what happens to the value you `return`.
17 changes: 11 additions & 6 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,25 @@ 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>` and `Unknown tool: <name>`
## Tool errors, unexpected errors, and `Unknown tool: <name>`

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

Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*:
Call `forecast` for a city the server doesn't know. Because the tool raises `ToolError`, the safe
message comes back with the request marked as *succeeded*:

```python
result.is_error # True
result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")]
result.content # [TextContent(text="No forecast for 'Atlantis'.")]
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.
An unexpected exception uses the same result shape but returns a generic message, while the traceback
is logged on the server. `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.

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 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. For a recoverable, model-facing failure, raise `ToolError` with a safe message. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise.

## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`

Expand Down Expand Up @@ -404,7 +408,8 @@ 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`.
* `call_tool` does not raise for a failing high-level tool. `ToolError`, unexpected tool exceptions,
and `Unknown tool: ...` are results: check `result.is_error`.
* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
Expand Down
3 changes: 2 additions & 1 deletion docs_src/client/tutorial003.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import TextContent

mcp = MCPServer("Bookshop")
Expand All @@ -17,7 +18,7 @@ class Book(BaseModel):
def lookup_book(title: str) -> Book:
"""Look up a book by its exact title."""
if title != "Dune":
raise ValueError(f"No book titled {title!r} in the catalog.")
raise ToolError(f"No book titled {title!r} in the catalog.")
return Book(title="Dune", author="Frank Herbert", year=1965)


Expand Down
Loading
Loading