Skip to content

Commit ff178c7

Browse files
committed
Log MCPServer handler exceptions once, by kind
A crashing tool used to leave no server-side trace: _handle_call_tool turned the exception into an is_error result before the dispatcher boundary could log it, so a KeyError('id') reached the model as "'id'" and its traceback existed nowhere. Resources logged once and prompts twice. Tool.run also re-wrapped a deliberate ToolError, so nothing downstream could tell an anticipated failure from a crash. Tool.run now validates arguments first (a schema rejection is a plain ToolError chained to the ValidationError) and runs the body under an except ladder that keeps the distinction in the type: a deliberate ToolError stays a ToolError, anything else becomes the new UnexpectedToolError. Both keep the "Error executing tool X: " text, so results are byte-identical. Resources get the matching UnexpectedResourceError, raised by whichever layer first sees the foreign exception so __cause__ is always the original. _log_handler_exception in server.py is the one place tools and resources are logged: INFO without a traceback for ToolError and ResourceError (deliberate, unknown name, bad arguments, not found), ERROR with the traceback for anything else. get_prompt stops logging, leaving the dispatcher boundary's record as the only one. ResourceError raised from a static resource now passes through to the client as it already did from a template.
1 parent 14aa889 commit ff178c7

21 files changed

Lines changed: 915 additions & 74 deletions

File tree

docs/handlers/logging.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ went to standard error: the terminal, not the wire.
7070
don't want log lines, you want spans. Your server already emits them: the SDK traces every
7171
message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**.
7272

73+
You don't have to log your own handlers' crashes either. When a tool or resource function raises something unexpected, the SDK writes the `ERROR` record with the traceback for you, on its own `mcp.*` loggers; a failure you raised deliberately (`ToolError`, `ResourceNotFoundError`) is an `INFO` line instead. A prompt function that raises is an `ERROR` record too, whatever it raised. **[Handling errors](../servers/handling-errors.md#what-lands-in-your-log)** has the split. (In a test using `Client(mcp, raise_exceptions=True)`, a prompt failure is handed to your test as the exception rather than logged.)
74+
7375
## Recap
7476

7577
* 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
@@ -1016,7 +1016,7 @@ except MCPError as e:
10161016

10171017
### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)
10181018

1019-
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.
1019+
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

10211021
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`).
10221022

docs/servers/handling-errors.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,29 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
115115
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
116116

117117
!!! info
118-
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
119-
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
120-
back into a traceback: by the time that flag could act, your exception is already the
121-
`is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern.
118+
Everything so far is what a **client** sees, and the in-memory `Client` you'll write tests
119+
with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing tool's
120+
exception back to the caller: by the time that flag could act, your exception is already the
121+
`is_error=True` result. Assert on the result; the traceback is in the server's log (next
122+
section), which pytest's `caplog` captures. **[Testing](../get-started/testing.md)** covers the pattern.
123+
124+
## What lands in your log
125+
126+
Your server keeps its own record of these failures, and it draws one more line: between a failure you anticipated and one you didn't.
127+
128+
`get_author` raised a plain `ValueError`. The model got the message, but the SDK can't know you *meant* that exception, so it assumes you didn't: the call is logged at `ERROR` with the full traceback. That is exactly what you want on the day the exception is a `KeyError` from three libraries down and the result text says only `'id'`.
129+
130+
When the failure is one you planned for, say so with `ToolError`:
131+
132+
```python title="server.py" hl_lines="2 12-13"
133+
--8<-- "docs_src/handling_errors/tutorial004.py"
134+
```
135+
136+
The model reads precisely what it read before. The difference is on your side: a `ToolError` is logged as one `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 `INFO` lines too; those are the caller's mistakes, not yours.
137+
138+
Resources draw the same line. The `-32603` from a crashing resource handler names only the URI, so the `ERROR` record in your log is the one place the cause and its traceback exist. `ResourceNotFoundError`, including the SDK's own `Unknown resource`, is an `INFO` line. (A template parameter that fails its type annotation, `books://{id}` read with an `id` that isn't an `int`, currently counts as a crash.)
139+
140+
Prompts aren't split yet: any failure in a prompt function, including an unknown name or a missing argument, is one `ERROR` record with its traceback, written by the transport layer that turns it into the JSON-RPC error.
122141

123142
## Recap
124143

@@ -127,7 +146,8 @@ It means a whole class of `raise` statements you don't write: don't re-validate
127146
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
128147
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
129148
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
130-
* `from mcp import MCPError`; the error-code constants come from `mcp.types`.
149+
* In your log: an exception you didn't raise as `ToolError` is an `ERROR` record with its traceback; `ToolError`, bad tool arguments, unknown tool names, and `ResourceNotFoundError` are one `INFO` line each.
150+
* `from mcp import MCPError`; `ToolError` and `ResourceNotFoundError` come from `mcp.server.mcpserver.exceptions`; the error-code constants come from `mcp.types`.
131151

132152
Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.
133153

docs/servers/uri-templates.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,11 @@ These checks are a heuristic pre-filter; for filesystem access,
199199
`safe_join` remains the containment boundary.
200200

201201
!!! tip
202-
If your handler can't fulfil the request (the file doesn't exist,
203-
the id is unknown), raise an exception. The SDK turns it into an
204-
error response. See **[Handling errors](handling-errors.md)** for the difference between a
205-
protocol error and a tool error.
202+
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, and your log gets one `INFO` line; any other exception is treated as
205+
a crash (`-32603`, and an `ERROR` record with the traceback). See
206+
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.
206207

207208
## Resources on the low-level Server
208209

docs/troubleshooting.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ 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, the traceback is in the **server's log**: an exception the tool didn't raise as `ToolError` is logged there at `ERROR`, as `Tool '<name>' raised an unexpected exception`.
96+
9597
## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
9698

9799
You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from mcp.server import MCPServer
2+
from mcp.server.mcpserver.exceptions import ToolError
3+
4+
mcp = MCPServer("Bookshop")
5+
6+
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}
7+
8+
9+
@mcp.tool()
10+
def get_author(title: str) -> str:
11+
"""Look up the author of a book in the catalog."""
12+
if title not in CATALOG:
13+
raise ToolError(f"No book titled {title!r} in the catalog.")
14+
return CATALOG[title]

src/mcp/server/mcpserver/exceptions.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,55 @@ class MCPServerError(Exception):
66

77

88
class ResourceError(MCPServerError):
9-
"""Error in resource operations."""
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.
13+
"""
1014

1115

1216
class ResourceNotFoundError(ResourceError):
1317
"""Resource does not exist.
1418
15-
Raise this from a resource template handler to signal that the requested instance does not exist;
19+
Raise this from a resource handler to signal that the requested instance does not exist;
1620
clients receive `-32602` (invalid params) per
1721
[SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164).
1822
"""
1923

2024

25+
class UnexpectedResourceError(ResourceError):
26+
"""A resource read failed with something other than `ResourceError` or `MCPError`.
27+
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.
32+
"""
33+
34+
2135
class ToolError(MCPServerError):
22-
"""Error in tool operations."""
36+
"""A tool failure the model should read.
37+
38+
Raise this from a tool (or a resolver) for a failure you anticipate: the
39+
call returns `is_error=True` with the message in `content`, and the server
40+
logs it at INFO without a traceback. Any other exception reaches the model
41+
the same way but is treated as a crash and logged at ERROR with its traceback.
42+
43+
The SDK raises it too, for an unknown tool name and for arguments that fail
44+
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
45+
around `MCPServer.call_tool()` catches every tool failure, crash or not.
46+
"""
47+
48+
49+
class UnexpectedToolError(ToolError):
50+
"""A tool call failed with something other than `ToolError` or `MCPError`.
51+
52+
MCPServer raises this itself, around a crash in the tool (or a resolver) or a
53+
return value that fails output conversion; you never raise it. `__cause__` is
54+
the original exception, which the server logs with its traceback before
55+
returning the usual `is_error=True` result. Catch it around
56+
`MCPServer.call_tool()` to tell a crash from a deliberate `ToolError`.
57+
"""
2358

2459

2560
class InvalidSignature(Exception):

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,5 +196,5 @@ async def render(
196196
return messages
197197
except MCPError:
198198
raise
199-
except Exception as e:
200-
raise ValueError(f"Error rendering prompt {self.name}: {e}")
199+
except Exception as exc:
200+
raise ValueError(f"Error rendering prompt {self.name}: {exc}") from exc

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,15 @@
1111
from mcp_types import Annotations, Icon, InputRequiredResult
1212
from pydantic import BaseModel, Field, validate_call
1313

14-
from mcp.server.mcpserver.exceptions import ResourceError
14+
from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError
1515
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
1616
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1717
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
18-
from mcp.server.mcpserver.utilities.logging import get_logger
1918
from mcp.shared._callable_inspection import is_async_callable
2019
from mcp.shared.exceptions import MCPError
2120
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
2221
from mcp.shared.uri_template import UriTemplate
2322

24-
logger = get_logger(__name__)
25-
2623
if TYPE_CHECKING:
2724
from mcp.server.context import LifespanContextT, RequestT
2825
from mcp.server.mcpserver.context import Context
@@ -218,7 +215,9 @@ async def create_resource(
218215
carrying the echoed opaque state.
219216
220217
Raises:
221-
ResourceError: If creating the resource fails.
218+
ResourceError: If the template function raises `ResourceError`.
219+
UnexpectedResourceError: If the template function raises anything other
220+
than `ResourceError` or `MCPError`; `__cause__` is the original.
222221
"""
223222
try:
224223
# Add context to params if needed
@@ -247,5 +246,6 @@ async def create_resource(
247246
except (ResourceError, MCPError):
248247
raise
249248
except Exception as exc:
250-
logger.exception(f"Error creating resource from template {uri}")
251-
raise ResourceError(f"Error creating resource from template {uri}") from exc
249+
# Name only the URI: the original text is withheld from the client, and
250+
# the server logs the traceback from `__cause__`.
251+
raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
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
1920
from mcp.server.mcpserver.resources.base import Resource
2021
from mcp.shared._callable_inspection import is_async_callable
2122
from mcp.shared.exceptions import MCPError
@@ -79,7 +80,12 @@ class FunctionResource(Resource):
7980
fn: Callable[[], Any] = Field(exclude=True)
8081

8182
async def read(self) -> str | bytes:
82-
"""Read the resource by calling the wrapped function."""
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.
88+
"""
8389
try:
8490
fn = self.fn
8591
if is_async_callable(fn):
@@ -103,10 +109,12 @@ async def read(self) -> str | bytes:
103109
return result
104110
else:
105111
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
106-
except MCPError:
112+
except (MCPError, ResourceError):
107113
raise
108-
except Exception as e:
109-
raise ValueError(f"Error reading resource {self.uri}: {e}")
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
110118

111119
@classmethod
112120
def from_function(
@@ -187,8 +195,8 @@ async def read(self) -> str | bytes:
187195
if self.encoding is None:
188196
return await anyio.to_thread.run_sync(self.path.read_bytes)
189197
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
190-
except Exception as e:
191-
raise ValueError(f"Error reading file {self.path}: {e}")
198+
except Exception as exc:
199+
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc
192200

193201

194202
class HttpResource(Resource):
@@ -232,14 +240,14 @@ def list_files(self) -> list[Path]: # pragma: no cover
232240
if self.pattern:
233241
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
234242
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
235-
except Exception as e:
236-
raise ValueError(f"Error listing directory {self.path}: {e}")
243+
except Exception as exc:
244+
raise ValueError(f"Error listing directory {self.path}: {exc}") from exc
237245

238246
async def read(self) -> str: # Always returns JSON string # pragma: no cover
239247
"""Read the directory listing."""
240248
try:
241249
files = await anyio.to_thread.run_sync(self.list_files)
242250
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
243251
return json.dumps({"files": file_list}, indent=2)
244-
except Exception as e:
245-
raise ValueError(f"Error reading directory {self.path}: {e}")
252+
except Exception as exc:
253+
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc

0 commit comments

Comments
 (0)