diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index 6f6c839314..bac877a8d3 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -70,6 +70,8 @@ went to standard error: the terminal, not the wire. don't want log lines, you want spans. Your server already emits them: the SDK traces every message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**. +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#what-the-server-logs)** explains what gets logged and at which level. + ## Recap * The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it. diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..e59b4a6ac6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1016,7 +1016,7 @@ except MCPError as e: ### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) -Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. +Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a resource handler (static or template) that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md index 4262f586a7..9e7cedff2f 100644 --- a/docs/servers/handling-errors.md +++ b/docs/servers/handling-errors.md @@ -115,10 +115,27 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. !!! info - Everything on this page is what a **client** sees, and the in-memory `Client` you'll write - tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error - back into a traceback: by the time that flag could act, your exception is already the - `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern. + Everything so far is what a **client** sees, and the in-memory `Client` you'll write tests + with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing tool's + exception back to the caller: by the time that flag could act, your exception is already the + `is_error=True` result. Assert on the result. If you need the traceback, it is in the server's + log (next section), and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern. + +## What the server logs + +The server also logs these failures, and how it logs them depends on whether you anticipated the failure. + +`get_author` raised a plain `ValueError`. The model got the message, but the SDK can't tell that you raised it on purpose, so it treats the call as a crash and logs it at `ERROR` with the full traceback. That is what you want on the day the exception is a `KeyError` from deep inside a library and the result text says only `'id'`. + +When the failure is one you planned for, say so with `ToolError`: + +```python title="server.py" hl_lines="2 12-13" +--8<-- "docs_src/handling_errors/tutorial004.py" +``` + +`ToolError` comes from `mcp.server.mcpserver.exceptions`. The model reads exactly what it read before. The difference is in your log, where a `ToolError` is a single `INFO` line with no traceback, so a production log at `WARNING` stays quiet until something is actually broken. Bad arguments and unknown tool names are logged at `INFO` too, because those are the caller's mistakes rather than yours. + +Resources work the same way. A crashing resource handler is logged at `ERROR` with its traceback, which matters more here because the `-32603` the client receives names only the URI. `ResourceNotFoundError` is an `INFO` line. ## Recap diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md index 406a8fda6a..5a0d1f0575 100644 --- a/docs/servers/uri-templates.md +++ b/docs/servers/uri-templates.md @@ -199,10 +199,10 @@ These checks are a heuristic pre-filter; for filesystem access, `safe_join` remains the containment boundary. !!! tip - If your handler can't fulfil the request (the file doesn't exist, - the id is unknown), raise an exception. The SDK turns it into an - error response. See **[Handling errors](handling-errors.md)** for the difference between a - protocol error and a tool error. + If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise + `ResourceNotFoundError` from `mcp.server.mcpserver.exceptions`. The client gets `-32602` with + your message and the URI. Any other exception is treated as a crash and the client gets a + generic `-32603`. See **[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**. ## Resources on the low-level Server diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 75a6652ecc..49d972ce25 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. +If `` alone doesn't tell you what broke, look in the **server's log**. Unless the tool raised `ToolError`, the exception is logged there at `ERROR` with its traceback, as `Tool '' raised an unexpected exception`. + ## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter. diff --git a/docs_src/handling_errors/tutorial004.py b/docs_src/handling_errors/tutorial004.py new file mode 100644 index 0000000000..9676a10075 --- /dev/null +++ b/docs_src/handling_errors/tutorial004.py @@ -0,0 +1,14 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError + +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.""" + if title not in CATALOG: + raise ToolError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 239785e9a9..a2cd0c1d8c 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -6,20 +6,55 @@ class MCPServerError(Exception): class ResourceError(MCPServerError): - """Error in resource operations.""" + """Error in resource operations. + + When a resource or resource template handler raises this, its message reaches + the client as a `-32603` protocol error. + """ class ResourceNotFoundError(ResourceError): """Resource does not exist. - Raise this from a resource template handler to signal that the requested instance does not exist; - clients receive `-32602` (invalid params) per + Raise this from a resource handler to signal that the requested instance does not exist. + Clients receive `-32602` (invalid params) per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). """ +class UnexpectedResourceError(ResourceError): + """A resource read failed with something other than `ResourceError` or `MCPError`. + + MCPServer raises this itself, around a crash in a resource or resource + template handler or a failed file read. You never raise it. `__cause__` is + the original exception, which the server logs with its traceback. The + message names only the URI, so the original text is withheld from the client. + """ + + class ToolError(MCPServerError): - """Error in tool operations.""" + """A tool failure the model should read. + + Raise this from a tool (or a resolver) for a failure you anticipate: the + call returns `is_error=True` with the message in `content`, and the server + logs it at INFO without a traceback. Any other exception reaches the model + the same way but is treated as a crash and logged at ERROR with its traceback. + + The SDK raises it too, for an unknown tool name and for arguments that fail + the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError` + around `MCPServer.call_tool()` catches every tool failure, crash or not. + """ + + +class UnexpectedToolError(ToolError): + """A tool call failed with something other than `ToolError` or `MCPError`. + + MCPServer raises this itself, around a crash in the tool (or a resolver) or a + return value that fails output conversion. You never raise it. `__cause__` is + the original exception, which the server logs with its traceback before + returning the usual `is_error=True` result. Catch it around + `MCPServer.call_tool()` to tell a crash from a deliberate `ToolError`. + """ class InvalidSignature(Exception): diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 0a010de7d2..253a05348f 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -196,5 +196,5 @@ async def render( return messages except MCPError: raise - except Exception as e: - raise ValueError(f"Error rendering prompt {self.name}: {e}") + except Exception as exc: + raise ValueError(f"Error rendering prompt {self.name}: {exc}") from exc diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 096e821d81..0afa9b4adc 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -11,18 +11,15 @@ from mcp_types import Annotations, Icon, InputRequiredResult from pydantic import BaseModel, Field, validate_call -from mcp.server.mcpserver.exceptions import ResourceError +from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError from mcp.server.mcpserver.resources.types import FunctionResource, Resource from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import 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.path_security import contains_path_traversal, is_absolute_path from mcp.shared.uri_template import UriTemplate -logger = get_logger(__name__) - if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT from mcp.server.mcpserver.context import Context @@ -218,7 +215,9 @@ async def create_resource( carrying the echoed opaque state. Raises: - ResourceError: If creating the resource fails. + ResourceError: If the template function raises `ResourceError`. + UnexpectedResourceError: If the template function raises anything other + than `ResourceError` or `MCPError`. `__cause__` is the original exception. """ try: # Add context to params if needed @@ -247,5 +246,6 @@ async def create_resource( except (ResourceError, MCPError): raise except Exception as exc: - logger.exception(f"Error creating resource from template {uri}") - raise ResourceError(f"Error creating resource from template {uri}") from exc + # Name only the URI: the original text is withheld from the client, and + # the server logs the traceback from `__cause__`. + raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index 2edf342337..f77b32b610 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -16,6 +16,7 @@ from mcp_types import Annotations, Icon, InputRequiredResult from pydantic import Field, validate_call +from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError @@ -79,7 +80,12 @@ class FunctionResource(Resource): fn: Callable[[], Any] = Field(exclude=True) async def read(self) -> str | bytes: - """Read the resource by calling the wrapped function.""" + """Read the resource by calling the wrapped function. + + Raises: + UnexpectedResourceError: If the function raises anything other than + `ResourceError` or `MCPError`. `__cause__` is the original exception. + """ try: fn = self.fn if is_async_callable(fn): @@ -103,10 +109,12 @@ async def read(self) -> str | bytes: return result else: return pydantic_core.to_json(result, fallback=str, indent=2).decode() - except MCPError: + except (MCPError, ResourceError): raise - except Exception as e: - raise ValueError(f"Error reading resource {self.uri}: {e}") + except Exception as exc: + # Name only the URI: the original text is withheld from the client, and + # the server logs the traceback from `__cause__`. + raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc @classmethod def from_function( @@ -187,8 +195,8 @@ async def read(self) -> str | bytes: if self.encoding is None: return await anyio.to_thread.run_sync(self.path.read_bytes) return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding)) - except Exception as e: - raise ValueError(f"Error reading file {self.path}: {e}") + except Exception as exc: + raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc class HttpResource(Resource): @@ -232,8 +240,8 @@ def list_files(self) -> list[Path]: # pragma: no cover if self.pattern: return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern)) return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*")) - except Exception as e: - raise ValueError(f"Error listing directory {self.path}: {e}") + except Exception as exc: + raise ValueError(f"Error listing directory {self.path}: {exc}") from exc async def read(self) -> str: # Always returns JSON string # pragma: no cover """Read the directory listing.""" @@ -241,5 +249,5 @@ async def read(self) -> str: # Always returns JSON string # pragma: no cover files = await anyio.to_thread.run_sync(self.list_files) file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()] return json.dumps({"files": file_list}, indent=2) - except Exception as e: - raise ValueError(f"Error reading directory {self.path}: {e}") + except Exception as exc: + raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index bc79c44a36..168a7df672 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -71,7 +71,13 @@ from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context -from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError +from mcp.server.mcpserver.exceptions import ( + ResourceError, + ResourceNotFoundError, + ToolError, + UnexpectedResourceError, + UnexpectedToolError, +) from mcp.server.mcpserver.prompts import Prompt, PromptManager from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, @@ -420,8 +426,16 @@ async def _handle_call_tool( return await self.call_tool(params.name, params.arguments or {}, context) except MCPError: raise - except Exception as e: - return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True) + except Exception as exc: + # A ToolError (deliberate, unknown tool, rejected arguments) is an outcome + # the model already reads in full, so it is one INFO record, repr-quoted to + # keep peer-supplied text on one line. Anything else is a crash in the + # tool: log the traceback that the result text doesn't carry. + if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError): + logger.info("Tool %r failed: %r", params.name, str(exc)) + else: + logger.exception("Tool %r raised an unexpected exception", params.name) + return CallToolResult(content=[TextContent(type="text", text=str(exc))], is_error=True) async def _handle_list_resources( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None @@ -434,10 +448,16 @@ async def _handle_read_resource( context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) try: results = await self.read_resource(params.uri, context) - except ResourceNotFoundError as err: - raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) except ResourceError as err: - raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) + # UnexpectedResourceError wraps a crash whose text is withheld from the + # client, so the traceback goes to the log. Any other ResourceError was + # raised on purpose (or is the SDK's "Unknown resource") and is one INFO record. + if isinstance(err, UnexpectedResourceError): + logger.exception("Resource %r raised an unexpected exception", str(params.uri)) + else: + logger.info("Resource %r failed: %r", str(params.uri), str(err)) + code = INVALID_PARAMS if isinstance(err, ResourceNotFoundError) else INTERNAL_ERROR + raise MCPError(code=code, message=str(err), data={"uri": str(params.uri)}) if isinstance(results, InputRequiredResult): return results contents: list[TextResourceContents | BlobResourceContents] = [] @@ -498,7 +518,15 @@ async def list_tools(self) -> list[MCPTool]: async def call_tool( self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None ) -> CallToolResult | InputRequiredResult: - """Call a tool by name with arguments.""" + """Call a tool by name with arguments. + + Raises: + ToolError: If the tool is unknown, the arguments fail validation, or the + tool (or a resolver) raises `ToolError`. + UnexpectedToolError: If the tool (or a resolver) raises anything other than + `ToolError` or `MCPError`, or its return value fails output conversion. + `__cause__` is the original exception. + """ if context is None: context = Context(mcp_server=self, subscriptions=self._subscriptions) return await self._tool_manager.call_tool(name, arguments, context, convert_result=True) @@ -549,7 +577,10 @@ async def read_resource( Raises: ResourceNotFoundError: If no resource or template matches the URI. - ResourceError: If template creation or resource reading fails. + ResourceError: If the resource or template function raises `ResourceError`. + UnexpectedResourceError: If reading the resource (or creating it from a + template) raises anything other than `ResourceError` or `MCPError`. + `__cause__` is the original exception. """ if context is None: context = Context(mcp_server=self, subscriptions=self._subscriptions) @@ -560,12 +591,14 @@ async def read_resource( try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] - except MCPError: + except (MCPError, ResourceError): + # Includes the UnexpectedResourceError the built-in resource types raise + # around a crash in the function or the file read. raise except Exception as exc: - logger.exception(f"Error getting resource {uri}") - # If an exception happens when reading the resource, we should not leak the exception to the client. - raise ResourceError(f"Error reading resource {uri}") from exc + # A custom Resource subclass whose read() raised: wrap it the same way, + # naming only the URI so the original text is withheld from the client. + raise UnexpectedResourceError(f"Error reading resource {uri}") from exc def add_tool( self, @@ -1293,7 +1326,10 @@ async def get_prompt( except MCPError: raise except Exception as e: - logger.exception(f"Error getting prompt {name}") + # Not logged here: this escapes `_handle_get_prompt` as-is, so the + # dispatcher boundary that turns it into the JSON-RPC error logs it once + # with its traceback (or, in-process with `raise_exceptions=True`, + # hands it to the caller instead). raise ValueError(str(e)) from e diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 23248707a3..cd556e9726 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -5,9 +5,9 @@ from typing import TYPE_CHECKING, Any from mcp_types import Icon, InputRequiredResult, ToolAnnotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError -from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError, UnexpectedToolError from mcp.server.mcpserver.resolve import ( build_resolver_plans, find_resolved_parameters, @@ -128,21 +128,33 @@ async def run( ) -> Any: """Run the tool with arguments. + Every failure other than `MCPError` is raised with its message prefixed + `Error executing tool : `, and `__cause__` set to what was raised. + Raises: - ToolError: If the tool function raises during execution. + ToolError: If the arguments fail validation against the input schema, or + the tool function (or a resolver) raises `ToolError`. + UnexpectedToolError: If the tool function (or a resolver) raises anything + other than `ToolError` or `MCPError`, or its return value fails output + conversion. """ + try: + validated = self.fn_metadata.validate_arguments(arguments) + except ValidationError as exc: + # The caller's arguments don't match the input schema. That is the model's + # mistake to read and correct, so it is reported like a deliberate ToolError. + raise ToolError(f"Error executing tool {self.name}: {exc}") from exc + try: pass_directly: dict[str, Any] = {} if self.context_kwarg is not None: pass_directly[self.context_kwarg] = context - # Resolvers see the same validated arguments the tool body receives: - # validate once and reuse it, so a `default_factory`/stateful validator - # can't hand a by-name resolver a different value than the body. - pre_validated: dict[str, Any] | None = None + # Resolvers see the same validated arguments the tool body receives, so a + # `default_factory`/stateful validator can't hand a by-name resolver a + # different value than the body. if self.resolved_params: - pre_validated = self.fn_metadata.validate_arguments(arguments) - resolved = await resolve_arguments(self.resolved_params, self.resolver_plans, pre_validated, context) + resolved = await resolve_arguments(self.resolved_params, self.resolver_plans, validated, context) if isinstance(resolved, InputRequiredResult): # A resolver still needs client input (>= 2026-07-28): surface the # batched questions instead of running the tool body this round. @@ -154,13 +166,14 @@ async def run( self.is_async, arguments, pass_directly or None, - pre_validated=pre_validated, + pre_validated=validated, ) # Registration rejects the annotated form of this combination; this covers - # a body that returns an InputRequiredResult without declaring it. + # a body that returns an InputRequiredResult without declaring it. It is + # an authoring bug, so it is raised as a crash rather than a ToolError. if self.resolved_params and isinstance(result, InputRequiredResult): - raise ToolError( + raise RuntimeError( "the tool returned an InputRequiredResult but its parameters use Resolve(...); " "a call has one input_required channel, so the multi-round flow is driven " "either by resolvers or by the tool body, not both" @@ -177,5 +190,13 @@ 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 + # Everything else reaches the model as an is_error result under this tool's + # name. The wrapper's type is what tells the server whether to log a crash. + except UnexpectedToolError as exc: + # A nested tool call crashed: still a crash under this tool's name. + raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc + except ToolError as exc: + # Raised deliberately by the tool or a resolver: anticipated. + raise ToolError(f"Error executing tool {self.name}: {exc}") from exc + except Exception as exc: + raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py index 0c2629169c..8872ba7b4c 100644 --- a/tests/docs_src/test_handling_errors.py +++ b/tests/docs_src/test_handling_errors.py @@ -1,9 +1,11 @@ """`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK.""" +import logging + import pytest from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents -from docs_src.handling_errors import tutorial001, tutorial002, tutorial003 +from docs_src.handling_errors import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client, MCPError # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -68,7 +70,8 @@ async def test_resource_not_found_error_maps_to_invalid_params() -> None: async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None: - """The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result.""" + """The `!!! info` before the log section: even `raise_exceptions=True` leaves a failing tool as the + `is_error=True` result.""" async with Client(tutorial001.mcp, raise_exceptions=True) as client: result = await client.call_tool("get_author", {"title": "Nothing"}) assert result.is_error @@ -84,3 +87,42 @@ async def test_a_title_the_template_knows_reads_normally() -> None: (contents,) = result.contents assert isinstance(contents, TextResourceContents) assert contents.text == "Dune by Frank Herbert" + + +async def test_a_plain_exception_is_logged_as_a_crash_with_its_traceback(caplog: pytest.LogCaptureFixture) -> None: + """tutorial001, "What the server logs": the `ValueError` is one ERROR record carrying the traceback.""" + caplog.set_level(logging.INFO) + async with Client(tutorial001.mcp) as client: + await client.call_tool("get_author", {"title": "Nothing"}) + (record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"] + assert record.levelno == logging.ERROR + assert record.exc_info is not None + logged = record.exc_info[1] + assert logged is not None and isinstance(logged.__cause__, ValueError) + + +async def test_tool_error_reads_the_same_to_the_model_and_logs_one_info_line( + caplog: pytest.LogCaptureFixture, +) -> None: + """tutorial004: swapping in `ToolError` leaves the result byte-identical and the log at INFO, no traceback.""" + caplog.set_level(logging.INFO) + async with Client(tutorial004.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.") + ] + records = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"] + assert [(r.levelno, r.exc_info) for r in records] == [(logging.INFO, None)] + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_a_bad_argument_is_an_info_line_not_a_crash(caplog: pytest.LogCaptureFixture) -> None: + """ "What the server logs": schema rejection of the arguments is logged at INFO with no traceback.""" + caplog.set_level(logging.INFO) + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": 42}) + assert result.is_error + records = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"] + assert [(r.levelno, r.exc_info) for r in records] == [(logging.INFO, None)] + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py index 9c94b643c1..7d79e21b5e 100644 --- a/tests/docs_src/test_troubleshooting.py +++ b/tests/docs_src/test_troubleshooting.py @@ -83,6 +83,16 @@ async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None ] +async def test_a_failing_tool_leaves_its_traceback_in_the_server_log(caplog: pytest.LogCaptureFixture) -> None: + """The `Error executing tool` entry's pointer to the server log: the exact ERROR message it names.""" + with caplog.at_level(logging.ERROR, logger="mcp.server.mcpserver.server"): + async with Client(tutorial001.mcp) as client: + await client.call_tool("forecast", {"city": "Atlantis"}) + (record,) = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"] + assert record.getMessage() == "Tool 'forecast' raised an unexpected exception" + assert record.exc_info is not None + + async def test_an_unknown_tool_is_the_same_kind_of_result() -> None: """`Unknown tool: ` travels the same `is_error=True` path as a failing tool.""" async with Client(tutorial001.mcp) as client: diff --git a/tests/server/mcpserver/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py index 042ea422aa..3604bf1b32 100644 --- a/tests/server/mcpserver/resources/test_file_resources.py +++ b/tests/server/mcpserver/resources/test_file_resources.py @@ -6,6 +6,7 @@ import pytest from pydantic import ValidationError +from mcp.server.mcpserver.exceptions import UnexpectedResourceError from mcp.server.mcpserver.resources import FileResource @@ -178,8 +179,10 @@ async def test_missing_file_error(temp_file: Path): name="test", path=missing, ) - with pytest.raises(ValueError, match="Error reading file"): + with pytest.raises(UnexpectedResourceError) as exc: await resource.read() + assert str(exc.value) == "Error reading resource file:///missing.txt" + assert isinstance(exc.value.__cause__, FileNotFoundError) @pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows") @@ -192,7 +195,8 @@ async def test_permission_error(temp_file: Path): # pragma: lax no cover name="test", path=temp_file, ) - with pytest.raises(ValueError, match="Error reading file"): + with pytest.raises(UnexpectedResourceError) as exc: await resource.read() + assert isinstance(exc.value.__cause__, PermissionError) finally: temp_file.chmod(0o644) # Restore permissions diff --git a/tests/server/mcpserver/resources/test_function_resources.py b/tests/server/mcpserver/resources/test_function_resources.py index 5a5c5c48dd..dc57dbc31c 100644 --- a/tests/server/mcpserver/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -7,6 +7,7 @@ from mcp_types import InputRequiredResult from pydantic import BaseModel +from mcp.server.mcpserver.exceptions import UnexpectedResourceError from mcp.server.mcpserver.resources import FunctionResource @@ -80,18 +81,22 @@ def get_data() -> dict[str, str]: @pytest.mark.anyio async def test_error_handling(self): - """Test error handling in FunctionResource.""" + """A crash in the function is wrapped as UnexpectedResourceError naming only the URI, + with the function's own exception as `__cause__`.""" + raised = ValueError("Test error") def failing_func() -> str: - raise ValueError("Test error") + raise raised resource = FunctionResource( uri="function://test", name="test", fn=failing_func, ) - with pytest.raises(ValueError, match="Error reading resource function://test"): + with pytest.raises(UnexpectedResourceError) as exc: await resource.read() + assert str(exc.value) == snapshot("Error reading resource function://test") + assert exc.value.__cause__ is raised @pytest.mark.anyio async def test_basemodel_conversion(self): @@ -255,9 +260,10 @@ def ask() -> InputRequiredResult: return InputRequiredResult(request_state="round-1") resource = FunctionResource(uri="resource://ask", name="ask", fn=ask) - with pytest.raises(ValueError) as exc: + with pytest.raises(UnexpectedResourceError) as exc: await resource.read() - assert str(exc.value) == snapshot( - "Error reading resource resource://ask: static resources cannot return " - "InputRequiredResult; only resource template functions participate in the multi-round-trip flow" + assert str(exc.value) == snapshot("Error reading resource resource://ask") + assert str(exc.value.__cause__) == snapshot( + "static resources cannot return InputRequiredResult; " + "only resource template functions participate in the multi-round-trip flow" ) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 48e900dcab..cd56990f9d 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,7 +1,8 @@ import base64 +import logging from pathlib import Path from types import SimpleNamespace -from typing import Any +from typing import Annotated, Any from unittest.mock import AsyncMock, MagicMock, patch import anyio @@ -24,6 +25,7 @@ ElicitRequestFormParams, ElicitResult, EmbeddedResource, + ErrorData, GetPromptResult, Icon, ImageContent, @@ -41,16 +43,23 @@ TextContent, TextResourceContents, ) -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from starlette.applications import Starlette from starlette.routing import Mount, Route from mcp.client import Client from mcp.server.context import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity -from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity, Resolve, ResourceSecurity +from mcp.server.mcpserver.exceptions import ( + ResourceError, + ResourceNotFoundError, + ToolError, + UnexpectedResourceError, + UnexpectedToolError, +) from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource +from mcp.server.mcpserver.resources import Resource as MCPServerResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.subscriptions import ( InMemorySubscriptionBus, @@ -2233,6 +2242,512 @@ def thing() -> str: assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]} +def _cause_chain(exc: BaseException | None) -> list[BaseException]: + """`exc` and everything it chains back to, explicitly (`__cause__`) or implicitly (`__context__`).""" + chain: list[BaseException] = [] + while exc is not None: + chain.append(exc) + exc = exc.__cause__ or exc.__context__ + return chain + + +def _server_records(caplog: pytest.LogCaptureFixture) -> list[tuple[str, str, bool]]: + """(level, message, has-traceback) for every record MCPServer itself wrote.""" + return [ + (r.levelname, r.getMessage(), r.exc_info is not None) + for r in caplog.records + if r.name == "mcp.server.mcpserver.server" + ] + + +def _logged_exception(caplog: pytest.LogCaptureFixture) -> BaseException: + """The exception attached to the one MCPServer record that carries a traceback.""" + (exc_info,) = [r.exc_info for r in caplog.records if r.name == "mcp.server.mcpserver.server" and r.exc_info] + assert exc_info[1] is not None + return exc_info[1] + + +async def test_tool_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: a tool crash still reaches the model as is_error, and the server logs the + original exception exactly once, at ERROR, with the traceback the result text lacks.""" + mcp = MCPServer() + raised = KeyError("k") + + @mcp.tool() + def lookup() -> str: + raise raised + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("lookup", {}) + + assert result.is_error is True + assert result.content == [TextContent(type="text", text="Error executing tool lookup: 'k'")] + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'lookup' raised an unexpected exception", True)]) + assert raised in _cause_chain(_logged_exception(caplog)) + assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1 + + +async def test_tool_raising_tool_error_is_logged_at_info_without_traceback(caplog: pytest.LogCaptureFixture): + """SDK-defined: ToolError marks an anticipated failure, so the same is_error result is + logged as one INFO record with no traceback rather than as a crash.""" + mcp = MCPServer() + + @mcp.tool() + def forecast(city: str) -> str: + raise ToolError(f"no forecast for {city}") + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("forecast", {"city": "Atlantis"}) + + assert result.is_error is True + assert result.content == [TextContent(type="text", text="Error executing tool forecast: no forecast for Atlantis")] + assert _server_records(caplog) == snapshot( + [("INFO", "Tool 'forecast' failed: 'Error executing tool forecast: no forecast for Atlantis'", False)] + ) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_tool_error_subclass_is_still_anticipated(caplog: pytest.LogCaptureFixture): + """SDK-defined: a user's ToolError subclass is treated like ToolError - INFO, no traceback - + and reaches a programmatic caller as a plain ToolError carrying the tool-name prefix.""" + mcp = MCPServer() + + class QuotaExceeded(ToolError): + pass + + @mcp.tool() + def spend() -> str: + raise QuotaExceeded("daily quota used up") + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("spend", {}) + with pytest.raises(ToolError) as exc: + await mcp.call_tool("spend", {}) + + assert result.is_error is True + assert type(exc.value) is ToolError + assert str(exc.value) == snapshot("Error executing tool spend: daily quota used up") + assert _server_records(caplog) == snapshot( + [("INFO", "Tool 'spend' failed: 'Error executing tool spend: daily quota used up'", False)] + ) + + +async def test_tool_argument_validation_failure_is_logged_at_info_without_traceback( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: arguments the model got wrong are the model's to correct, so the rejection + is logged as one INFO record with no traceback; the message is repr-quoted onto one line.""" + mcp = MCPServer() + + @mcp.tool() + def add(a: int, b: int) -> int: + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": "one", "b": 2}) + + assert result.is_error is True + ((level, message, has_traceback),) = _server_records(caplog) + assert (level, has_traceback) == ("INFO", False) + # pydantic owns the rest of the text; pin only the SDK's part and the single-line rendering. + assert message.startswith("Tool 'add' failed: ") and "Error executing tool add: 1 validation error" in message + assert "\n" not in message + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_tool_argument_validation_failure_chains_directly_to_the_validation_error(): + """SDK-defined: a programmatic caller sees a plain ToolError whose `__cause__` is pydantic's + ValidationError, with no intermediate wrapper.""" + mcp = MCPServer() + + @mcp.tool() + def add(a: int, b: int) -> int: + raise NotImplementedError + + with pytest.raises(ToolError) as exc: + await mcp.call_tool("add", {"a": "one", "b": 2}) + assert type(exc.value) is ToolError + assert isinstance(exc.value.__cause__, ValidationError) + + +async def test_validation_error_raised_inside_the_tool_body_is_a_crash(caplog: pytest.LogCaptureFixture): + """SDK-defined: only the SDK's own argument validation is anticipated; a pydantic + ValidationError from the tool's code is logged as a crash with its traceback.""" + mcp = MCPServer() + + class Row(BaseModel): + n: int + + @mcp.tool() + def parse() -> str: + Row.model_validate({"n": "x"}) + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("parse", {}) + + assert result.is_error is True + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'parse' raised an unexpected exception", True)]) + assert isinstance(_cause_chain(_logged_exception(caplog))[-1], ValidationError) + + +async def test_return_value_failing_the_output_schema_is_a_crash(caplog: pytest.LogCaptureFixture): + """SDK-defined: a return value that doesn't match the declared output schema is the tool's + bug, so it is logged as a crash even though the model still gets an is_error result.""" + mcp = MCPServer() + + class Weather(BaseModel): + temperature: float + + @mcp.tool() + def get_weather() -> Weather: + reading: Any = {"temperature": "warm"} + return reading + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("get_weather", {}) + + assert result.is_error is True + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'get_weather' raised an unexpected exception", True)]) + + +async def test_unknown_tool_is_logged_at_info_without_traceback(caplog: pytest.LogCaptureFixture): + """SDK-defined: a call to a name that was never registered is the caller's mistake, logged + as one INFO record alongside the is_error result.""" + mcp = MCPServer() + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("nope", {}) + + assert result.is_error is True + assert _server_records(caplog) == snapshot([("INFO", "Tool 'nope' failed: 'Unknown tool: nope'", False)]) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_tool_raising_mcp_error_is_not_logged_by_mcpserver(caplog: pytest.LogCaptureFixture): + """SDK-defined: MCPError is a protocol answer the tool chose, so MCPServer writes no record for it.""" + mcp = MCPServer() + + @mcp.tool() + def gated() -> str: + raise MCPError(code=INVALID_PARAMS, message="not for you") + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("gated", {}) + + assert exc.value.error.code == INVALID_PARAMS + assert _server_records(caplog) == [] + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_resolver_raising_tool_error_is_anticipated(caplog: pytest.LogCaptureFixture): + """SDK-defined: a ToolError from a Resolve() resolver is classified like one from the tool + body - INFO, no traceback.""" + mcp = MCPServer(name="resolvers", request_state_security=RequestStateSecurity.ephemeral()) + + async def current_user(ctx: Context) -> str: + raise ToolError("sign in first") + + @mcp.tool() + async def whoami(user: Annotated[str, Resolve(current_user)]) -> str: + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("whoami", {}) + + assert result.content == [TextContent(type="text", text="Error executing tool whoami: sign in first")] + assert _server_records(caplog) == snapshot( + [("INFO", "Tool 'whoami' failed: 'Error executing tool whoami: sign in first'", False)] + ) + + +async def test_resolver_crash_is_logged_as_the_tools_crash(caplog: pytest.LogCaptureFixture): + """SDK-defined: an unexpected exception in a Resolve() resolver is the tool's crash - ERROR + with a traceback reaching the resolver's exception.""" + mcp = MCPServer(name="resolvers", request_state_security=RequestStateSecurity.ephemeral()) + raised = ConnectionError("user directory unreachable") + + async def current_user(ctx: Context) -> str: + raise raised + + @mcp.tool() + async def whoami(user: Annotated[str, Resolve(current_user)]) -> str: + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("whoami", {}) + + assert result.is_error is True + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'whoami' raised an unexpected exception", True)]) + assert raised in _cause_chain(_logged_exception(caplog)) + + +async def test_static_resource_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: the client gets a -32603 naming only the URI, and the withheld original is + logged exactly once, at ERROR, with its traceback.""" + mcp = MCPServer() + raised = RuntimeError("connection pool exhausted") + + @mcp.resource("db://stats") + def stats() -> str: + raise raised + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("db://stats") + + assert exc.value.error == snapshot( + ErrorData(code=INTERNAL_ERROR, message="Error reading resource db://stats", data={"uri": "db://stats"}) + ) + assert _server_records(caplog) == snapshot( + [("ERROR", "Resource 'db://stats' raised an unexpected exception", True)] + ) + assert raised in _cause_chain(_logged_exception(caplog)) + assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1 + + +async def test_resource_template_raising_unexpected_exception_is_logged_once_at_error_with_its_traceback( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: a template handler crash surfaces as -32603 naming only the URI, and the + withheld original is logged exactly once, at ERROR, with its traceback.""" + mcp = MCPServer() + raised = RuntimeError("connection pool exhausted") + + @mcp.resource("db://tables/{table}") + def describe(table: str) -> str: + raise raised + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("db://tables/users") + + assert exc.value.error == snapshot( + ErrorData( + code=INTERNAL_ERROR, + message="Error creating resource from template db://tables/users", + data={"uri": "db://tables/users"}, + ) + ) + assert _server_records(caplog) == snapshot( + [("ERROR", "Resource 'db://tables/users' raised an unexpected exception", True)] + ) + assert raised in _cause_chain(_logged_exception(caplog)) + assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1 + + +async def test_read_resource_wraps_a_crash_as_unexpected_resource_error_chained_to_the_original(): + """SDK-defined: for static and template resources alike, a programmatic caller gets + UnexpectedResourceError naming only the URI, with `__cause__` the handler's own exception.""" + mcp = MCPServer() + raised = RuntimeError("connection pool exhausted") + + @mcp.resource("db://stats") + def stats() -> str: + raise raised + + @mcp.resource("db://tables/{table}") + def describe(table: str) -> str: + raise raised + + with pytest.raises(UnexpectedResourceError) as static: + await mcp.read_resource("db://stats") + with pytest.raises(UnexpectedResourceError) as template: + await mcp.read_resource("db://tables/users") + + assert str(static.value) == snapshot("Error reading resource db://stats") + assert static.value.__cause__ is raised + assert str(template.value) == snapshot("Error creating resource from template db://tables/users") + assert template.value.__cause__ is raised + + +async def test_custom_resource_subclass_crash_is_wrapped_and_logged_like_a_function_resource( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: a hand-written Resource subclass whose read() raises gets the same treatment as + a decorated function - -32603 naming only the URI, one ERROR record chaining to the original.""" + raised = OSError("sensor bus offline") + + class SensorResource(MCPServerResource): + async def read(self) -> str: + raise raised + + mcp = MCPServer() + mcp.add_resource(SensorResource(uri="sensor://temp", name="temp")) + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("sensor://temp") + + assert exc.value.error == snapshot( + ErrorData(code=INTERNAL_ERROR, message="Error reading resource sensor://temp", data={"uri": "sensor://temp"}) + ) + assert _server_records(caplog) == snapshot( + [("ERROR", "Resource 'sensor://temp' raised an unexpected exception", True)] + ) + logged = _logged_exception(caplog) + assert isinstance(logged, UnexpectedResourceError) and logged.__cause__ is raised + + +async def test_static_resource_raising_resource_not_found_error_is_invalid_params_logged_at_info( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: ResourceNotFoundError from a static resource handler passes through as -32602 + with the handler's message, as it does from a template handler, and is logged at INFO.""" + mcp = MCPServer() + + @mcp.resource("reports://latest") + def latest() -> str: + raise ResourceNotFoundError("no report has been generated yet") + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("reports://latest") + + assert exc.value.error == snapshot( + ErrorData(code=INVALID_PARAMS, message="no report has been generated yet", data={"uri": "reports://latest"}) + ) + assert _server_records(caplog) == snapshot( + [("INFO", "Resource 'reports://latest' failed: 'no report has been generated yet'", False)] + ) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_deliberate_resource_error_passes_its_message_through_and_is_logged_at_info( + caplog: pytest.LogCaptureFixture, +): + """SDK-defined: a ResourceError the handler raised on purpose reaches the client as -32603 with + the handler's message, from a static resource as from a template, and is one INFO record each.""" + mcp = MCPServer() + + @mcp.resource("db://stats") + def stats() -> str: + raise ResourceError("stats database is in maintenance") + + @mcp.resource("db://tables/{table}") + def describe(table: str) -> str: + raise ResourceError(f"table {table} is being rebuilt") + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as static: + await client.read_resource("db://stats") + with pytest.raises(MCPError) as template: + await client.read_resource("db://tables/users") + + assert static.value.error == snapshot( + ErrorData(code=INTERNAL_ERROR, message="stats database is in maintenance", data={"uri": "db://stats"}) + ) + assert template.value.error == snapshot( + ErrorData(code=INTERNAL_ERROR, message="table users is being rebuilt", data={"uri": "db://tables/users"}) + ) + assert _server_records(caplog) == snapshot( + [ + ("INFO", "Resource 'db://stats' failed: 'stats database is in maintenance'", False), + ("INFO", "Resource 'db://tables/users' failed: 'table users is being rebuilt'", False), + ] + ) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_prompt_raising_unexpected_exception_is_logged_once(caplog: pytest.LogCaptureFixture): + """SDK-defined: a prompt crash is logged exactly once, by the dispatcher boundary that turns it + into the JSON-RPC error, and not a second time by MCPServer.""" + mcp = MCPServer() + raised = RuntimeError("template store unreachable") + + @mcp.prompt() + def briefing() -> str: + raise raised + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.get_prompt("briefing") + + assert exc.value.error.code == INTERNAL_ERROR + assert _server_records(caplog) == [] + (record,) = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert record.levelno == logging.ERROR + assert record.exc_info is not None and raised in _cause_chain(record.exc_info[1]) + + +async def test_call_tool_wraps_a_crash_as_unexpected_tool_error_chained_to_the_original(): + """SDK-defined: programmatic callers can tell a crash from a deliberate ToolError by type and + reach the original exception through `__cause__`.""" + mcp = MCPServer() + raised = RuntimeError("boom") + + @mcp.tool() + def explode() -> str: + raise raised + + with pytest.raises(UnexpectedToolError) as exc: + await mcp.call_tool("explode", {}) + assert str(exc.value) == snapshot("Error executing tool explode: boom") + assert exc.value.__cause__ is raised + + +async def test_call_tool_keeps_a_deliberate_tool_error_a_plain_tool_error(): + """SDK-defined: a ToolError raised by the tool is re-raised as a plain ToolError carrying the + tool-name prefix, never reclassified as unexpected.""" + mcp = MCPServer() + + @mcp.tool() + def refuse() -> str: + raise ToolError("not today") + + with pytest.raises(ToolError) as exc: + await mcp.call_tool("refuse", {}) + assert type(exc.value) is ToolError + assert str(exc.value) == snapshot("Error executing tool refuse: not today") + + +async def test_nested_tool_crash_stays_unexpected_through_the_outer_tool(caplog: pytest.LogCaptureFixture): + """SDK-defined: when a tool awaits another tool that crashes, the outer wrapper keeps the + UnexpectedToolError classification, so the crash is still logged once with its traceback.""" + mcp = MCPServer() + raised = ZeroDivisionError("division by zero") + + @mcp.tool() + def inner() -> str: + raise raised + + @mcp.tool() + async def outer(ctx: Context) -> str: + await ctx.mcp_server.call_tool("inner", {}) + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("outer", {}) + + assert result.content == [ + TextContent(type="text", text="Error executing tool outer: Error executing tool inner: division by zero") + ] + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'outer' raised an unexpected exception", True)]) + assert raised in _cause_chain(_logged_exception(caplog)) + + async def test_context_exposes_client_capabilities_from_connection(): mcp = MCPServer() seen: list[ClientCapabilities | None] = []