Skip to content

Commit 6fd5e05

Browse files
committed
Wrap crashing validators, treat ResourceError in a tool as anticipated
A custom argument validator that raises something other than ValidationError escaped Tool.run unwrapped, losing the "Error executing tool" prefix and the UnexpectedToolError type. It is now wrapped as a crash, and an MCPError raised there still passes through. A ResourceError (usually ResourceNotFoundError from ctx.read_resource) that escapes a tool body is now classified like a ToolError, since it is the same anticipated outcome resources/read logs at INFO. An UnexpectedResourceError escaping a tool stays a crash. MCPServer.read_resource is now the single place a resource crash is wrapped (plus create_resource for templates), so the built-in Resource types let the original exception propagate to direct callers. Also: trimmed raise-site comments in favour of the exception docstrings, reworded the ToolError and ResourceError docstrings, documented the FunctionResource/FileResource.read change in migration.md, corrected the uri-templates tip and example, and pinned the new cases in tests (including a wire test for ResourceNotFoundError from a static resource).
1 parent 96b5cc8 commit 6fd5e05

19 files changed

Lines changed: 296 additions & 135 deletions

File tree

docs/handlers/logging.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ The default is `"INFO"`.
4949

5050
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
5151

52+
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.
53+
5254
## Try it
5355

5456
Run the server with the MCP Inspector:
@@ -70,8 +72,6 @@ went to standard error: the terminal, not the wire.
7072
don't want log lines, you want spans. Your server already emits them: the SDK traces every
7173
message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**.
7274

73-
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.
74-
7575
## Recap
7676

7777
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1018,7 +1018,7 @@ except MCPError as e:
10181018

10191019
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.
10201020

1021-
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`).
1021+
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__`.
10221022

10231023
### `Resource` classes reject unknown keyword arguments
10241024

docs/servers/handling-errors.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate
123123

124124
## What the server logs
125125

126-
The server also logs these failures, and how it logs them depends on whether you anticipated the failure.
126+
The server also logs tool and resource failures, and how it logs them depends on whether you anticipated the failure.
127127

128128
`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'`.
129129

@@ -135,7 +135,7 @@ When the failure is one you planned for, say so with `ToolError`:
135135

136136
`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.
137137

138-
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.
138+
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`.
139139

140140
## Recap
141141

docs/servers/uri-templates.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ The built-in checks stop the common cases but can't know your sandbox
159159
boundary. For filesystem access, use `safe_join` to resolve the path
160160
and verify it stays inside your base directory:
161161

162-
```python title="server.py" hl_lines="4 14"
162+
```python title="server.py" hl_lines="5 15"
163163
--8<-- "docs_src/uri_templates/tutorial002.py"
164164
```
165165

@@ -200,9 +200,9 @@ These checks are a heuristic pre-filter; for filesystem access,
200200

201201
!!! tip
202202
If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise
203-
`ResourceNotFoundError` from `mcp.server.mcpserver.exceptions`. The client gets `-32602` with
204-
your message and the URI. Any other exception is treated as a crash and the client gets a
205-
generic `-32603`. See **[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.
203+
`ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message
204+
and the URI. An unexpected exception becomes a generic `-32603` instead. See
205+
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.
206206

207207
## Resources on the low-level Server
208208

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ result.structured_content # None
9292

9393
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.
9494

95-
If `<message>` 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 '<name>' raised an unexpected exception`.
95+
If `<message>` 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 '<name>' raised an unexpected exception`.
9696

9797
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
9898

docs_src/uri_templates/tutorial002.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from pathlib import Path
22

33
from mcp.server import MCPServer
4+
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
45
from mcp.shared.path_security import safe_join
56

67
mcp = MCPServer("Bookshop")
@@ -11,4 +12,7 @@
1112
@mcp.resource("manuals://{+path}")
1213
def read_manual(path: str) -> str:
1314
"""A staff manual page, served from a directory on disk."""
14-
return safe_join(DOCS_ROOT, path).read_text()
15+
file = safe_join(DOCS_ROOT, path)
16+
if not file.is_file():
17+
raise ResourceNotFoundError(f"No manual at {path!r}.")
18+
return file.read_text()

src/mcp/server/mcpserver/context.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,12 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
169169
The resource content as either text or bytes
170170
171171
Raises:
172-
ResourceNotFoundError: If no resource or template matches the URI.
173-
ResourceError: If template creation or resource reading fails.
172+
ResourceNotFoundError: If no resource or template matches the URI, or the
173+
handler raised it.
174+
ResourceError: If the resource or template function raises `ResourceError`.
175+
UnexpectedResourceError: If the resource or template function raises anything
176+
else. `__cause__` is the original exception. Left uncaught in a tool, this
177+
is logged as the tool's crash, while the two above are not.
174178
RuntimeError: If the resource returned an `InputRequiredResult`.
175179
"""
176180
assert self._mcp_server is not None, "Context is not available outside of a request"

src/mcp/server/mcpserver/exceptions.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ class MCPServerError(Exception):
66

77

88
class ResourceError(MCPServerError):
9-
"""Error in resource operations.
10-
11-
When a resource or resource template handler raises this, its message reaches
12-
the client as a `-32603` protocol error.
9+
"""A resource failure you anticipated.
10+
11+
Raise this from a resource or resource template handler for a failure you saw
12+
coming: the client receives a `-32603` protocol error carrying your message
13+
(`ResourceNotFoundError` below is the `-32602` variant), and the server logs it
14+
at INFO without a traceback. Any other exception is treated as a crash: the
15+
client gets a generic message naming only the URI, and the server logs the
16+
traceback at ERROR.
17+
18+
The SDK raises it too, and `UnexpectedResourceError` subclasses it, so
19+
`except ResourceError` around `MCPServer.read_resource()` catches every read
20+
failure, crash or not.
1321
"""
1422

1523

@@ -25,20 +33,22 @@ class ResourceNotFoundError(ResourceError):
2533
class UnexpectedResourceError(ResourceError):
2634
"""A resource read failed with something other than `ResourceError` or `MCPError`.
2735
28-
MCPServer raises this itself, around a crash in a resource or resource
29-
template handler or a failed file read. You never raise it. `__cause__` is
30-
the original exception, which the server logs with its traceback. The
31-
message names only the URI, so the original text is withheld from the client.
36+
The SDK raises this itself, around a crash in a resource or resource template
37+
handler. You never raise it. `__cause__` is the original exception, which the
38+
server logs with its traceback. The message names only the URI, so the
39+
original text is withheld from the client.
3240
"""
3341

3442

3543
class ToolError(MCPServerError):
36-
"""A tool failure the model should read.
44+
"""A tool failure you anticipated.
3745
38-
Raise this from a tool (or a resolver) for a failure you anticipate: the
46+
Raise this from a tool (or a resolver) for a failure you saw coming: the
3947
call returns `is_error=True` with the message in `content`, and the server
4048
logs it at INFO without a traceback. Any other exception reaches the model
4149
the same way but is treated as a crash and logged at ERROR with its traceback.
50+
A `ResourceError` that escapes the tool (say from `ctx.read_resource()`) counts
51+
as anticipated too.
4252
4353
The SDK raises it too, for an unknown tool name and for arguments that fail
4454
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
@@ -49,7 +59,7 @@ class ToolError(MCPServerError):
4959
class UnexpectedToolError(ToolError):
5060
"""A tool call failed with something other than `ToolError` or `MCPError`.
5161
52-
MCPServer raises this itself, around a crash in the tool (or a resolver) or a
62+
The SDK raises this itself, around a crash in the tool (or a resolver) or a
5363
return value that fails output conversion. You never raise it. `__cause__` is
5464
the original exception, which the server logs with its traceback before
5565
returning the usual `is_error=True` result. Catch it around

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,4 @@ async def create_resource(
246246
except (ResourceError, MCPError):
247247
raise
248248
except Exception as exc:
249-
# Name only the URI: the original text is withheld from the client, and
250-
# the server logs the traceback from `__cause__`.
251249
raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc

src/mcp/server/mcpserver/resources/types.py

Lines changed: 32 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,8 @@
1616
from mcp_types import Annotations, Icon, InputRequiredResult
1717
from pydantic import Field, validate_call
1818

19-
from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError
2019
from mcp.server.mcpserver.resources.base import Resource
2120
from mcp.shared._callable_inspection import is_async_callable
22-
from mcp.shared.exceptions import MCPError
2321

2422
# `application/*` types that are textual but predate the `+json`/`+xml`
2523
# structured-syntax suffixes, so the suffix rule below can't catch them.
@@ -80,41 +78,29 @@ class FunctionResource(Resource):
8078
fn: Callable[[], Any] = Field(exclude=True)
8179

8280
async def read(self) -> str | bytes:
83-
"""Read the resource by calling the wrapped function.
84-
85-
Raises:
86-
UnexpectedResourceError: If the function raises anything other than
87-
`ResourceError` or `MCPError`. `__cause__` is the original exception.
88-
"""
89-
try:
90-
fn = self.fn
91-
if is_async_callable(fn):
92-
result = await fn()
93-
else:
94-
result = await anyio.to_thread.run_sync(self.fn)
95-
96-
if isinstance(result, InputRequiredResult):
97-
# A static resource function can never read the retry's
98-
# input_responses (it takes no Context), so this can only be a
99-
# mistake — reject it instead of JSON-dumping it as content.
100-
raise ValueError(
101-
"static resources cannot return InputRequiredResult; only resource "
102-
"template functions participate in the multi-round-trip flow"
103-
)
104-
if isinstance(result, Resource): # pragma: no cover
105-
return await result.read()
106-
elif isinstance(result, bytes):
107-
return result
108-
elif isinstance(result, str):
109-
return result
110-
else:
111-
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
112-
except (MCPError, ResourceError):
113-
raise
114-
except Exception as exc:
115-
# Name only the URI: the original text is withheld from the client, and
116-
# the server logs the traceback from `__cause__`.
117-
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc
81+
"""Read the resource by calling the wrapped function."""
82+
fn = self.fn
83+
if is_async_callable(fn):
84+
result = await fn()
85+
else:
86+
result = await anyio.to_thread.run_sync(self.fn)
87+
88+
if isinstance(result, InputRequiredResult):
89+
# A static resource function can never read the retry's
90+
# input_responses (it takes no Context), so this can only be a
91+
# mistake — reject it instead of JSON-dumping it as content.
92+
raise ValueError(
93+
"static resources cannot return InputRequiredResult; only resource "
94+
"template functions participate in the multi-round-trip flow"
95+
)
96+
if isinstance(result, Resource): # pragma: no cover
97+
return await result.read()
98+
elif isinstance(result, bytes):
99+
return result
100+
elif isinstance(result, str):
101+
return result
102+
else:
103+
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
118104

119105
@classmethod
120106
def from_function(
@@ -191,12 +177,9 @@ def validate_text_encoding(cls, encoding: str | None) -> str | None:
191177

192178
async def read(self) -> str | bytes:
193179
"""Read the file content."""
194-
try:
195-
if self.encoding is None:
196-
return await anyio.to_thread.run_sync(self.path.read_bytes)
197-
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
198-
except Exception as exc:
199-
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc
180+
if self.encoding is None:
181+
return await anyio.to_thread.run_sync(self.path.read_bytes)
182+
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
200183

201184

202185
class HttpResource(Resource):
@@ -236,18 +219,12 @@ def list_files(self) -> list[Path]: # pragma: no cover
236219
if not self.path.is_dir():
237220
raise NotADirectoryError(f"Not a directory: {self.path}")
238221

239-
try:
240-
if self.pattern:
241-
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
242-
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
243-
except Exception as exc:
244-
raise ValueError(f"Error listing directory {self.path}: {exc}") from exc
222+
if self.pattern:
223+
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
224+
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
245225

246226
async def read(self) -> str: # Always returns JSON string # pragma: no cover
247227
"""Read the directory listing."""
248-
try:
249-
files = await anyio.to_thread.run_sync(self.list_files)
250-
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
251-
return json.dumps({"files": file_list}, indent=2)
252-
except Exception as exc:
253-
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc
228+
files = await anyio.to_thread.run_sync(self.list_files)
229+
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
230+
return json.dumps({"files": file_list}, indent=2)

0 commit comments

Comments
 (0)