diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md index 6f6c839314..2370750d82 100644 --- a/docs/handlers/logging.md +++ b/docs/handlers/logging.md @@ -49,6 +49,8 @@ The default is `"INFO"`. `logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins. +You also don't need a `try`/`except` in every handler just to record failures. When a tool or resource function raises, the SDK logs it for you. **[Handling errors](../servers/handling-errors.md#what-the-server-logs)** explains what gets logged and at which level. + ## Try it Run the server with the MCP Inspector: diff --git a/docs/migration.md b/docs/migration.md index b094d79f84..ed1b2a58b5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1016,9 +1016,9 @@ 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`). +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`). Likewise, `FunctionResource.read()` and `FileResource.read()` no longer wrap failures in `ValueError`: called directly they raise whatever the function or file read raised, and through `MCPServer.read_resource()` that arrives as `UnexpectedResourceError` (a `ResourceError`) with the original as `__cause__`. ### `Resource` classes reject unknown keyword arguments diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md index 4262f586a7..005396ace9 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 tool and resource 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` and `ResourceError` are the anticipated kind and are logged at `INFO`. ## Recap diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md index 406a8fda6a..1d6e13c0a0 100644 --- a/docs/servers/uri-templates.md +++ b/docs/servers/uri-templates.md @@ -159,7 +159,7 @@ The built-in checks stop the common cases but can't know your sandbox boundary. For filesystem access, use `safe_join` to resolve the path and verify it stays inside your base directory: -```python title="server.py" hl_lines="4 14" +```python title="server.py" hl_lines="5 15" --8<-- "docs_src/uri_templates/tutorial002.py" ``` @@ -199,10 +199,10 @@ These checks are a heuristic pre-filter; for filesystem access, `safe_join` remains the containment boundary. !!! tip - If your handler can't fulfil the request (the file doesn't exist, - the id is unknown), raise an exception. The SDK turns it into an - error response. See **[Handling errors](handling-errors.md)** for the difference between a - protocol error and a tool error. + If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise + `ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message + and the URI. An unexpected exception becomes a generic `-32603` instead. See + **[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**. ## Resources on the low-level Server diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 75a6652ecc..2d53123a69 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 and the tool crashed (rather than raising `ToolError`, being unknown, or rejecting an argument), the traceback is in the **server's log** at `ERROR`, 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/docs_src/uri_templates/tutorial002.py b/docs_src/uri_templates/tutorial002.py index 3d0dc5c36b..94ca94c10d 100644 --- a/docs_src/uri_templates/tutorial002.py +++ b/docs_src/uri_templates/tutorial002.py @@ -1,6 +1,7 @@ from pathlib import Path from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError from mcp.shared.path_security import safe_join mcp = MCPServer("Bookshop") @@ -11,4 +12,7 @@ @mcp.resource("manuals://{+path}") def read_manual(path: str) -> str: """A staff manual page, served from a directory on disk.""" - return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8") + file = safe_join(DOCS_ROOT, path) + if not file.is_file(): + raise ResourceNotFoundError(f"No manual at {path!r}.") + return file.read_text(encoding="utf-8") diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index bf4c26a248..07c4799dc1 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -169,8 +169,12 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent The resource content as either text or bytes Raises: - ResourceNotFoundError: If no resource or template matches the URI. - ResourceError: If template creation or resource reading fails. + ResourceNotFoundError: If no resource or template matches the URI, or the + handler raised it. + ResourceError: If the resource or template function raises `ResourceError`. + UnexpectedResourceError: If the resource or template function raises anything + else. `__cause__` is the original exception. Left uncaught in a tool, this + is logged as the tool's crash, while the two above are not. RuntimeError: If the resource returned an `InputRequiredResult`. """ assert self._mcp_server is not None, "Context is not available outside of a request" diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 239785e9a9..0a39d61981 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -6,20 +6,65 @@ class MCPServerError(Exception): class ResourceError(MCPServerError): - """Error in resource operations.""" + """A resource failure you anticipated. + + Raise this from a resource or resource template handler for a failure you saw + coming: the client receives a `-32603` protocol error carrying your message + (`ResourceNotFoundError` below is the `-32602` variant), and the server logs it + at INFO without a traceback. Any other exception is treated as a crash: the + client gets a generic message naming only the URI, and the server logs the + traceback at ERROR. + + The SDK raises it too, and `UnexpectedResourceError` subclasses it, so + `except ResourceError` around `MCPServer.read_resource()` catches every read + failure, crash or not. + """ 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`. + + The SDK raises this itself, around a crash in a resource or resource template + handler. 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 you anticipated. + + Raise this from a tool (or a resolver) for a failure you saw coming: 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. + A `ResourceError` that escapes the tool (say from `ctx.read_resource()`) counts + as anticipated too. + + 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`. + + The SDK 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 d30c0b3c60..a43f452df3 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -209,5 +209,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}) from exc diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 2ea99c19b6..621b2e9448 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 @@ -217,7 +214,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 @@ -246,5 +245,4 @@ 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 + 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..c8b479bb78 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -18,7 +18,6 @@ from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable -from mcp.shared.exceptions import MCPError # `application/*` types that are textual but predate the `+json`/`+xml` # structured-syntax suffixes, so the suffix rule below can't catch them. @@ -80,33 +79,28 @@ class FunctionResource(Resource): async def read(self) -> str | bytes: """Read the resource by calling the wrapped function.""" - try: - fn = self.fn - if is_async_callable(fn): - result = await fn() - else: - result = await anyio.to_thread.run_sync(self.fn) - - if isinstance(result, InputRequiredResult): - # A static resource function can never read the retry's - # input_responses (it takes no Context), so this can only be a - # mistake — reject it instead of JSON-dumping it as content. - raise ValueError( - "static resources cannot return InputRequiredResult; only resource " - "template functions participate in the multi-round-trip flow" - ) - if isinstance(result, Resource): # pragma: no cover - return await result.read() - elif isinstance(result, bytes): - return result - elif isinstance(result, str): - return result - else: - return pydantic_core.to_json(result, fallback=str, indent=2).decode() - except MCPError: - raise - except Exception as e: - raise ValueError(f"Error reading resource {self.uri}: {e}") + fn = self.fn + if is_async_callable(fn): + result = await fn() + else: + result = await anyio.to_thread.run_sync(self.fn) + + if isinstance(result, InputRequiredResult): + # A static resource function can never read the retry's + # input_responses (it takes no Context), so this can only be a + # mistake — reject it instead of JSON-dumping it as content. + raise ValueError( + "static resources cannot return InputRequiredResult; only resource " + "template functions participate in the multi-round-trip flow" + ) + if isinstance(result, Resource): # pragma: no cover + return await result.read() + elif isinstance(result, bytes): + return result + elif isinstance(result, str): + return result + else: + return pydantic_core.to_json(result, fallback=str, indent=2).decode() @classmethod def from_function( @@ -183,12 +177,9 @@ def validate_text_encoding(cls, encoding: str | None) -> str | None: async def read(self) -> str | bytes: """Read the file content.""" - try: - 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}") + 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)) class HttpResource(Resource): @@ -228,18 +219,12 @@ def list_files(self) -> list[Path]: # pragma: no cover if not self.path.is_dir(): raise NotADirectoryError(f"Not a directory: {self.path}") - try: - 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}") + 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("*")) async def read(self) -> str: # Always returns JSON string # pragma: no cover """Read the directory listing.""" - try: - 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}") + 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) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 70e45329c5..ac5aebe344 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,13 @@ 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: + # %r keeps peer-supplied text (names, pydantic messages) on one line. + 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 +445,13 @@ 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)}) + 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 +512,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,23 +571,23 @@ 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) - resource = await self._resource_manager.get_resource(uri, context) - if isinstance(resource, InputRequiredResult): - return resource - try: + resource = await self._resource_manager.get_resource(uri, context) + if isinstance(resource, InputRequiredResult): + return resource content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] - except MCPError: + except (MCPError, ResourceError): 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 + raise UnexpectedResourceError(f"Error reading resource {uri}") from exc def add_tool( self, @@ -1293,7 +1315,7 @@ async def get_prompt( except MCPError: raise except Exception as e: - logger.exception(f"Error getting prompt {name}") + # Not logged here: the dispatcher boundary logs it once. 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..0768372c39 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -5,9 +5,15 @@ from typing import TYPE_CHECKING, Any from mcp_types import Icon, InputRequiredResult, ToolAnnotations -from pydantic import BaseModel, Field - -from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from pydantic import BaseModel, Field, ValidationError + +from mcp.server.mcpserver.exceptions import ( + InvalidSignature, + ResourceError, + ToolError, + UnexpectedResourceError, + UnexpectedToolError, +) from mcp.server.mcpserver.resolve import ( build_resolver_plans, find_resolved_parameters, @@ -128,21 +134,37 @@ 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` or `ResourceError`. + UnexpectedToolError: If argument validation, the tool function, or a + resolver raises anything else, or the 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: 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 + except MCPError: + raise + except Exception as exc: + # A custom validator or default_factory that raises is a crash. + raise UnexpectedToolError(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 +176,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 +200,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, and the wrapper's type tells the server whether to log a crash. + except (UnexpectedToolError, UnexpectedResourceError) as exc: + # A nested tool call or resource read crashed: still a crash here. + raise UnexpectedToolError(f"Error executing tool {self.name}: {exc}") from exc + except (ToolError, ResourceError) as exc: + # Raised deliberately by the tool, a resolver, or a resource it read. + 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/docs_src/test_uri_templates.py b/tests/docs_src/test_uri_templates.py index 03e12d8174..16744fd447 100644 --- a/tests/docs_src/test_uri_templates.py +++ b/tests/docs_src/test_uri_templates.py @@ -139,6 +139,19 @@ async def test_safe_join_serves_a_file_inside_the_base_directory( assert content.text == "# Printer setup" +async def test_a_missing_manual_is_resource_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """tutorial002 and the closing tip: a path with no file behind it is `-32602` with the handler's message.""" + monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path) + async with Client(tutorial002.mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("manuals://printing/missing.md") + assert exc.value.error == ErrorData( + code=INVALID_PARAMS, + message="No manual at 'printing/missing.md'.", + data={"uri": "manuals://printing/missing.md"}, + ) + + def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None: """tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`.""" with pytest.raises(PathEscapeError): diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 86725bcb4f..38b250c505 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1307,8 +1307,15 @@ def __post_init__(self) -> None: "mcpserver:resource:read-throws-surfaced": Requirement( source="sdk", behavior=( - "A resource function that raises is surfaced to the caller as a JSON-RPC error response " - "(-32603 Internal error), with the original exception text withheld." + "A resource function that raises an unexpected exception is surfaced to the caller as a JSON-RPC " + "error response (-32603 Internal error), with the original exception text withheld." + ), + ), + "mcpserver:resource:static-not-found": Requirement( + source="sdk", + behavior=( + "A static (fixed-URI) resource function that raises ResourceNotFoundError is surfaced as -32602 " + "with the handler's message and the URI in data, the same as from a template function." ), ), "mcpserver:resource:static": Requirement( diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index eadf4794e6..914d3cbbeb 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -14,6 +14,7 @@ from mcp import MCPError from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError from tests._stamp import Unstamp from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -152,6 +153,28 @@ def boom() -> str: ) +@requirement("mcpserver:resource:static-not-found") +async def test_static_resource_function_raising_not_found_is_invalid_params(connect: Connect) -> None: + """ResourceNotFoundError from a fixed-URI resource function reaches the caller as -32602 with its message. + + A static resource can still be absent (a report not generated yet, a file that comes and goes), + and the handler's message passes through exactly as it does from a template function. + """ + mcp = MCPServer("library") + + @mcp.resource("reports://latest") + def latest() -> str: + raise ResourceNotFoundError("no report has been generated yet") + + async with connect(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("reports://latest") + + assert exc_info.value.error == snapshot( + ErrorData(code=-32602, message="no report has been generated yet", data={"uri": "reports://latest"}) + ) + + @requirement("mcpserver:resource:duplicate-name") async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first( connect: Connect, unstamped: Unstamp diff --git a/tests/server/mcpserver/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py index db9f73e935..8149f5bee0 100644 --- a/tests/server/mcpserver/resources/test_file_resources.py +++ b/tests/server/mcpserver/resources/test_file_resources.py @@ -178,7 +178,7 @@ async def test_missing_file_error(temp_file: Path): name="test", path=missing, ) - with pytest.raises(ValueError, match="Error reading file"): + with pytest.raises(FileNotFoundError): await resource.read() @@ -192,7 +192,7 @@ 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(PermissionError): await resource.read() 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..d38ddd840c 100644 --- a/tests/server/mcpserver/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -80,7 +80,7 @@ def get_data() -> dict[str, str]: @pytest.mark.anyio async def test_error_handling(self): - """Test error handling in FunctionResource.""" + """read() lets the function's own exception propagate; MCPServer.read_resource does the wrapping.""" def failing_func() -> str: raise ValueError("Test error") @@ -90,8 +90,9 @@ def failing_func() -> str: name="test", fn=failing_func, ) - with pytest.raises(ValueError, match="Error reading resource function://test"): + with pytest.raises(ValueError) as exc: await resource.read() + assert str(exc.value) == "Test error" @pytest.mark.anyio async def test_basemodel_conversion(self): @@ -258,6 +259,6 @@ def ask() -> InputRequiredResult: with pytest.raises(ValueError) 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" + "static resources cannot return InputRequiredResult; " + "only resource template functions participate in the multi-round-trip flow" ) diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index aa5ced266a..966bb94ed1 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -1,6 +1,7 @@ """Tests for resolver dependency injection (MRTR) on MCPServer tools.""" import json +import logging from collections.abc import Callable from datetime import datetime from typing import Annotated, Any, Literal, TypeVar, cast @@ -1761,10 +1762,13 @@ async def listy(login: Annotated[Login, Resolve(lookup)]) -> list[str]: @pytest.mark.anyio -async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error(): +async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error( + caplog: pytest.LogCaptureFixture, +): # The annotated form of this combination is rejected at registration; a body # that returns an InputRequiredResult without declaring it fails loudly at the # same boundary instead of silently fighting the resolvers for the channel. + # It is an authoring bug, so it is logged as a crash rather than at INFO. mcp = MCPServer(name="DynamicChannelClash", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: @@ -1774,11 +1778,14 @@ async def lookup(ctx: Context) -> Login: async def sneaky(login: Annotated[Login, Resolve(lookup)]): return InputRequiredResult(input_requests={}, request_state="opaque") + caplog.set_level(logging.INFO) async with Client(mcp) as client: result = await client.call_tool("sneaky", {}) assert result.is_error assert isinstance(result.content[0], TextContent) assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text + records = [(r.levelname, r.getMessage()) for r in caplog.records if r.name == "mcp.server.mcpserver.server"] + assert records == [("ERROR", "Tool 'sneaky' raised an unexpected exception")] def test_question_digest_pins_the_rendered_question(): diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 81b490c544..7170b529a6 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 AfterValidator, 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, @@ -2219,6 +2228,643 @@ def thing() -> str: assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]} +def _cause_chain(exc: BaseException | None) -> list[BaseException]: + """`exc` and everything it explicitly chains back to via `__cause__` (`raise ... from ...`).""" + chain: list[BaseException] = [] + while exc is not None: + chain.append(exc) + exc = exc.__cause__ + 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_argument_validator_that_crashes_is_the_tools_crash(caplog: pytest.LogCaptureFixture): + """SDK-defined: pydantic only turns ValueError/AssertionError into ValidationError, so a validator + raising anything else is a bug in the tool's schema and is wrapped and logged as a crash.""" + mcp = MCPServer() + raised = TypeError("codes are compared as integers") + + def check(code: str) -> str: + raise raised + + @mcp.tool() + def redeem(code: Annotated[str, AfterValidator(check)]) -> str: + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("redeem", {"code": "SAVE10"}) + with pytest.raises(UnexpectedToolError) as exc: + await mcp.call_tool("redeem", {"code": "SAVE10"}) + + assert result.content == [ + TextContent(type="text", text="Error executing tool redeem: codes are compared as integers") + ] + assert exc.value.__cause__ is raised + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'redeem' raised an unexpected exception", True)]) + + +async def test_argument_validator_raising_mcp_error_is_a_protocol_error(caplog: pytest.LogCaptureFixture): + """SDK-defined: MCPError keeps its meaning wherever it is raised, including inside an argument + validator: the request fails with that code and MCPServer logs nothing.""" + mcp = MCPServer() + + def check(code: str) -> str: + raise MCPError(code=INVALID_PARAMS, message="codes are issued per session") + + @mcp.tool() + def redeem(code: Annotated[str, AfterValidator(check)]) -> str: + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("redeem", {"code": "SAVE10"}) + + assert exc.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="codes are issued per session")) + assert _server_records(caplog) == [] + + +async def test_resource_error_escaping_a_tool_is_anticipated(caplog: pytest.LogCaptureFixture): + """SDK-defined: a tool that lets ResourceNotFoundError from ctx.read_resource() propagate has + reported an anticipated failure, so it is INFO here just as it is for resources/read.""" + mcp = MCPServer() + + @mcp.resource("books://{title}") + def book(title: str) -> str: + raise ResourceNotFoundError(f"No book titled {title!r}.") + + @mcp.tool() + async def summarise(title: str, ctx: Context) -> str: + await ctx.read_resource(f"books://{title}") + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("summarise", {"title": "Nothing"}) + with pytest.raises(ToolError) as exc: + await mcp.call_tool("summarise", {"title": "Nothing"}) + + assert result.content == [ + TextContent(type="text", text="Error executing tool summarise: No book titled 'Nothing'.") + ] + assert type(exc.value) is ToolError + assert _server_records(caplog) == snapshot( + [("INFO", "Tool 'summarise' failed: \"Error executing tool summarise: No book titled 'Nothing'.\"", False)] + ) + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +async def test_resource_crash_escaping_a_tool_is_the_tools_crash(caplog: pytest.LogCaptureFixture): + """SDK-defined: a crashing resource read inside a tool stays a crash under the tool's name, logged + once, with the traceback reaching the resource function's own exception.""" + mcp = MCPServer() + raised = ConnectionError("catalog database unreachable") + + @mcp.resource("books://{title}") + def book(title: str) -> str: + raise raised + + @mcp.tool() + async def summarise(title: str, ctx: Context) -> str: + await ctx.read_resource(f"books://{title}") + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("summarise", {"title": "Dune"}) + + assert result.content == [ + TextContent( + type="text", + text="Error executing tool summarise: Error creating resource from template books://Dune", + ) + ] + assert _server_records(caplog) == snapshot([("ERROR", "Tool 'summarise' raised an unexpected exception", True)]) + assert raised in _cause_chain(_logged_exception(caplog)) + + +async def test_tool_that_recovers_from_a_missing_resource_logs_nothing(caplog: pytest.LogCaptureFixture): + """SDK-defined: MCPServer.read_resource() itself writes no record, so a tool that catches + ResourceNotFoundError and carries on leaves the log clean.""" + mcp = MCPServer() + + @mcp.resource("books://{title}") + def book(title: str) -> str: + raise ResourceNotFoundError(f"No book titled {title!r}.") + + @mcp.tool() + async def summarise(title: str, ctx: Context) -> str: + try: + await ctx.read_resource(f"books://{title}") + except ResourceNotFoundError: + return "not in the catalog" + raise NotImplementedError + + caplog.set_level(logging.INFO) + async with Client(mcp) as client: + result = await client.call_tool("summarise", {"title": "Nothing"}) + + assert result.content == [TextContent(type="text", text="not in the catalog")] + assert _server_records(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] = []