Skip to content

Log MCPServer handler exceptions once, by kind - #3314

Open
maxisbey wants to merge 4 commits into
mainfrom
mcpserver-handler-exception-logging
Open

Log MCPServer handler exceptions once, by kind#3314
maxisbey wants to merge 4 commits into
mainfrom
mcpserver-handler-exception-logging

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Make MCPServer log exceptions from tool, resource, and prompt handlers consistently: a crash in user code is one ERROR record with its traceback, an anticipated failure is one INFO record, and nothing is logged twice. What the client receives is unchanged.

Fixes #3266.

Motivation and Context

Today 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}"
static resource / template ERROR + traceback, once -32603 "Error reading resource {uri}" (message withheld)
prompt ERROR + traceback, twice (get_prompt and the dispatcher boundary) transport-dependent

The tool row is the one that hurts: _handle_call_tool converts the exception into a successful JSON-RPC response, so the dispatcher-boundary logging never sees it, and the exception object is gone by the time middleware or the OTel middleware run. For a KeyError('id') or an anyio task-group failure the result text is 'id' / unhandled errors in a TaskGroup (1 sub-exception) and the traceback exists nowhere.

Rather than add an eighth site-specific logger.exception (#3267 / #3271), this makes one function own the decision and gives it enough information to make it well:

  • Tool.run stops erasing the exception's kind. Arguments are validated first: a schema rejection is a plain ToolError chained to the ValidationError as before, an MCPError from a validator passes through, and a validator that raises anything else is wrapped as a crash. Then the body runs under an except ladder: a ToolError or ResourceError raised deliberately (by the tool, a resolver, or a resource it read via ctx.read_resource()) is re-raised as a plain ToolError, anything else is wrapped in the new UnexpectedToolError(ToolError) with __cause__ set, and a nested UnexpectedToolError/UnexpectedResourceError stays a crash. Every arm keeps the Error executing tool X: prefix, so the result text is byte-identical. Resources get the matching UnexpectedResourceError(ResourceError), raised in MCPServer.read_resource (and ResourceTemplate.create_resource, whose message is already pinned), so __cause__ is always the original.
  • _handle_call_tool / _handle_read_resource log at the point the failure becomes a response, and nowhere else does. A ToolError / ResourceError that isn't one of the Unexpected* wrappers → logger.info, no traceback, text repr-quoted so a peer-supplied name or pydantic's multi-line message stays on one physical line. Anything else → logger.exception.
  • Prompts drop the inner logger.exception in get_prompt, so the dispatcher boundary's existing record is the only one. Giving _handle_get_prompt ownership like the other two would mean picking a wire shape, and today's prompt error shape differs by transport (legacy code=0 + str(e) vs modern -32603 "Internal server error"); that's a separate decision, already recorded by the mcpserver:prompt:unknown-name / prompts:get:missing-required-args divergences.

Why encode anticipated-vs-crash into the level rather than logging everything at ERROR: level is the one filter operators get for free, and it's what Sentry/Datadog-style integrations key on. External FastMCP shipped "logger.exception for every tool failure" and then walked it back over PrefectHQ/fastmcp#4036, PrefectHQ/fastmcp#4029 and PrefectHQ/fastmcp#4392 after deliberate ToolErrors and model argument typos flooded error monitoring and stdio stderr. #2422 and #2346 are the same signal here. The convention across uvicorn/Flask/Django/Celery/gRPC is likewise "unexpected → ERROR with traceback, once, at the layer that swallows it; expected → lower level or nothing".

INFO (rather than DEBUG or WARNING) for the anticipated bucket is the judgement call I'd most like a second opinion on: with MCPServer's default basicConfig(INFO) it means a model's bad-argument call prints one line to stderr during local development, and a production config at WARNING hears only about crashes.

Also in here

  • A ResourceError (usually ResourceNotFoundError from ctx.read_resource()) that escapes a tool body is classified as anticipated, since it is the same outcome resources/read logs at INFO. Same result text as before.
  • ResourceError / ResourceNotFoundError raised from a static resource (a decorated fixed-URI function, or any Resource subclass's read()) now pass through — -32602 / the handler's message — as they already did from a template function and as the ResourceNotFoundError docstring and handling-errors.md promise. Previously FunctionResource.read wrapped them into a generic -32603. This is the one client-visible change, and it also shows up one level removed: a tool that does await ctx.read_resource(uri) on such a resource gets the handler's message in its is_error text instead of the generic one. Happy to split it out if preferred.
  • The SDK's own guard for a tool body that returns InputRequiredResult while its parameters use Resolve(...) now raises RuntimeError instead of ToolError, so an authoring bug is logged as a crash rather than filed as anticipated. Same result text.
  • Prompt.render chains with from exc so the boundary's traceback reaches the original.
  • Docs: new "What the server logs" section in docs/servers/handling-errors.md (introduces ToolError as the way to say "I anticipated this", with tutorial004.py), pointers from troubleshooting.md and handlers/logging.md, the uri-templates.md tip and its safe_join example now use ResourceNotFoundError, and a migration.md clause for the Resource.read() change below.

Deliberately not in here

How Has This Been Tested?

  • tests/server/mcpserver/test_server.py: level, message, logger name, traceback identity and wire result for each class — crash, ToolError, ToolError subclass, bad arguments (and that __cause__ is the ValidationError directly), a validator that crashes vs one that raises MCPError, ValidationError raised inside the body / by output-schema conversion (both crashes), unknown tool, MCPError (no MCPServer record), resolver ToolError vs resolver crash, ResourceNotFoundError vs resource crash escaping a tool via ctx.read_resource(), a tool that recovers from a missing resource (nothing logged), static / template / custom-subclass resource crash, static ResourceNotFoundError, deliberate ResourceError static and template, prompt crash logged once, nested tool crash keeps its classification, and the direct call_tool() / read_resource() type and __cause__ contracts.
  • One interaction test pins the static-resource ResourceNotFoundError-32602 wire behaviour across the transport matrix.
  • tests/docs_src/test_handling_errors.py / test_troubleshooting.py prove the new docs claims.
  • A wire diff of ~40 failure scenarios (in-memory legacy/auto/2026-07-28, SSE, streamable-HTTP stateful and stateless) against main: byte-identical except the static-resource pass-through above.
  • End-to-end: a small weather server run over real stdio (stderr captured as a host would) and over streamable HTTP on a socket, driven through Client; one record per failure at the expected level on both, only the three crash records left at log_level="WARNING", and the same session against main shows 0 records for the tool crash and 2 for the prompt crash.
  • ./scripts/test: 100% coverage, strict-no-cover clean, pyright and pre-commit clean.

Breaking Changes

None intended on the wire beyond the static-resource pass-through called out above. The new exception types subclass the existing ones, so except ToolError / except ResourceError and the documented Raises: contracts keep working. Softer observable differences worth knowing about:

  • Calling FunctionResource.read() / FileResource.read() directly now raises whatever the function or file read raised, instead of a ValueError carrying its text. Through MCPServer.read_resource() it arrives as UnexpectedResourceError (a ResourceError) with the original as __cause__. Noted in migration.md.
  • MCPServer.read_resource() and get_prompt() called programmatically (outside a request) no longer write a log record themselves; the exception they raise is the record. Likewise a tool that catches ResourceError around ctx.read_resource() and recovers no longer leaves an ERROR line behind.
  • Under the in-process Client(mcp, raise_exceptions=True), a crashing prompt is handed to the test as the exception and not logged (tools and resources still log, since MCPServer records them before the boundary).
  • The two existing messages (Error getting resource …, Error getting prompt …) are replaced by Resource '<uri>' raised an unexpected exception on mcp.server.mcpserver.server and by the dispatcher boundary's record respectively; deliberate ResourceNotFoundErrors drop from ERROR to INFO.

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 #3267 and #3271 (thank you both — the diagnosis was right; this moves the fix to where all three primitives share it). Related: #698, #2153, #2198, #2422.

AI Disclaimer

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.
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3314.mcp-python-docs.pages.dev
Deployment https://310071c9.mcp-python-docs.pages.dev
Commit d3f3f5c
Triggered by @maxisbey
Updated 2026-08-18 14:19:29 UTC

Comment thread src/mcp/server/mcpserver/server.py Outdated
Comment thread tests/interaction/mcpserver/test_prompts.py Outdated
Comment thread tests/interaction/mcpserver/test_resources.py Outdated
Comment thread tests/interaction/mcpserver/test_tools.py Outdated
Comment thread tests/interaction/_requirements.py Outdated
Comment thread docs/servers/handling-errors.md Outdated
Comment thread docs/servers/handling-errors.md Outdated
Comment thread docs/servers/handling-errors.md Outdated
Comment thread src/mcp/server/mcpserver/server.py Outdated
Comment thread tests/docs_src/test_handling_errors.py Outdated
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.
@maxisbey
maxisbey marked this pull request as ready for review August 18, 2026 13:29

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 17 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/mcpserver/tools/base.py
Comment thread src/mcp/server/mcpserver/exceptions.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/mcp/server/mcpserver/resources/types.py — FileResource.read (and DirectoryResource.read at line 247) now raise UnexpectedResourceError but their docstrings were not given the Raises: section that this same PR added to FunctionResource.read and ResourceTemplate.create_resource.

    Extended reasoning...

    AGENTS.md (Code Quality) requires: "When a public API raises exceptions a caller would reasonably catch, document them in a Raises: section." A caller reading FileResource.read's docstring ("Read the file content.") has no way to know a missing/unreadable file now surfaces as UnexpectedResourceError with the FileNotFoundError/PermissionError in cause, inconsistent with the sibling resource types updated in the same change.

    Verification: nit — The claim is factually true. This PR changed FileResource.read (src/mcp/server/mcpserver/resources/types.py:198-199: raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc) and DirectoryResource.read (lines 252-253, same raise) to raise UnexpectedResourceError, but their docstrings remain "Read the file content." (line 193) and "Read the directory listing." (lin

Comment thread src/mcp/server/mcpserver/tools/base.py
Comment thread src/mcp/server/mcpserver/resources/templates.py
Comment thread docs/servers/uri-templates.md Outdated
Comment thread docs/troubleshooting.md Outdated
Comment thread src/mcp/server/mcpserver/resources/types.py Outdated
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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline findings, this run also checked the new validation ladder from 6fd5e05 for a deliberate ToolError raised inside a custom argument validator or default_factory: pydantic propagates it raw, so it lands in the except Exception arm and is classified as a crash — but the wire result (prefix, message, is_error=True) is identical to main, so the only effect is the ERROR-vs-INFO log level for a pattern the docstrings don't sanction, and it was ruled out as a finding.

Extended reasoning...

The commits pushed since the prior review (6fd5e05 in particular) address the earlier red-flag finding at tools/base.py:151 — non-ValidationError exceptions from validators are now wrapped as UnexpectedToolError instead of escaping raw. This run's two remaining findings (a Raises-docstring inaccuracy in server.py and a dead arguments argument at the call_fn_with_arg_validation call site) are posted inline and are quality-level, not correctness blockers. The one new candidate investigated this run — a deliberate ToolError from a custom validator being reclassified as a crash — was verified against both HEAD and the base commit: the client-visible text and is_error result are byte-identical, so the difference is confined to the new log-level taxonomy for an out-of-contract raising site. The hunt exited at max_rounds, so approval is off the table regardless; this note only records what else was examined.

Comment on lines +520 to +521
UnexpectedToolError: If the tool (or a resolver) raises anything other than
`ToolError` or `MCPError`, or its return value fails output conversion.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit: MCPServer.call_tool's new Raises docstring says UnexpectedToolError is raised when "the tool (or a resolver) raises anything other than ToolError or MCPError", but Tool.run's ladder (src/mcp/server/mcpserver/tools/base.py:208-210) deliberately re-raises a plain ResourceError/ResourceNotFoundError (e.g. propagated from ctx.read_resource()) as a plain anticipated ToolError, never UnexpectedToolError. The ToolError arm of the same docstring (lines 518-519) also omits this ResourceError case. Tool.run's own docstring (tools/base.py:140-144) states the contract correctly, so the two public docstrings in the same PR… [also at: src/mcp/server/mcpserver/exceptions.py:51 - nit: ToolError docstring says "A ResourceError that escapes the tool ... counts as anticipated too", but…]

Extended reasoning...

A programmatic caller of MCPServer.call_tool() whose tool lets ResourceNotFoundError from ctx.read_resource() propagate reads this Raises section (AGENTS.md requires Raises sections to document catchable exceptions accurately) and writes except UnexpectedToolError expecting to catch that case as documented, or conversely treats any non-Unexpected ToolError as covering only the three listed causes. At runtime the failure arrives as a plain ToolError (proved by this PR's own test test_resource_error_escaping_a_tool_is_anticipated asserting type(exc.value) is ToolError), so code keyed to the documented UnexpectedToolError classification silently takes the wrong branch — e.g. crash-alerting logic that re-reports UnexpectedToolError never fires, or the author adds a needless try/except inside the tool to "fix" a misclassification that never happens.

Verification: nit — the docstring is factually inaccurate about the raised type, though runtime behavior is intentional. server.py:520-521 says "UnexpectedToolError: If the tool (or a resolver) raises anything other than ToolError or MCPError", but tools/base.py:208-210 has except (ToolError, ResourceError) as exc: raise ToolError(...) from exc — a plain ResourceError/ResourceNotFoundError (a sibling of

Comment on lines 176 to 180
self.is_async,
arguments,
pass_directly or None,
pre_validated=pre_validated,
pre_validated=validated,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 After hoisting validation, Tool.run always passes pre_validated=validated to call_fn_with_arg_validation, so the raw arguments argument at this call site is dead and the helper's internal validate-fallback branch (func_metadata.py:118-120) is no longer exercised by any SDK code path — the call could simply invoke the function with the merged validated kwargs.

Extended reasoning...

Concrete cost: misleading data flow and retained duplicate logic. Before this change the fallback self.validate_arguments(arguments_to_validate) inside call_fn_with_arg_validation (src/mcp/server/mcpserver/utilities/func_metadata.py:118-120) was the live validation path for every tool without Resolve() params; now Tool.run validates up front (tools/base.py:147) and this remains the helper's only SDK call site, so a reader of tools/base.py:174-180 sees arguments passed as if it might be re-validated when it is always ignored, and the helper keeps a second validation path that only third-party callers could reach. Simpler form: merge validated | (pass_directly or {}) and dispatch to the function directly (or pass an empty dict with a comment), keeping one obvious validation site.

Verification: nit — The claim is factually true. tools/base.py:146-156 hoists validated = self.fn_metadata.validate_arguments(arguments) ahead of the body try, and line 179 unconditionally passes pre_validated=validated, so the raw arguments positional at line 177 is dead at this call site. func_metadata.py:118-120 only reads arguments_to_validate in the pre_validated is None fallback, and grep shows

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.

Log exceptions in tool calls

1 participant