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 @@
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.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).

Check warning on line 126 in docs/deprecated.md

View check run for this annotation

Claude / Claude Code Review

nit: The quoted server-log line `mcp.MCPDeprecationWarning: The logging capability is deprecated...` is not what the log will contain — a Python traceback renders the exception's real `__module__.__qualname__`, and MCPDeprecationWarning is defined in `m

nit: The quoted server-log line `mcp.MCPDeprecationWarning: The logging capability is deprecated...` is not what the log will contain — a Python traceback renders the exception's real `__module__.__qualname__`, and MCPDeprecationWarning is defined in `mcp.shared.exceptions` with no `__module__` override, so the captured log's final line reads `mcp.shared.exceptions.MCPDeprecationWarning: ...`.

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: The quoted server-log line mcp.MCPDeprecationWarning: The logging capability is deprecated... is not what the log will contain — a Python traceback renders the exception's real __module__.__qualname__, and MCPDeprecationWarning is defined in mcp.shared.exceptions with no __module__ override, so the captured log's final line reads mcp.shared.exceptions.MCPDeprecationWarning: ....

Extended reasoning...

A user follows the tip, runs pytest with error::mcp.MCPDeprecationWarning, and greps the captured server log for the documented string mcp.MCPDeprecationWarning: (or asserts on it in a test) — it never matches, because the ERROR record's traceback ends with mcp.shared.exceptions.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).; the accompanying test (tests/docs_src/test_deprecated.py) only checks type(...).__name__ and str(...), so the doc drift is not caught.

Verification: nit. docs/deprecated.md:123-126 (added by this diff) presents the block as literal captured server-log content — "the captured server log names the culprit:" followed by mcp.MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). — but that string never appears in the log. MCPDeprecationWarning is defined at src/mcp/shared/exceptions.py:8 with no __module__

```

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
55 changes: 39 additions & 16 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). Any other exception 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)**.

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: The resource section omits the INFO/no-traceback behavior for anticipated ResourceError and incorrectly groups MCPError with crashes. State that ResourceError returns -32603 and logs once at INFO, that ResourceNotFoundError remains -32602, and that MCPError passes through unchanged.

(Based on your team's feedback about documenting resource error outcomes.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/servers/handling-errors.md, line 128:

<comment>The resource section omits the `INFO`/no-traceback behavior for anticipated `ResourceError` and incorrectly groups `MCPError` with crashes. State that `ResourceError` returns `-32603` and logs once at `INFO`, that `ResourceNotFoundError` remains `-32602`, and that `MCPError` passes through unchanged.

(Based on your team's feedback about documenting resource error outcomes.) </comment>

<file context>
@@ -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.
+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). Any other exception 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.

Errors you never raise

</file context>


</details>

```suggestion
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 logs one `INFO` line without a traceback. `ResourceNotFoundError` remains `-32602`, while `MCPError` passes through unchanged. Any other exception 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 @@ -116,18 +137,20 @@ It means a whole class of `raise` statements you don't write: don't re-validate

!!! info
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write

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 info box now says every statement on the page is client-visible, but the page also documents server-only ERROR records and tracebacks. Limit the sentence to client-facing behavior so the logging guidance is not presented as a client observation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/servers/handling-errors.md, line 139:

<comment>This info box now says every statement on the page is client-visible, but the page also documents server-only `ERROR` records and tracebacks. Limit the sentence to client-facing behavior so the logging guidance is not presented as a client observation.</comment>

<file context>
@@ -115,36 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
-`ToolError` comes from `mcp.server.mcpserver.exceptions`. The model reads exactly what it read before. The difference is in your log, where a `ToolError` is a single `INFO` line with no traceback, so a production log at `WARNING` stays quiet until something is actually broken. Bad arguments and unknown tool names are logged at `INFO` too, because those are the caller's mistakes rather than yours.
-
-Resources work the same way. A crashing resource handler is logged at `ERROR` with its traceback, which matters more here because the `-32603` the client receives names only the URI. `ResourceNotFoundError` and `ResourceError` are the anticipated kind and are logged at `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 hand a failing
+    tool's exception back to the caller: by the time that flag could act, your exception is already
</file context>
Suggested change
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
The **client-facing behavior** described 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.
tests with sees exactly the same thing. 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, ResourceNotFoundError`, and the error-code constants from `mcp.types`.

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: The page now introduces ResourceError as the anticipated non-not-found resource failure, but the only import guidance omits it. Add ResourceError to the documented exception import so the guidance is usable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/servers/handling-errors.md, line 153:

<comment>The page now introduces `ResourceError` as the anticipated non-not-found resource failure, but the only import guidance omits it. Add `ResourceError` to the documented exception import so the guidance is usable.</comment>

<file context>
@@ -115,36 +136,21 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
 * `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, 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)**.
</file context>
Suggested change
* Imports: `from mcp import MCPError`, `from mcp.server.mcpserver.exceptions import ToolError, ResourceNotFoundError`, and the error-code constants from `mcp.types`.
* Imports: `from mcp import MCPError`, `from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError, ToolError`, 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