Python: fix(mcp): name the real error when a cancel scope masks MCP init failures - #7704
Python: fix(mcp): name the real error when a cancel scope masks MCP init failures#7704Yufeng He (he-yufeng) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds an internal _describe_error helper to improve MCP connection/session error messages by unmasking “cancel scope” cancellations and single-leaf ExceptionGroups so the real underlying failure is surfaced.
Changes:
- Introduced
_describe_errorhelper inagent_framework/_mcp.pyand used it in multiple MCP connection/session error messages. - Added unit tests covering plain exceptions, cancel-scope unmasking via
__context__, and single-leafExceptionGroupunwrapping. - Added an integration-style test ensuring
ToolExceptionmessages prefer the inner auth failure over the cancel-scope message.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| python/packages/core/agent_framework/_mcp.py | Adds _describe_error and applies it to MCP connection/session initialization error messages. |
| python/packages/core/tests/core/test_mcp.py | Adds tests validating _describe_error behavior and improved connect() error reporting. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| inner = getattr(current, "exceptions", None) # ExceptionGroup leaf | ||
| if inner is not None and len(inner) == 1: | ||
| current = inner[0] |
| bare cancellation keeps its own message. | ||
| """ | ||
| current = ex | ||
| for _ in range(10): # pathological chains only; normally 0-2 hops |
| def _describe_error(ex: BaseException) -> str: | ||
| """Return the most specific message in *ex*'s chain, unmasking cancel scopes. | ||
|
|
||
| anyio cancel scopes and task groups surface internal failures as a bare |
| error_msg = f"MCP server '{full_command}' failed to initialize: {_describe_error(ex)}" | ||
| else: | ||
| error_msg = f"MCP server failed to initialize: {ex}" | ||
| error_msg = f"MCP server failed to initialize: {_describe_error(ex)}" |
There was a problem hiding this comment.
Could we carry the exception raised by _safe_close_exit_stack() into this message instead of describing only ex? In the reported 401 path, session.initialize() raises a bare CancelledError; the sibling task's HTTPStatusError appears later in the ExceptionGroup from aclose(), which _safe_close_exit_stack() logs and discards before this line runs. With a real 401 endpoint this still produces MCP server failed to initialize: WouldBlock(), so capturing the cleanup group and selecting its leaf is needed for the linked issue.
There was a problem hiding this comment.
Good catch, that path still reported the cancellation. In 2a3f77a _safe_close_exit_stack now returns the failure it swallowed during aclose(), and the three cancel-catch sites that build a ToolException describe that close-time error when ex itself is a bare CancelledError. Your 401 path now reads MCP server failed to initialize: 401 Client Error: Unauthorized instead of WouldBlock(). Regression test added with the close raising a single-leaf ExceptionGroup (3.11+ gated, matching the neighboring tests). Also folded in the Copilot notes above: the unwrap loop now gates group membership on the type name rather than the attribute, and the hop bound is a named constant.
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||
…nit failures When the MCP client stack cancels (e.g. an HTTP 401 from the server during session creation), anyio surfaces a bare CancelledError and the wrap sites reported 'Cancelled via cancel scope ...' as the failure. Unwrap single-member exception groups and __cause__/__context__ chains when composing the ToolException message so the real error is named. Genuine caller-driven cancellations still propagate unchanged and bare cancellations keep their own message. Fixes microsoft#7699
2a3f77a to
06987cb
Compare
|
Rebased onto current main and fixed the Python 3.10 typing failure by resolving |
Motivation & Context
An MCP server can reject initialization with HTTP 401 while the client surfaces only
Cancelled via cancel scope. That hides the useful authentication error and sends users toward cancellation debugging.Description & Review Guide
_describe_error()follows a masked exception chain and unwraps a single member exception group, while preserving a bare cancellation when no better error exists. The connect path also remembers a failure raised during exit stack cleanup, so that error wins when initialization itself reports only a bare cancellation.Please focus on the chain termination rules and the cleanup failure precedence.
Related Issue
Fixes #7699
Testing
The complete MCP test module passes locally with 289 tests and 2 environment skips. Ruff and all five core test type checkers pass on the current rebased branch, including Python 3.10 static analysis of the Python 3.11 exception group cases.
Checklist