Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
Max 2026-08-24 15:51:18 +01:00 • committed by GitHub
commit 56af44727b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1427 additions and 310 deletions

View file

@ -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"
```
@ -113,7 +113,7 @@ 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
@ -121,9 +121,10 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
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

View file

@ -1,6 +1,6 @@
# Deprecated features
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**.
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. One SDK helper is deprecated on its own account and is listed [at the end](#deprecated-sdk-helpers).
The table below names each deprecated feature, why it is going away, and the replacement to build on.
@ -119,15 +119,24 @@ 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
codebase without failing a test.
## Deprecated SDK helpers
These are not spec changes, only SDK internals with a better replacement. They warn with the same `MCPDeprecationWarning` and will be removed in 3.0.
| Deprecated | What you do instead |
|---|---|
| `FuncMetadata.call_fn_with_arg_validation()` | `FuncMetadata.validate_arguments()` and then `FuncMetadata.call_fn()`. Only code that drives `FuncMetadata` directly (a custom `Tool` subclass, say) ever called it. |
## Recap
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**.
@ -135,6 +144,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
* One SDK helper, `FuncMetadata.call_fn_with_arg_validation()`, is deprecated separately for removal in 3.0.
* New code should not be built on any of these.
Every other page in these docs teaches the current API.

View file

@ -6,7 +6,7 @@ Which means connecting to a host is one act: you tell it **the command that star
## One server, every host
```python title="server.py" hl_lines="3 33-34"
```python title="server.py" hl_lines="4 34-35"
--8<-- "docs_src/real_host/tutorial001.py"
```

View file

@ -79,8 +79,8 @@ 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:
`is_error=True` (and if it was a `ToolError`, the model reads your 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

View file

@ -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
@ -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

View file

@ -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:

View file

@ -992,8 +992,8 @@ 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` or return
`CallToolResult(is_error=True, ...)` directly.
The client sees this change too. `Client.call_tool()` and
`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
@ -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`).
@ -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]

View file

@ -1,8 +1,8 @@
# 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.
@ -10,11 +10,11 @@ This page is about choosing.
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:
@ -25,13 +25,15 @@ 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.
@ -39,7 +41,7 @@ The model is the one calling your tool. It picked the arguments. So a tool error
## 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"
@ -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.
@ -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.
@ -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
@ -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)**.

View file

@ -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

View file

@ -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"
```
@ -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

View file

@ -76,11 +76,11 @@ 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>`
## `Error executing tool <name>: <message>`, `Error executing tool <name>`, 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, and the `ToolError` it raises comes back with the request marked as *succeeded*:
```python
result.is_error # True
@ -92,6 +92,8 @@ result.structured_content # None
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`.
## `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.
@ -404,7 +406,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`.
* `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.
* `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=[...])`.

View file

@ -129,7 +129,7 @@ On those types, every Python attribute is now snake_case: `result.is_error`, `to
The renames announce themselves. These do not:
* **Sync functions run on a worker thread.** A `def` tool (or resource, prompt, or resolver) no longer blocks the event loop; the trade is that its body no longer runs *on* the event-loop thread, which matters to thread-affine code. `async def` handlers are untouched. **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**.
* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result the model can read and react to. **[Handling errors](servers/handling-errors.md)** is the split.
* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result, but only a `ToolError`'s message reaches the model: any other exception now reads `Error executing tool <name>`, with the traceback in your server log. **[Handling errors](servers/handling-errors.md)** is the split.
* **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas.
* **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details.
* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**.

View file

@ -2,6 +2,7 @@ from pydantic import BaseModel
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import TextContent
mcp = MCPServer("Bookshop")
@ -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)

View file

@ -1,4 +1,5 @@
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Bookshop")
@ -9,5 +10,5 @@ CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
def get_author(title: str) -> str:
"""Look up the author of a book in the catalog."""
if title not in CATALOG:
raise ValueError(f"No book titled {title!r} in the catalog.")
raise ToolError(f"No book titled {title!r} in the catalog.")
return CATALOG[title]

View file

@ -0,0 +1,11 @@
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
@mcp.tool()
def get_author(title: str) -> str:
"""Look up the author of a book in the catalog."""
return CATALOG[title]

View file

@ -1,4 +1,5 @@
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Bookshop")
@ -20,7 +21,7 @@ def search_books(query: str) -> list[str]:
def get_author(title: str) -> str:
"""Look up the author of a book in the catalog."""
if title not in CATALOG:
raise ValueError(f"No book titled {title!r} in the catalog.")
raise ToolError(f"No book titled {title!r} in the catalog.")
return CATALOG[title]

View file

@ -1,5 +1,5 @@
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
mcp = MCPServer("Weather")
@ -10,7 +10,7 @@ FORECASTS = {"London": "Rain.", "Cairo": "Sun."}
def forecast(city: str) -> str:
"""Today's forecast for one city."""
if city not in FORECASTS:
raise ValueError(f"No forecast for {city!r}.")
raise ToolError(f"No forecast for {city!r}.")
return FORECASTS[city]

View file

@ -1,6 +1,7 @@
from pathlib import Path
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from mcp.shared.path_security import safe_join
mcp = MCPServer("Bookshop")
@ -11,4 +12,7 @@ DOCS_ROOT = Path("./manuals")
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page, served from a directory on disk."""
return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8")
file = safe_join(DOCS_ROOT, path)
if not file.is_file():
raise ResourceNotFoundError(f"No manual at {path!r}.")
return file.read_text(encoding="utf-8")

View file

@ -13,6 +13,7 @@ from typing import Annotated, Any
import click
from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
@ -328,7 +329,7 @@ async def test_elicitation_sep1330_enums(ctx: Context) -> str:
@mcp.tool()
def test_error_handling() -> str:
"""Tests error response handling"""
raise RuntimeError("This tool intentionally returns an error for testing")
raise ToolError("This tool intentionally returns an error for testing")
@mcp.tool()

View file

@ -169,8 +169,12 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
The resource content as either text or bytes
Raises:
ResourceNotFoundError: If no resource or template matches the URI.
ResourceError: If template creation or resource reading fails.
ResourceNotFoundError: If no resource or template matches the URI, or the
handler raised it.
ResourceError: If the resource or template function raises `ResourceError`.
UnexpectedResourceError: If the resource or template function raises anything
else. `__cause__` is the original exception. Left uncaught in a tool, this
is logged as the tool's crash, while the two above are not.
RuntimeError: If the resource returned an `InputRequiredResult`.
"""
assert self._mcp_server is not None, "Context is not available outside of a request"

Some files were not shown because too many files have changed in this diff Show more