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:
parent
f1c40b062f
commit
56af44727b
45 changed files with 1427 additions and 310 deletions
|
|
@ -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`.
|
`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"
|
--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
|
!!! check
|
||||||
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
|
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
|
```python
|
||||||
result.is_error # True
|
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
|
result.structured_content # None
|
||||||
```
|
```
|
||||||
|
|
||||||
The exception's message landed in `content`, where the **model** can read it and try again. That
|
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. Always look at `is_error`
|
is deliberate: a tool error is part of the conversation, not a crash. (Had the tool crashed with
|
||||||
before you trust `structured_content`.
|
some other exception, `content` would say only `Error executing tool lookup_book`.) Always look at
|
||||||
|
`is_error` before you trust `structured_content`.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
|
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Deprecated features
|
# 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.
|
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
|
Run the filter the other way and you get a free regression test. Add
|
||||||
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
|
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
|
||||||
configuration and the deprecated call **raises** instead of warning. A tool named
|
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
|
```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
|
One line of pytest configuration, and a deprecated call can never sneak back into your
|
||||||
codebase without failing a test.
|
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
|
## 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`**.
|
* 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).
|
* 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.
|
* 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.
|
* `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.
|
* New code should not be built on any of these.
|
||||||
|
|
||||||
Every other page in these docs teaches the current API.
|
Every other page in these docs teaches the current API.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Which means connecting to a host is one act: you tell it **the command that star
|
||||||
|
|
||||||
## One server, every host
|
## 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"
|
--8<-- "docs_src/real_host/tutorial001.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
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
|
`is_error=True` (and if it was a `ToolError`, the model reads your message). `raise_exceptions` doesn't
|
||||||
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
|
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)**.
|
**[Handling errors](../servers/handling-errors.md)**.
|
||||||
|
|
||||||
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
|
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,8 @@ That schema is the form. `Field(description=...)` is the label; a default pre-fi
|
||||||
!!! warning
|
!!! warning
|
||||||
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
|
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`).
|
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
|
```text
|
||||||
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
|
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
|
!!! tip
|
||||||
The answer is validated against your model before your code sees it. A client that sends
|
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
|
`"maybe"` for a `bool` doesn't corrupt your booking: `ctx.elicit` raises `ValueError`, the call
|
||||||
schema-mismatch error, your `if` never runs.
|
fails, and your `if` never runs.
|
||||||
|
|
||||||
## Send the user to a URL
|
## Send the user to a URL
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`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
|
## Try it
|
||||||
|
|
||||||
Run the server with the MCP Inspector:
|
Run the server with the MCP Inspector:
|
||||||
|
|
|
||||||
|
|
@ -992,8 +992,8 @@ its behavior is unchanged.
|
||||||
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
|
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
|
||||||
when the request itself should be rejected (missing client capability,
|
when the request itself should be rejected (missing client capability,
|
||||||
elicitation required, invalid parameters). For tool *execution* failures the
|
elicitation required, invalid parameters). For tool *execution* failures the
|
||||||
calling LLM should see and react to, raise any other exception or return
|
calling LLM should see and react to, raise `ToolError` or return
|
||||||
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
|
`CallToolResult(is_error=True, ...)` directly.
|
||||||
|
|
||||||
The client sees this change too. `Client.call_tool()` and
|
The client sees this change too. `Client.call_tool()` and
|
||||||
`ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that
|
`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)
|
### 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`).
|
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.
|
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
|
```toml
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Handling errors
|
# 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.
|
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:
|
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"
|
--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:
|
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.
|
* 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.
|
* `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.
|
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
|
!!! tip
|
||||||
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
|
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.
|
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
|
## 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"
|
```python title="server.py" hl_lines="1 3 14"
|
||||||
--8<-- "docs_src/handling_errors/tutorial002.py"
|
--8<-- "docs_src/handling_errors/tutorial002.py"
|
||||||
|
|
@ -72,10 +74,10 @@ Now swap `ValueError` for `MCPError`.
|
||||||
|
|
||||||
The two paths answer two different questions.
|
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.
|
* **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.
|
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
|
`data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised
|
||||||
`MCPError` verbatim instead of sanitising it.
|
`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
|
## A resource that doesn't exist
|
||||||
|
|
||||||
Resources draw the same line, and ship one named exception for the common case.
|
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
|
## 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.
|
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
|
Everything a **client** sees on this page, the in-memory `Client` you'll write tests with
|
||||||
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
|
sees too. Even `raise_exceptions=True` doesn't hand a failing
|
||||||
back into a traceback: by the time that flag could act, your exception is already the
|
tool's exception back to the caller: by the time that flag could act, your exception is already
|
||||||
`is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern.
|
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
|
## 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.
|
* 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`.
|
* `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.
|
* 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)**.
|
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)**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.
|
||||||
|
|
||||||
!!! check
|
!!! check
|
||||||
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails,
|
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:
|
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
|
```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
|
humidity
|
||||||
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
|
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.
|
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
|
## Opting out
|
||||||
|
|
|
||||||
|
|
@ -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
|
boundary. For filesystem access, use `safe_join` to resolve the path
|
||||||
and verify it stays inside your base directory:
|
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"
|
--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.
|
`safe_join` remains the containment boundary.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
If your handler can't fulfil the request (the file doesn't exist,
|
If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise
|
||||||
the id is unknown), raise an exception. The SDK turns it into an
|
`ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message
|
||||||
error response. See **[Handling errors](handling-errors.md)** for the difference between a
|
and the URI. An unexpected exception becomes a generic `-32603` instead. See
|
||||||
protocol error and a tool error.
|
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.
|
||||||
|
|
||||||
## Resources on the low-level Server
|
## Resources on the low-level Server
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`__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.
|
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
|
```python
|
||||||
result.is_error # True
|
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 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`
|
## `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.
|
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
|
## 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.
|
* `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.
|
* `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.
|
* `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=[...])`.
|
* 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=[...])`.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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)**.
|
* **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.
|
* **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.
|
* **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)**.
|
* **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)**.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from mcp import Client
|
from mcp import Client
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
from mcp.types import TextContent
|
from mcp.types import TextContent
|
||||||
|
|
||||||
mcp = MCPServer("Bookshop")
|
mcp = MCPServer("Bookshop")
|
||||||
|
|
@ -17,7 +18,7 @@ class Book(BaseModel):
|
||||||
def lookup_book(title: str) -> Book:
|
def lookup_book(title: str) -> Book:
|
||||||
"""Look up a book by its exact title."""
|
"""Look up a book by its exact title."""
|
||||||
if title != "Dune":
|
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)
|
return Book(title="Dune", author="Frank Herbert", year=1965)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
|
|
||||||
mcp = MCPServer("Bookshop")
|
mcp = MCPServer("Bookshop")
|
||||||
|
|
||||||
|
|
@ -9,5 +10,5 @@ CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
|
||||||
def get_author(title: str) -> str:
|
def get_author(title: str) -> str:
|
||||||
"""Look up the author of a book in the catalog."""
|
"""Look up the author of a book in the catalog."""
|
||||||
if title not in 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]
|
return CATALOG[title]
|
||||||
|
|
|
||||||
11
docs_src/handling_errors/tutorial004.py
Normal file
11
docs_src/handling_errors/tutorial004.py
Normal 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]
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ToolError
|
||||||
|
|
||||||
mcp = MCPServer("Bookshop")
|
mcp = MCPServer("Bookshop")
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ def search_books(query: str) -> list[str]:
|
||||||
def get_author(title: str) -> str:
|
def get_author(title: str) -> str:
|
||||||
"""Look up the author of a book in the catalog."""
|
"""Look up the author of a book in the catalog."""
|
||||||
if title not in 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]
|
return CATALOG[title]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
|
||||||
|
|
||||||
mcp = MCPServer("Weather")
|
mcp = MCPServer("Weather")
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ FORECASTS = {"London": "Rain.", "Cairo": "Sun."}
|
||||||
def forecast(city: str) -> str:
|
def forecast(city: str) -> str:
|
||||||
"""Today's forecast for one city."""
|
"""Today's forecast for one city."""
|
||||||
if city not in FORECASTS:
|
if city not in FORECASTS:
|
||||||
raise ValueError(f"No forecast for {city!r}.")
|
raise ToolError(f"No forecast for {city!r}.")
|
||||||
return FORECASTS[city]
|
return FORECASTS[city]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
|
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||||
from mcp.shared.path_security import safe_join
|
from mcp.shared.path_security import safe_join
|
||||||
|
|
||||||
mcp = MCPServer("Bookshop")
|
mcp = MCPServer("Bookshop")
|
||||||
|
|
@ -11,4 +12,7 @@ DOCS_ROOT = Path("./manuals")
|
||||||
@mcp.resource("manuals://{+path}")
|
@mcp.resource("manuals://{+path}")
|
||||||
def read_manual(path: str) -> str:
|
def read_manual(path: str) -> str:
|
||||||
"""A staff manual page, served from a directory on disk."""
|
"""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")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from typing import Annotated, Any
|
||||||
import click
|
import click
|
||||||
from mcp.server import ServerRequestContext
|
from mcp.server import ServerRequestContext
|
||||||
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
|
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.mcpserver.prompts.base import Prompt, UserMessage
|
||||||
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
|
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
|
||||||
from mcp.shared.exceptions import MCPError
|
from mcp.shared.exceptions import MCPError
|
||||||
|
|
@ -328,7 +329,7 @@ async def test_elicitation_sep1330_enums(ctx: Context) -> str:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def test_error_handling() -> str:
|
def test_error_handling() -> str:
|
||||||
"""Tests error response handling"""
|
"""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()
|
@mcp.tool()
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,12 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
||||||
The resource content as either text or bytes
|
The resource content as either text or bytes
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ResourceNotFoundError: If no resource or template matches the URI.
|
ResourceNotFoundError: If no resource or template matches the URI, or the
|
||||||
ResourceError: If template creation or resource reading fails.
|
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`.
|
RuntimeError: If the resource returned an `InputRequiredResult`.
|
||||||
"""
|
"""
|
||||||
assert self._mcp_server is not None, "Context is not available outside of a request"
|
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
Loading…
Reference in a new issue