diff --git a/docs/client/index.md b/docs/client/index.md index 1e1df3c01b..24bf10275a 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -112,17 +112,17 @@ A tool that raises does **not** raise in your client. It comes back as an ordina !!! check Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises - `ValueError`. The call still returns normally: + `ToolError`. The call still returns normally: ```python result.is_error # True - result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")] + result.content # [TextContent(type='text', text="No book titled 'Solaris' in the catalog.")] result.structured_content # None ``` - The exception's message landed in `content`, where the **model** can read it and try again. That - is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error` - before you trust `structured_content`. + The deliberate `ToolError` message landed in `content`, where the **model** can read it and try + again. Unexpected exceptions are logged on the server and replaced with a generic message. Always + look at `is_error` before you trust `structured_content`. !!! warning `is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have diff --git a/docs/deprecated.md b/docs/deprecated.md index 9b879f1f6f..1aa976fdaf 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -73,7 +73,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th `old_log` that still calls `ctx.info()` stops passing and starts reporting: ```text - Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + An unexpected error occurred while executing tool old_log ``` One line of pytest configuration, and a deprecated call can never sneak back into your diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index 9abd281ceb..97cfce2f25 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -78,9 +78,10 @@ There you go! You can now extend your tests to cover more scenarios. Two different things can go wrong, and this flag only touches one of them. -An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with -`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or -without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it: +An explicit `ToolError` inside one of **your tools** is not a protocol failure. It becomes a normal +result with `is_error=True` and its safe message in the content. An unexpected exception is logged +server-side and becomes a normal result with a generic message. `raise_exceptions` doesn't change that: +with or without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it: **[Handling errors](../servers/handling-errors.md)**. A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md index b347e1b7d6..48cb5e417c 100644 --- a/docs/handlers/dependencies.md +++ b/docs/handlers/dependencies.md @@ -111,7 +111,7 @@ And if the user won't answer at all - declines the question, or cancels it? result the model can read: ```text - Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + Resolver for parameter 'backorder' could not resolve: elicitation was decline ``` That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..a51aa96084 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -992,8 +992,10 @@ its behavior is unchanged. `MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it when the request itself should be rejected (missing client capability, elicitation required, invalid parameters). For tool *execution* failures the -calling LLM should see and react to, raise any other exception or return -`CallToolResult(is_error=True, ...)` directly; that path is unchanged. +calling LLM should see and react to, raise `ToolError` with a safe message or +return `CallToolResult(is_error=True, ...)` directly. Unexpected exceptions are +logged server-side and returned as a generic `is_error=True` result instead of +exposing their values to the client. The client sees this change too. `Client.call_tool()` and `ClientSession.call_tool()` raise on a JSON-RPC error response, so a tool that @@ -2737,7 +2739,7 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve Every deprecation below is a runtime warning as well as a type-checker one: deprecated methods and helpers emit `mcp.MCPDeprecationWarning` on each call, and the deprecated `Server(...)` constructor parameters (`on_set_logging_level`, `on_roots_list_changed`, `on_progress`) emit it at construction time. The category subclasses `UserWarning`, not `DeprecationWarning`, so it is visible by default; [Deprecated features](deprecated.md) has the full list and each replacement. -Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is caught like any other and returned as `CallToolResult(is_error=True)` (`Error executing tool ...: The logging capability is deprecated as of 2026-07-28 (SEP-2577).`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with: +Under pytest's `filterwarnings = ["error"]`, that warning becomes an exception at the first deprecated call. Inside an `@mcp.tool()` handler the exception is logged and returned as `CallToolResult(is_error=True)` (`An unexpected error occurred while executing tool old_log`), which reads as a failing tool rather than a warning. Keep the warnings visible but non-fatal with: ```toml [tool.pytest.ini_options] diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md index 4262f586a7..edcf666c76 100644 --- a/docs/servers/handling-errors.md +++ b/docs/servers/handling-errors.md @@ -2,7 +2,9 @@ A tool can fail in two ways, and the SDK treats them very differently. -Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. +Raise `ToolError` when the **model** should see a safe, actionable message. Raise `MCPError` when the +**protocol** should see the failure. Unexpected exceptions are logged server-side and replaced with a +generic tool error. This page is about choosing. @@ -14,32 +16,39 @@ Take a tool that looks something up, and let the lookup miss: --8<-- "docs_src/handling_errors/tutorial001.py" ``` -There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would. +`get_author` raises `ToolError` with the message the model is allowed to see. Use this exception for +expected, recoverable failures such as a missing catalog entry. Call it with a title that isn't in the catalog and look at the result: ```python result.is_error # True -result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.content # [TextContent(text="No book titled 'Nothing' in the catalog.")] result.structured_content # None ``` * The request **succeeded**. There is a result; nothing was raised at the caller. -* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads. +* `is_error` is `True`, and the `ToolError` message is in `content`, exactly where the model reads. * `structured_content` is `None`. A failed call has no return value to structure. -This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want. +This is a **tool error**. The message is explicit and safe because the tool author chose to raise +`ToolError`. -The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent. +The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise ToolError(...)` and got a self-correcting agent. + +!!! warning + If an unexpected exception escapes the tool, the SDK logs the traceback on the server and returns + `An unexpected error occurred while executing tool `. It never sends the exception value to + the client. Use `ToolError` when the model needs a specific recovery hint. !!! tip Never `return` an error message from a tool. A returned string has `is_error=False`, so to the model (and to every client UI) it looks like the tool worked and that string was the answer. - `raise`. The flag is the signal. + `raise ToolError(...)`. The flag is the signal. ## An error the model cannot fix -Now swap `ValueError` for `MCPError`. +Now swap `ToolError` for `MCPError`. ```python title="server.py" hl_lines="1 3 14" --8<-- "docs_src/handling_errors/tutorial002.py" @@ -72,12 +81,16 @@ Now swap `ValueError` for `MCPError`. The two paths answer two different questions. -* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. +* **Raise `ToolError`** for an expected failure of *execution* that the model can recover from. Include only information that is safe for the client to see: a misspelled title, a row that doesn't exist, or a user-facing validation message. +* Let **unexpected exceptions** propagate when the details are for server operators. The SDK logs the traceback and returns a generic `is_error=True` result. * **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message. -One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`. +One question decides it: **does the model need a safe recovery hint?** Yes -> `ToolError`. No, because +the failure is unexpected or internal -> let the original exception be logged and sanitized. If the +request itself is invalid or unsupported -> `MCPError`. -By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it. +By that test, `get_author` uses `ToolError`: a better title fixes the problem, so the model deserves +to see the message. !!! info `MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional @@ -110,7 +123,7 @@ Notice there is no `is_error=True` half-result here. A resource read either retu A bad argument never reaches your function. -Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint. +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, returning a generic `is_error=True` tool result. The validation details stay in the server log while the model can use the advertised schema to correct its arguments. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint. It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. @@ -122,9 +135,10 @@ It means a whole class of `raise` statements you don't write: don't re-validate ## Recap -* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. +* Raise **`ToolError`** in a tool -> the call returns `is_error=True` with your safe message in `content`. The model reads it and can retry. +* Let an **unexpected exception** escape -> the server logs the traceback and the call returns `is_error=True` with a generic message. * Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. -* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`. +* The deciding question: *does the model need a safe recovery hint?* Yes -> `ToolError`. No -> let the SDK sanitize the unexpected error, or raise `MCPError` if the request itself should fail. * `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`. * Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. * `from mcp import MCPError`; the error-code constants come from `mcp.types`. diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md index a146e01442..7eb3972030 100644 --- a/docs/servers/structured-output.md +++ b/docs/servers/structured-output.md @@ -183,16 +183,14 @@ The annotation promises `WeatherData`. The upstream response stopped sending `hu !!! check Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails, - and the first lines of the error name the field: + while the validation details stay in the server log: ```text - Error executing tool get_weather: 1 validation error for WeatherData - humidity - Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + An unexpected error occurred while executing tool get_weather ``` - That text comes back as the tool result with `is_error=True`, so the model knows the call failed - instead of confidently reading weather that isn't there. + The generic text comes back as the tool result with `is_error=True`, so the model knows the call + failed instead of receiving internal schema details. Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 5b728cb782..60d07cc504 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -104,15 +104,16 @@ Three new things, all on the parameters: * `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those. !!! check - Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a - tool error **before your function runs**: + Constraints are not decoration. Call the tool with `limit=999` and the SDK rejects it + **before your function runs**: ```text - Input should be less than or equal to 50 + An unexpected error occurred while executing tool search_books ``` - That error goes back to the model as the tool result, and the model reads it and retries with - a valid value. You wrote `le=50` once and got self-correcting agents for free. + The validation details stay in the server log; the client never receives Pydantic's internal + model name, version-specific wording, or documentation URL. The model already has the + constraint in the input schema and can retry with a valid value. !!! info If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`, @@ -166,7 +167,7 @@ A well-behaved client uses them to decide things like *"do I need to ask the use * Type hints **are** the input schema. Defaults make arguments optional. * `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums. * A Pydantic model parameter is how you take a structured "body". -* Bad arguments are rejected for you, with an error the model can read and recover from. +* Bad arguments are rejected for you before the function runs; validation details stay server-side. * `async def` for I/O, plain `def` for everything else. **[Structured Output](structured-output.md)** is what happens to the value you `return`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 75a6652ecc..e3b0572f2e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -76,21 +76,25 @@ async def main() -> None: `__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern. -## `Error executing tool : ` and `Unknown tool: ` +## Tool errors, unexpected errors, and `Unknown 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. Because the tool raises `ToolError`, the safe +message comes back with the request marked as *succeeded*: ```python result.is_error # True -result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.content # [TextContent(text="No forecast for 'Atlantis'.")] result.structured_content # None ``` -`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs. +An unexpected exception uses the same result shape but returns a generic message, while the traceback +is logged on the server. `Unknown tool: get_forecast` is the same shape for a name the server never +registered, and a bad argument is rejected the same way, against the tool's input schema, before your +function ever runs. -The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise. +The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. For a recoverable, model-facing failure, raise `ToolError` with a safe message. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise. ## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` @@ -404,7 +408,8 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key ## Recap * `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely. -* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. +* `call_tool` does not raise for a failing high-level tool. `ToolError`, unexpected tool exceptions, + and `Unknown tool: ...` are results: check `result.is_error`. * `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses. * `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one. * One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py index bf74c46748..0831f5f752 100644 --- a/docs_src/client/tutorial003.py +++ b/docs_src/client/tutorial003.py @@ -2,6 +2,7 @@ from mcp import Client from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.types import TextContent mcp = MCPServer("Bookshop") @@ -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) diff --git a/docs_src/handling_errors/tutorial001.py b/docs_src/handling_errors/tutorial001.py index 003ea94669..9676a10075 100644 --- a/docs_src/handling_errors/tutorial001.py +++ b/docs_src/handling_errors/tutorial001.py @@ -1,4 +1,5 @@ from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError mcp = MCPServer("Bookshop") @@ -9,5 +10,5 @@ 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] diff --git a/docs_src/real_host/tutorial001.py b/docs_src/real_host/tutorial001.py index 1cd39c8c58..aef76a81ab 100644 --- a/docs_src/real_host/tutorial001.py +++ b/docs_src/real_host/tutorial001.py @@ -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] diff --git a/docs_src/troubleshooting/tutorial001.py b/docs_src/troubleshooting/tutorial001.py index e83a552df0..0b0f4840a7 100644 --- a/docs_src/troubleshooting/tutorial001.py +++ b/docs_src/troubleshooting/tutorial001.py @@ -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 @@ 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] diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md index 475a2a0b29..6e3e328475 100644 --- a/examples/stories/error_handling/README.md +++ b/examples/stories/error_handling/README.md @@ -27,20 +27,20 @@ uv run python -m stories.error_handling.client --http --server server_lowlevel `except MCPError` catches protocol errors; the client never auto-raises on `is_error`. - `server.py` — `raise ToolError(...)` vs `raise MCPError(...)`: same `raise` - keyword, opposite wire channel. The tool wrapper re-raises `MCPError` - verbatim and wraps everything else as an `is_error` result. + keyword, opposite wire channel. The tool wrapper re-raises `ToolError` and + `MCPError` through their respective channels; unexpected exceptions are logged + and sanitized. - `server_lowlevel.py` — no wrapper: you build `CallToolResult(is_error=True)` yourself, and `MCPError` is the only way to pick a JSON-RPC error code. ## Caveats -- The "any other exception → `is_error` result" contract on `MCPServer` and the - "uncaught exception → `code=0`" behaviour on `lowlevel.Server` are **not - shown** — the contract is under design and the legacy code is a known spec - divergence. This story will grow those cases once the contract lands. -- `MCPServer` prefixes the execution-error message with - `"Error executing tool {name}: "`; build a `CallToolResult` directly from a - lowlevel handler if you need verbatim control. +- This story does not show an unexpected exception. `MCPServer` logs its traceback + and returns `An unexpected error occurred while executing tool `; use + `ToolError` when the model needs a safe, specific recovery hint. +- `ToolError` messages are sent to the client verbatim. A lowlevel handler still + needs to build a `CallToolResult` directly when it wants full control over the + result shape. ## Spec diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py index 4a7cffb0c0..ec3146ceb4 100644 --- a/examples/stories/error_handling/client.py +++ b/examples/stories/error_handling/client.py @@ -18,9 +18,9 @@ async def main(target: Target, *, mode: str = "auto") -> None: failed = await client.call_tool("divide", {"a": 1, "b": 0}) assert failed.is_error is True, "execution errors ride CallToolResult, not an exception" assert isinstance(failed.content[0], TextContent) - # MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns - # the message verbatim. Assert the substring both produce. - assert "cannot divide by zero" in failed.content[0].text + # ToolError messages are returned verbatim by MCPServer. The lowlevel + # server builds the same result explicitly. + assert failed.content[0].text == "cannot divide by zero" # Protocol error: arrives as a raised MCPError. try: diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py index 96667a5d0c..184c55ed7e 100644 --- a/examples/stories/error_handling/server.py +++ b/examples/stories/error_handling/server.py @@ -12,7 +12,7 @@ def build_server() -> MCPServer: @mcp.tool() def divide(a: float, b: float) -> float: - """Divide a by b. Division by zero is an execution error the LLM should see.""" + """Divide a by b. Division by zero is an expected tool error.""" if b == 0: # ToolError is caught by the tool wrapper and returned as # CallToolResult(is_error=True) — the LLM reads the message and can diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index f10363698b..0831722dba 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -42,8 +42,8 @@ uv run python -m stories.refund_desk.client --http declining the scope question aborts the whole `cents` chain with an error containing the framework's `Resolver for parameter 'scope' could not resolve: elicitation was decline` - (the client sees it behind the usual `Error executing tool refund_order:` - prefix); `restock` keeps the `ElicitationResult` union, so declining restock + (the client sees the `ToolError` message directly); `restock` keeps the + `ElicitationResult` union, so declining restock still refunds — just with `restocked: false`. - `client.py` — the scope counter proves memoization from outside: one call consumes `refund_scope` from two resolvers but the question fires once. diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 26425c1338..bc347b7ce7 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -13,6 +13,7 @@ from pydantic_core import core_schema from typing_extensions import TypeAliasType +from mcp.server.mcpserver.exceptions import ToolError from mcp.server.session import ServerSession ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel) @@ -116,7 +117,7 @@ async def elicit_with_validation( For sensitive data like credentials or OAuth flows, use elicit_url() instead. Raises: - ValueError: If the client accepted the elicitation without supplying + ToolError: If the client accepted the elicitation without supplying content, or with content that does not match the requested schema. """ json_schema = render_elicitation_schema(schema) @@ -129,13 +130,11 @@ async def elicit_with_validation( if result.action == "accept": if result.content is None: - raise ValueError("Received an accepted elicitation with no content") + raise ToolError("Received an accepted elicitation with no content") try: validated_data = schema.model_validate(result.content) except ValidationError as e: - raise ValueError( - "Received an accepted elicitation whose content does not match the requested schema" - ) from e + raise ToolError("Received an accepted elicitation whose content does not match the requested schema") from e return AcceptedElicitation(data=validated_data) if result.action == "decline": return DeclinedElicitation() diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 23248707a3..72ee0d4463 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -16,6 +16,7 @@ ) from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata +from mcp.server.mcpserver.utilities.logging import get_logger from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError from mcp.shared.tool_name_validation import validate_and_warn_tool_name @@ -25,6 +26,9 @@ from mcp.server.mcpserver.context import Context +logger = get_logger(__name__) + + class Tool(BaseModel): """Internal tool registration info.""" @@ -129,7 +133,7 @@ async def run( """Run the tool with arguments. Raises: - ToolError: If the tool function raises during execution. + ToolError: If the tool function raises a handled or unexpected error during execution. """ try: pass_directly: dict[str, Any] = {} @@ -177,5 +181,10 @@ async def run( # it as a top-level JSON-RPC error rather than wrapping it as a # `CallToolResult(isError=True)` execution failure. raise - except Exception as e: - raise ToolError(f"Error executing tool {self.name}: {e}") from e + except ToolError: + raise + except Exception: + # Intentional broad catch at the tool execution boundary: arbitrary + # tool exceptions must not cross the MCP boundary with their value. + logger.exception("Error executing tool %s", self.name) + raise ToolError(f"An unexpected error occurred while executing tool {self.name}") from None diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index c8292d989b..9b18fa0073 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -85,13 +85,13 @@ async def test_call_tool_result_has_three_things_to_read() -> None: async def test_a_raising_tool_is_a_result_not_an_exception() -> None: - """tutorial003 `!!! check`: the exception's message comes back in content with is_error=True.""" + """tutorial003 `!!! check`: an explicit ToolError comes back in content with is_error=True.""" async with Client(tutorial003.mcp) as client: result = await client.call_tool("lookup_book", {"title": "Solaris"}) assert result.is_error (block,) = result.content assert isinstance(block, TextContent) - assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog." + assert block.text == "No book titled 'Solaris' in the catalog." assert result.structured_content is None diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 8474d55e4f..ca689e489f 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -135,9 +135,7 @@ async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> assert result.is_error assert isinstance(result.content[0], TextContent) - assert result.content[0].text == ( - "Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline" - ) + assert result.content[0].text == ("Resolver for parameter 'backorder' could not resolve: elicitation was decline") @pytest.mark.parametrize("mode", ["legacy", "auto"]) diff --git a/tests/docs_src/test_deprecated.py b/tests/docs_src/test_deprecated.py index 090ca61643..987081d1bc 100644 --- a/tests/docs_src/test_deprecated.py +++ b/tests/docs_src/test_deprecated.py @@ -120,17 +120,15 @@ def test_mcp_deprecation_warning_is_a_user_warning() -> None: async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None: """The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail. - Under the error filter the warning becomes the raised exception, the tool manager - wraps it, and the result is exactly the tool error the page quotes. + Under the error filter the warning becomes an unexpected tool exception, which is + logged server-side and sanitized in the result. """ async with Client(mcp) as client: result = await client.call_tool("old_log", {}) assert result.is_error [content] = result.content assert isinstance(content, TextContent) - assert content.text == ( - "Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)." - ) + assert content.text == "An unexpected error occurred while executing tool old_log" async def test_filterwarnings_ignore_silences_the_whole_category() -> None: diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py index 17933816bd..10e3a7137b 100644 --- a/tests/docs_src/test_elicitation.py +++ b/tests/docs_src/test_elicitation.py @@ -124,7 +124,9 @@ async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert "does not match the requested schema" in result.content[0].text + assert result.content[0].text == ( + "Received an accepted elicitation whose content does not match the requested schema" + ) class Address(BaseModel): @@ -164,10 +166,7 @@ async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None: result = await client.call_tool("sign_up", {}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert result.content[0].text == ( - "Error executing tool sign_up: Elicitation schema field 'address' rendered as " - "{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition" - ) + assert result.content[0].text == "An unexpected error occurred while executing tool sign_up" async def test_a_literal_field_passes_the_gate_as_an_enum() -> None: diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py index 0c2629169c..ff9cef9912 100644 --- a/tests/docs_src/test_handling_errors.py +++ b/tests/docs_src/test_handling_errors.py @@ -10,14 +10,12 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] -async def test_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None: - """tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`.""" +async def test_an_explicit_tool_error_is_the_message_the_model_reads() -> None: + """tutorial001: an explicit `ToolError` comes back as `is_error=True` with its message in `content`.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("get_author", {"title": "Nothing"}) assert result.is_error - assert result.content == [ - TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") - ] + assert result.content == [TextContent(type="text", text="No book titled 'Nothing' in the catalog.")] assert result.structured_content is None @@ -35,7 +33,7 @@ async def test_a_bad_argument_never_reaches_the_function() -> None: result = await client.call_tool("get_author", {"title": 42}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert "Input should be a valid string" in result.content[0].text + assert result.content[0].text == "An unexpected error occurred while executing tool get_author" async def test_mcp_error_makes_the_call_itself_fail() -> None: @@ -72,9 +70,7 @@ async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> async with Client(tutorial001.mcp, raise_exceptions=True) as client: result = await client.call_tool("get_author", {"title": "Nothing"}) assert result.is_error - assert result.content == [ - TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") - ] + assert result.content == [TextContent(type="text", text="No book titled 'Nothing' in the catalog.")] async def test_a_title_the_template_knows_reads_normally() -> None: diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py index c0b900d2d3..796093e21c 100644 --- a/tests/docs_src/test_structured_output.py +++ b/tests/docs_src/test_structured_output.py @@ -157,8 +157,7 @@ async def test_return_value_is_validated_against_the_schema() -> None: assert result.is_error assert result.structured_content is None assert isinstance(result.content[0], TextContent) - assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData") - assert "humidity\n Field required" in result.content[0].text + assert result.content[0].text == "An unexpected error occurred while executing tool get_weather" async def test_structured_output_false_opts_out() -> None: diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index c4051794f4..0255f87cd6 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -80,13 +80,13 @@ async def test_field_constraints_land_in_the_schema() -> None: assert props["genre"]["anyOf"][0]["enum"] == ["fiction", "non-fiction", "poetry"] -async def test_constraint_violation_is_an_error_the_model_can_read() -> None: +async def test_constraint_violation_is_sanitized_in_the_tool_result() -> None: """tutorial003: an out-of-range argument is rejected by the schema, not by your code.""" async with Client(tutorial003.mcp) as client: result = await client.call_tool("search_books", {"query": "dune", "limit": 999}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert "less than or equal to 50" in result.content[0].text + assert result.content[0].text == "An unexpected error occurred while executing tool search_books" async def test_pydantic_model_parameter() -> None: diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py index 9c94b643c1..40f4384277 100644 --- a/tests/docs_src/test_troubleshooting.py +++ b/tests/docs_src/test_troubleshooting.py @@ -74,13 +74,11 @@ async def test_a_client_outside_its_async_with_refuses_every_call() -> None: async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None: - """The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`.""" + """A deliberate `ToolError` is a result, not an exception. Nothing to `except`.""" async with Client(tutorial001.mcp) as client: result = await client.call_tool("forecast", {"city": "Atlantis"}) assert result.is_error - assert result.content == [ - TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.") - ] + assert result.content == [TextContent(type="text", text="No forecast for 'Atlantis'.")] async def test_an_unknown_tool_is_the_same_kind_of_result() -> None: diff --git a/tests/interaction/README.md b/tests/interaction/README.md index 3060a240c1..6a852b7a86 100644 --- a/tests/interaction/README.md +++ b/tests/interaction/README.md @@ -247,7 +247,7 @@ many requirements at once; if the assertions would be separate, write separate t | the result of a transformation (arguments → output, exception → error result) | `result == snapshot(...)` of the full object, so any field the implementation adds or drops fails the test | | pass-through of an opaque value (`_meta`, cursors) | identity against the same variable that was sent — a snapshot of a pass-through value only matches the input because a human checked two literals correspond | | an error | `pytest.raises(MCPError)` and a snapshot of `exc.value.error` when the message is the SDK's own; a plain `==` on `.code` against the `mcp_types` constant when it is not | -| third-party output embedded in a result (validation messages) | the stable prefix only — never pin text that changes with a dependency upgrade | +| third-party output embedded in a result (validation messages) | the stable, sanitized result contract; only pin a prefix when the API intentionally preserves one | ### Notifications and concurrency diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 964a1829d2..b51f935923 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1016,15 +1016,16 @@ def __post_init__(self) -> None: "mcpserver:tool:handler-throws": Requirement( source="sdk", behavior=( - "An exception raised by a tool function (ToolError or otherwise) is caught and returned as a " - "tool result with isError true and the failure text in content; it does not become a JSON-RPC error." + "An exception raised by a tool function is caught and returned as a tool result with isError true " + "rather than a JSON-RPC error. ToolError messages are preserved; unexpected exception details are " + "logged server-side and replaced with a generic message." ), ), "mcpserver:tool:input-validation": Requirement( source=f"{SPEC_BASE_URL}/server/tools#error-handling", behavior=( - "Arguments that fail the tool's input validation produce a tool execution error (isError true " - "with the validation failure described in content) without invoking the function." + "Arguments that fail the tool's input validation produce a sanitized tool execution error (isError true) " + "without invoking the function; validation details remain in server logs." ), ), "mcpserver:tool:naming-validation": Requirement( diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 8409e50207..60c3077f47 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -123,8 +123,8 @@ async def test_get_prompt_with_a_wrong_type_argument_is_rejected_before_the_func The decorated function is wrapped in pydantic's validate_call, so a value that cannot be coerced to the parameter's annotation fails before the body executes. The function body - raises NotImplementedError to prove it never ran. The error is wrapped in the SDK's stable - rendering-error prefix; the body of the message is raw pydantic output and is not asserted. + raises NotImplementedError to prove it never ran. The error is logged server-side and the + client receives the raw validation message with the stable rendering-error prefix. """ mcp = MCPServer("prompter") diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a6418ac9c5..3800284b23 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -85,8 +85,8 @@ async def test_call_tool_function_exception_becomes_error_result(connect: Connec """An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error. The function's `-> str` annotation gives the tool a derived output schema, but the error - result is built before any schema validation runs, so no validation failure is layered on - top of the original exception. + result is built before any schema validation runs, so the unexpected exception is sanitized + instead of being exposed to the client. """ mcp = MCPServer("errors") @@ -98,7 +98,9 @@ def explode() -> str: result = await client.call_tool("explode", {}) assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True) + CallToolResult( + content=[TextContent(text="An unexpected error occurred while executing tool explode")], is_error=True + ) ) @@ -115,7 +117,7 @@ def flux() -> str: result = await client.call_tool("flux", {}) assert unstamped(result) == snapshot( - CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True) + CallToolResult(content=[TextContent(text="flux capacitor offline")], is_error=True) ) @@ -234,12 +236,11 @@ def add(a: int, b: int) -> str: async with connect(mcp) as client: result = await client.call_tool("add", {"b": 3}) - # The description is raw pydantic output -- it embeds a pydantic-version-specific - # errors.pydantic.dev URL and the internal `addArguments` model name -- so only the stable - # prefix is asserted; a full snapshot would break on every pydantic upgrade. + # Validation details can include pydantic-version-specific URLs and internal model names, + # so the client receives the same generic message as other unexpected exceptions. assert result.is_error is True assert isinstance(result.content[0], TextContent) - assert result.content[0].text.startswith("Error executing tool add: 1 validation error") + assert result.content[0].text == "An unexpected error occurred while executing tool add" @requirement("mcpserver:output-schema:server-validate") @@ -252,8 +253,8 @@ async def test_tool_with_output_schema_returning_mismatched_structured_content_i A tool annotated `Annotated[CallToolResult, Model]` returns a hand-built CallToolResult while declaring `Model` as its output schema; MCPServer validates the supplied structured_content against that schema before returning. The two cases -- a content shape that does not match, - and no structured content at all -- both fail that validation and are reported as is_error - results carrying the (raw pydantic) validation error wrapped in the SDK's stable prefix. + and no structured content at all -- both fail that validation and are reported as sanitized + is_error results. The validation details stay in the server log. """ mcp = MCPServer("forecaster") @@ -273,16 +274,14 @@ def missing() -> Annotated[CallToolResult, Weather]: mismatched_result = await client.call_tool("mismatched", {}) missing_result = await client.call_tool("missing", {}) - # The body of each message is raw pydantic ValidationError output (model name, field paths, - # an errors.pydantic.dev URL) and changes across pydantic versions, so only the SDK's stable - # prefix is asserted. + # Validation details are kept in the server log rather than returned to the client. assert mismatched_result.is_error is True assert isinstance(mismatched_result.content[0], TextContent) - assert mismatched_result.content[0].text.startswith("Error executing tool mismatched: 2 validation errors") + assert mismatched_result.content[0].text == "An unexpected error occurred while executing tool mismatched" assert missing_result.is_error is True assert isinstance(missing_result.content[0], TextContent) - assert missing_result.content[0].text.startswith("Error executing tool missing: 1 validation error") + assert missing_result.content[0].text == "An unexpected error occurred while executing tool missing" @requirement("mcpserver:tool:duplicate-name") diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index aa5ced266a..b11818d89d 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -1048,7 +1048,10 @@ async def empty_accept(context: ClientRequestContext, params: ElicitRequestParam result = await client.call_tool("tool", {}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert "no content" in result.content[0].text + if mode == "auto": + assert "received an accepted elicitation with no content" in result.content[0].text + else: + assert result.content[0].text == "Received an accepted elicitation with no content" @pytest.mark.anyio @@ -1425,12 +1428,12 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: assert result.is_error assert isinstance(result.content[0], TextContent) text = result.content[0].text - assert "does not match the requested schema" in text - assert "errors.pydantic.dev" not in text if mode == "auto": - assert "Resolver" in text # the input_required transport names the offending resolver key + assert "does not match the requested schema" in text + assert "Resolver" in text else: - assert "Received an accepted elicitation" in text # the legacy path has no wire key to name + assert text == "Received an accepted elicitation whose content does not match the requested schema" + assert "errors.pydantic.dev" not in text @pytest.mark.anyio @@ -2816,7 +2819,7 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: assert result.is_error assert isinstance(result.content[0], TextContent) assert result.content[0].text == snapshot( - "Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline" + "Resolver for parameter 'login' could not resolve: elicitation was decline" ) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 48e900dcab..6a0155c376 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -288,7 +288,7 @@ async def test_tool_exception_handling(self): assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) - assert "Test error" in content.text + assert content.text == "An unexpected error occurred while executing tool error_tool_fn" assert result.is_error is True async def test_tool_error_handling(self): @@ -299,7 +299,7 @@ async def test_tool_error_handling(self): assert len(result.content) == 1 content = result.content[0] assert isinstance(content, TextContent) - assert "Test error" in content.text + assert content.text == "An unexpected error occurred while executing tool error_tool_fn" assert result.is_error is True async def test_tool_error_details(self): @@ -311,7 +311,7 @@ async def test_tool_error_details(self): content = result.content[0] assert isinstance(content, TextContent) assert isinstance(content.text, str) - assert "Test error" in content.text + assert content.text == "An unexpected error occurred while executing tool error_tool_fn" assert result.is_error is True async def test_tool_return_value_conversion(self): diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index 221828c60a..558350415a 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -383,7 +383,7 @@ async def async_tool(x: int, ctx: Context) -> str: assert result == "42" @pytest.mark.anyio - async def test_context_error_handling(self): + async def test_context_error_handling(self) -> None: """Test error handling when context injection fails.""" def tool_with_context(x: int, ctx: Context) -> str: @@ -392,9 +392,11 @@ def tool_with_context(x: int, ctx: Context) -> str: manager = ToolManager() manager.add_tool(tool_with_context) - with pytest.raises(ToolError, match="Error executing tool tool_with_context"): + with pytest.raises(ToolError) as exc_info: await manager.call_tool("tool_with_context", {"x": 42}, context=Context()) + assert str(exc_info.value) == "An unexpected error occurred while executing tool tool_with_context" + class TestToolAnnotations: def test_tool_annotations(self): diff --git a/tests/server/mcpserver/test_url_elicitation_error_throw.py b/tests/server/mcpserver/test_url_elicitation_error_throw.py index 29117e6936..673333ff34 100644 --- a/tests/server/mcpserver/test_url_elicitation_error_throw.py +++ b/tests/server/mcpserver/test_url_elicitation_error_throw.py @@ -106,4 +106,4 @@ async def failing_tool(ctx: Context) -> str: assert result.is_error is True assert len(result.content) == 1 assert isinstance(result.content[0], types.TextContent) - assert "Something went wrong" in result.content[0].text + assert result.content[0].text == "An unexpected error occurred while executing tool failing_tool" diff --git a/tests/server/mcpserver/tools/test_base.py b/tests/server/mcpserver/tools/test_base.py index 0cb583028d..7f0232b056 100644 --- a/tests/server/mcpserver/tools/test_base.py +++ b/tests/server/mcpserver/tools/test_base.py @@ -1,8 +1,11 @@ +import logging + import mcp_types as types import pytest from mcp import Client from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.tools.base import Tool from mcp.shared.exceptions import MCPError @@ -40,10 +43,8 @@ async def needs_sampling() -> str: @pytest.mark.anyio -async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result(): - """SDK-defined: ordinary exceptions from a tool body are execution failures - the LLM should see, so they become ``CallToolResult(isError=True)`` rather - than a protocol-level JSON-RPC error. Pins the other arm of the same branch.""" +async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result() -> None: + """SDK-defined: unexpected tool exceptions become sanitized ``is_error`` results.""" mcp = MCPServer(name="srv") @mcp.tool() @@ -55,3 +56,37 @@ async def boom() -> str: assert isinstance(result, types.CallToolResult) assert result.is_error is True + + +@pytest.mark.anyio +async def test_unexpected_tool_error_is_sanitized_and_logged(caplog: pytest.LogCaptureFixture) -> None: + """SDK-defined: ``Tool.run`` logs exception details but exposes a stable message.""" + secret = "database password" + + def boom() -> str: + raise RuntimeError(secret) + + tool = Tool.from_function(boom) + + with caplog.at_level(logging.ERROR, logger="mcp.server.mcpserver.tools.base"): + with pytest.raises(ToolError) as exc_info: + await tool.run({}, Context()) + + assert str(exc_info.value) == "An unexpected error occurred while executing tool boom" + assert exc_info.value.__cause__ is None + assert secret in caplog.text + + +@pytest.mark.anyio +async def test_tool_error_is_re_raised_without_wrapping() -> None: + """SDK-defined: an explicit ``ToolError`` remains actionable and is not wrapped.""" + + def fail() -> str: + raise ToolError("the requested record is unavailable") + + tool = Tool.from_function(fail) + + with pytest.raises(ToolError) as exc_info: + await tool.run({}, Context()) + + assert str(exc_info.value) == "the requested record is unavailable"