Skip to content
11 changes: 6 additions & 5 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ That schema is everything a UI needs to render an argument form, and everything

`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.

```python title="client.py" hl_lines="26-33"
```python title="client.py" hl_lines="27-34"
--8<-- "docs_src/client/tutorial003.py"
```

Expand Down Expand Up @@ -113,17 +113,18 @@ 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.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 `ToolError`'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. (Had the tool crashed with
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
Expand Down
5 changes: 3 additions & 2 deletions docs/deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,11 @@ That is the whole API. There is no per-method switch, and you don't want one: th
Run the filter the other way and you get a free regression test. Add
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
configuration and the deprecated call **raises** instead of warning. A tool named
`old_log` that still calls `ctx.info()` stops passing and starts reporting:
`old_log` that still calls `ctx.info()` stops passing: the call comes back `is_error=True` with
`Error executing tool old_log`, and the captured server log names the culprit:

```text
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
mcp.shared.exceptions.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
```

One line of pytest configuration, and a deprecated call can never sneak back into your
Expand Down
7 changes: 4 additions & 3 deletions docs/handlers/elicitation.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ That schema is the form. `Field(description=...)` is the label; a default pre-fi
!!! warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client.
The tool call fails with `Error executing tool <name>`, and your server log has the reason:

```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
Expand All @@ -107,8 +108,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki

!!! tip
The answer is validated against your model before your code sees it. A client that sends
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
schema-mismatch error, your `if` never runs.
`"maybe"` for a `bool` doesn't corrupt your booking: `ctx.elicit` raises `ValueError`, the call
fails, and your `if` never runs.

## Send the user to a URL

Expand Down
2 changes: 2 additions & 0 deletions docs/handlers/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The default is `"INFO"`.

`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.

You also don't need a `try`/`except` in every handler just to record failures. When a tool or resource function raises, the SDK logs it for you. **[Handling errors](../servers/handling-errors.md#any-other-exception)** explains what gets logged and at which level.

## Try it

Run the server with the MCP Inspector:
Expand Down
4 changes: 2 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,7 +1016,7 @@ except MCPError as e:

### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)

Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.
Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a resource handler (static or template) that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.

The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).

Expand Down Expand Up @@ -2737,7 +2737,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 caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...`, with the `MCPDeprecationWarning` traceback in the server 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
57 changes: 40 additions & 17 deletions docs/servers/handling-errors.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# Handling errors

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

Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
Raise `ToolError` and the **model** sees your message. Raise `MCPError` and the **protocol** sees it. Raise anything else and it is a crash: the model learns only that the call failed, and your log gets the traceback.

This page is about choosing.

## An error the model can fix

Take a tool that looks something up, and let the lookup miss:

```python title="server.py" hl_lines="11-12"
```python title="server.py" hl_lines="2 12-13"
--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.
`ToolError`, from `mcp.server.mcpserver.exceptions`, is how a tool tells the model that something went wrong.

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

Expand All @@ -25,21 +25,23 @@ 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 your message (prefixed with the tool name) 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**, and it is almost always what you want.

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.

On the server, a `ToolError` is one `INFO` line in the log, with no traceback. You saw it coming, so there is nothing to investigate.

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

## 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,10 +74,10 @@ 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 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 `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: **could a smarter model have avoided this?** Yes -> `ToolError`. No -> `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.

Expand All @@ -84,6 +86,25 @@ By that test, the second version of `get_author` made the wrong choice: a better
`data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised
`MCPError` verbatim instead of sanitising it.

## Any other exception

Now take the check out and let the dictionary lookup fail on its own:

```python title="server.py" hl_lines="11"
--8<-- "docs_src/handling_errors/tutorial004.py"
```

`CATALOG[title]` raises `KeyError`. You didn't plan for it, so the SDK treats it as a crash:

```python
result.is_error # True
result.content # [TextContent(text="Error executing tool get_author")]
```

The call still returns `is_error=True`, so the model knows it failed and can move on. What it doesn't get is the exception's text: a `KeyError` from your code, or a stack of SQL from a driver three libraries down, may describe your server's internals, so it never leaves the server.

You get it instead. The server logs the crash at `ERROR` with the full traceback, as `Tool 'get_author' raised an unexpected exception`. A production log at `WARNING` therefore stays quiet through every `ToolError` and speaks up the moment something is actually broken.

## A resource that doesn't exist

Resources draw the same line, and ship one named exception for the common case.
Expand All @@ -104,7 +125,7 @@ When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol
}
```

Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **[Resources](resources.md)**.
Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. `ResourceError` is the same thing for a failure that isn't "not found" (`-32603`, your message), and both are one `INFO` line in your log. Any other exception bar `MCPError` is a crash: the client gets `-32603` naming only the URI, and the traceback goes to your log at `ERROR`. Templates and everything else about resources live in **[Resources](resources.md)**.

## Errors you never raise

Expand All @@ -115,19 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.

!!! info
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
back into a traceback: by the time that flag could act, your exception is already the
`is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern.
Everything a **client** sees on this page, the in-memory `Client` you'll write tests with
sees too. Even `raise_exceptions=True` doesn't hand a failing
tool's exception back to the caller: by the time that flag could act, your exception is already
the `is_error=True` result. Assert on the result. If you need the traceback of a crash, it is in
the server's log, and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern.

## 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 message in `content`. The model reads it and can retry.
* 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: *could a smarter model have avoided this?* Yes -> `ToolError`. No -> `MCPError`.
* Any **other exception** is a crash -> `is_error=True` with only `Error executing tool <name>` for the model, and an `ERROR` record with the traceback for you.
* `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`.
* Imports: `from mcp import MCPError`, `from mcp.server.mcpserver.exceptions import ToolError, ResourceError, ResourceNotFoundError`, and the error-code constants from `mcp.types`.

Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.

Expand Down
13 changes: 7 additions & 6 deletions docs/servers/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,19 @@ You don't notice while you build the value by hand: Pydantic already made sure y
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.

!!! 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:
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails:
the client gets `is_error=True` with `Error executing tool get_weather`, so the model knows the
call failed instead of confidently reading weather that isn't there. The field name is for you,
in the server log at `ERROR`:

```text
Error executing tool get_weather: 1 validation error for WeatherData
Tool 'get_weather' raised an unexpected exception
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for WeatherData
humidity
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
```

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.

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.

## Opting out
Expand Down
10 changes: 5 additions & 5 deletions docs/servers/uri-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ The built-in checks stop the common cases but can't know your sandbox
boundary. For filesystem access, use `safe_join` to resolve the path
and verify it stays inside your base directory:

```python title="server.py" hl_lines="4 14"
```python title="server.py" hl_lines="5 15"
--8<-- "docs_src/uri_templates/tutorial002.py"
```

Expand Down Expand Up @@ -199,10 +199,10 @@ These checks are a heuristic pre-filter; for filesystem access,
`safe_join` remains the containment boundary.

!!! tip
If your handler can't fulfil the request (the file doesn't exist,
the id is unknown), raise an exception. The SDK turns it into an
error response. See **[Handling errors](handling-errors.md)** for the difference between a
protocol error and a tool error.
If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise
`ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message
and the URI. An unexpected exception becomes a generic `-32603` instead. See
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.

## Resources on the low-level Server

Expand Down
Loading
Loading