Skip to content

Log MCPServer handler exceptions by kind and keep crash details off the wire - #1

Draft
niklasgruener wants to merge 8 commits into
contextgoblin/pr-3314-basefrom
contextgoblin/pr-3314-head
Draft

Log MCPServer handler exceptions by kind and keep crash details off the wire#1
niklasgruener wants to merge 8 commits into
contextgoblin/pr-3314-basefrom
contextgoblin/pr-3314-head

Conversation

@niklasgruener

Copy link
Copy Markdown
Owner

MCPServer now treats an exception from a tool, resource, or prompt handler in one of two ways, decided by its type:

  • Anticipated (ToolError, ResourceError/ResourceNotFoundError, a schema rejection of the arguments, an unknown name): the message reaches the client as before, and the server writes one INFO record with no traceback.
  • A crash (anything else): the client learns only that it failed (Error executing tool <name>, Error reading resource <uri>, Error rendering prompt <name>), and the server writes one ERROR record with the traceback. Nothing from the exception's text goes on the wire, and nothing is logged twice.

Fixes modelcontextprotocol#3266. Fixes modelcontextprotocol#698.

Motivation and Context

On main the three primitives each hand-roll their own except ladder and each made a different choice:

handler raises logged client sees
tool nothing is_error=True, "Error executing tool X: {e}" — the raw exception text
static resource / template ERROR + traceback, once -32603 "Error reading resource {uri}" (text withheld)
prompt ERROR + traceback, twice legacy path: code=0 with the raw text; modern: Internal server error

Two problems in that table. A crashing tool leaves no trace on the server (modelcontextprotocol#3266): for a KeyError('id') the model reads 'id' and the traceback exists nowhere. And a crashing tool or prompt sends str(exc) to the client (modelcontextprotocol#698), which can describe server internals; for an output-schema failure it echoes the tool's return value.

Rather than an eighth site-specific patch (modelcontextprotocol#3267 / modelcontextprotocol#3271 / modelcontextprotocol#2198), the exception's type now carries "was this anticipated?" to one decision point per primitive:

  • Tool.run validates arguments first (a schema rejection is a plain ToolError chained to the ValidationError; a validator that raises anything else is a crash). The body then runs under an except ladder: ToolError or ResourceError (from the tool, a resolver, or ctx.read_resource()) is re-raised as ToolError with its message; anything else becomes the new UnexpectedToolError(ToolError) whose message is only Error executing tool <name> and whose __cause__ is the original.
  • _handle_call_tool / _handle_read_resource log at the point the failure becomes a response: INFO for the anticipated types (rejected arguments log field names, not values), logger.exception for the Unexpected* wrappers.
  • Resources get the matching UnexpectedResourceError(ResourceError), raised in MCPServer.read_resource (and ResourceTemplate.create_resource), so __cause__ is always the original. Built-in Resource.read() implementations no longer wrap.
  • Prompts drop the inner logger.exception in get_prompt (the dispatcher boundary's record is the only one), and Prompt.render no longer interpolates the exception text into its message.
  • @mcp.completion() gets the same treatment: a crash is one ERROR record and -32603 "Error completing argument <name>".

Why level, not "log everything at ERROR": level is the one filter operators get for free and what Sentry/Datadog integrations key on. External FastMCP shipped logger.exception for every tool failure and walked it back over PrefectHQ/fastmcp item 4036, #4029, #4392 once deliberate ToolErrors and model typos flooded error monitoring. modelcontextprotocol#2422 and modelcontextprotocol#2346 are the same signal here.

Why withhold crash text: it's the call already made for resources (modelcontextprotocol#1957) and prompts, it's what modelcontextprotocol#698 / modelcontextprotocol#2386 ask for, and it's the default in every framework surveyed (Starlette/uvicorn, Flask, Django, gRPC-java, the C# SDK). Model self-correction is preserved because the two channels the model can act on, ToolError and argument-validation text, still pass through.

Client-visible changes

  • A tool that raises something other than ToolError/ResourceError/MCPErrorcontent is Error executing tool <name> (was …: <str(exc)>). Same for a crashing resolver, a crashing validator, and an output-schema failure.
  • Prompt.render failure on the legacy path → Error rendering prompt <name> (was …: <str(exc)>).
  • @mcp.completion() crash → -32603 Error completing argument <name> (legacy path was code=0, str(exc)).
  • ResourceError / ResourceNotFoundError from a static resource now pass through (-32602 / your message) as they already did from a template. Also visible one level up when a tool reads such a resource via ctx.read_resource().

Not in here

How Has This Been Tested?

  • tests/server/mcpserver/test_server.py: level, message, traceback identity and wire result per class — crash, ToolError, ToolError subclass, bad arguments (INFO names fields; __cause__ is the ValidationError), validator crash vs validator MCPError, ValidationError inside the body / output-schema failure (crashes), unknown tool, MCPError (no record), resolver ToolError vs crash, ResourceNotFoundError vs resource crash escaping a tool, a tool that recovers from a missing resource (nothing logged), static / template / custom-subclass resource crash, static ResourceNotFoundError, deliberate ResourceError, completion crash vs MCPError, prompt crash logged once, nested tool crash, and the direct call_tool() / read_resource() type and __cause__ contracts.
  • Interaction suite: existing wire snapshots updated to the sanitised text; one new wire test for static ResourceNotFoundError-32602.
  • tests/docs_src/*: every rewritten docs claim is exercised.
  • End-to-end over real stdio and streamable HTTP with a small server: one record per failure at the expected level, crash text absent from every client-visible result, log_level="WARNING" leaves only the crash records.
  • ./scripts/test: 100% coverage, strict-no-cover, pyright, pre-commit clean.

Breaking Changes

The client-visible changes listed above. docs/servers/handling-errors.md now teaches ToolError as the way to hand the model a message; code that raised a plain exception expecting the model to read its text should switch to ToolError. The new exception types subclass the existing ones, so except ToolError / except ResourceError and the documented Raises: contracts keep working. Softer differences: FunctionResource.read() / FileResource.read() called directly raise the original exception instead of a ValueError; MCPServer.read_resource() / get_prompt() no longer log by themselves; log message wording changed.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Supersedes modelcontextprotocol#3267, modelcontextprotocol#3271, and modelcontextprotocol#2198 (thank you all — the diagnoses were right; this moves the fix to where all three primitives share it). Related: modelcontextprotocol#2153, modelcontextprotocol#2386, modelcontextprotocol#2422.

AI Disclaimer

maxisbey and others added 8 commits August 14, 2026 12:57
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.
Log at the two handler sites directly instead of through a shared
helper: the tool site checks for ToolError, the resource site only has
to ask whether it caught an UnexpectedResourceError.

Drop the three transport-matrix logging tests and their requirement
ids from the interaction suite, which is for wire behaviour; the same
properties are covered next to MCPServer in test_server.py.

Shorten the logging docs to a pointer, reword the handling-errors
section plainly, and drop the recap bullet and prompt caveats.
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).
…eption-logging

# Conflicts:
#	docs_src/uri_templates/tutorial002.py
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
The applied suggestion dropped the closing quote along with the
interpolated exception text, so prompts/base.py no longer parsed. With
the message now just "Error rendering prompt <name>", the legacy-path
interaction test snapshots that instead of matching the pydantic prefix.
Keep the one-word correction to the SEP-2164 sentence (static resources
now pass ResourceNotFoundError through too), remove the added clause
about FunctionResource.read()/FileResource.read().

No-Verification-Needed: docs-only change
A tool that crashed used to send the exception's own text to the client
as "Error executing tool <name>: <str(exc)>". That text can describe
server internals (or, for an output-schema failure, echo the tool's
return value), so a crash now reads just "Error executing tool <name>".
ToolError, ResourceError, and argument-validation messages still reach
the model unchanged, since those are the anticipated failures it can act
on. Closes the tool half of the leak that resources already avoided and
that prompts stopped doing earlier in this branch.

Related tidy-ups in the same direction:
- a crashing @mcp.completion() handler is logged once and answered with
  -32603 "Error completing argument <name>" instead of str(exc)
- the legacy resolver path reports a malformed elicitation answer as a
  ToolError, matching what the input_required path already did
- the INFO line for rejected arguments names the fields, not the values

Docs now teach ToolError as the way to talk to the model and describe a
plain exception as a crash the model sees generically; examples that
relied on ValueError text reaching the client raise ToolError instead.
@niklasgruener

Copy link
Copy Markdown
Owner Author

@contextgoblindev

1 similar comment
@niklasgruener

Copy link
Copy Markdown
Owner Author

@contextgoblindev

@contextgoblindev

Copy link
Copy Markdown

👀 Got the request — running the review now, results will land here shortly.

@contextgoblindev contextgoblindev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Overview

Verdict:  Approve with comments
At a glance: 2 findings — medium: Prompts don't get the INFO/ERROR classification this PR builds for tools and resources · low: Multi-field validation rejection logging is untested beyond a single bad field

Details (2 findings)

Findings (posted as inline comments)

  • Low Tests src/mcp/server/mcpserver/server.py:433 — see inline comment on this line.

Unplaced findings

  • Architecture  Medium src/mcp/server/mcpserver/server.py:1327-1330Prompts don't get the INFO/ERROR classification this PR builds for tools and resources. get_prompt catches Exception and re-raises a bare ValueError(str(e)), and _handle_get_prompt has no try/except to classify the failure. Unlike tools (ToolError/UnexpectedToolError) and resources (ResourceError/UnexpectedResourceError), prompts have no typed pair for the dispatcher to distinguish "anticipated" from "crash." Since handler_exception_to_error_data only special-cases MCPError/ValidationError, a plain ValueError falls through to the generic path (logger.exception("modern request handler raised")) unconditionally at ERROR. Calling get_prompt with an unknown name, or any prompt raising anything other than MCPError, therefore always logs at ERROR with a traceback — there is no INFO tier for prompts at all, contrary to the PR's explicit stated goal of giving operators an INFO/ERROR filter for anticipated vs. crash failures. Recommendation: Give prompts the same typed pair (e.g. PromptError/UnexpectedPromptError) and classify in _handle_get_prompt the way _handle_call_tool/_handle_read_resource do, or explicitly document this as an intentional "not yet covered" gap. Add a regression test asserting get_prompt("nonexistent") logs at INFO without a traceback, mirroring the existing tool/resource classification tests.

if isinstance(exc, ToolError) and not isinstance(exc, UnexpectedToolError):
if isinstance(exc.__cause__, ValidationError):
# Field names only: the rejected values are the caller's data.
fields = sorted({".".join(str(part) for part in err["loc"]) for err in exc.__cause__.errors()})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests  Low

Multi-field validation rejection logging is untested beyond a single bad field

The new field-extraction logic for the INFO-level "rejected arguments" log (fields = sorted({".".join(...) for err in exc.__cause__.errors()})) is only exercised by a test with a single invalid field. No test supplies two or more invalid arguments, so the sorted(...) ordering and set dedup added by this PR have no case where their removal would change observable test output.

Recommendation: Add a test calling a tool with ≥2 invalid arguments (e.g. add(a="one", b="two")) and assert the exact INFO message lists both field names, comma-separated, in sorted order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants