Skip to content

fix(toolsets): prevent MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset#5171

Merged
DouweM merged 4 commits intopydantic:mainfrom
anishesg:fix/ph-issue-3542
Apr 23, 2026
Merged

fix(toolsets): prevent MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset#5171
DouweM merged 4 commits intopydantic:mainfrom
anishesg:fix/ph-issue-3542

Conversation

@anishesg
Copy link
Copy Markdown

When a DynamicToolset factory creates a new MCPServer instance on each run step (the common pattern for per-user authentication), the for_run_step method exits the old server and then enters the new one. If __aenter__ fails on the new server, self._toolset still points to it. When the outer context manager then calls DynamicToolset.__aexit__, it tries to exit this unentered server, triggering ValueError: MCPServer.__aexit__ called more times than __aenter__.

The fix in pydantic_ai/toolsets/_dynamic.py wraps the __aenter__ calls in both for_run_step and __aenter__ with a try/except that clears self._toolset to None on failure, ensuring DynamicToolset.__aexit__ will not attempt to exit a context manager that was never entered.

Fixes #3542

…enter__ in DynamicToolset

When a `DynamicToolset` factory creates a new `MCPServer` instance on each run step (the common pattern for per-user authentication), the `for_run_step` method exits the old server and then enters the new one. If `__aenter__` fails on the new server, `self._toolset` still points to it. When the outer context manager then calls `DynamicToolset.__aexit__`, it tries to exit this unentered server, triggering `ValueError: MCPServer.__aexit__ called more times than __aenter__`.

Signed-off-by: anish k <[email protected]>
@github-actions github-actions Bot added size: S Small PR (≤100 weighted lines) bug Report that something isn't working, or PR implementing a fix labels Apr 23, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py Outdated
Comment on lines 93 to 94
finally:
self._toolset = new_toolset
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Apr 23, 2026

Choose a reason for hiding this comment

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

📝 Info: Behavioral change: old aexit failure no longer stores the new toolset

The old for_run_step used try/finally to guarantee self._toolset = new_toolset even when old_toolset.__aexit__ raised. This meant the new (unentered) toolset was stored, and the outer DynamicToolset.__aexit__ would subsequently call __aexit__ on it — which was the root cause of the MCPServer issue (#3542). The new code at pydantic_ai_slim/pydantic_ai/toolsets/_dynamic.py:91-95 sets self._toolset = None before exiting the old toolset, so if old_toolset.__aexit__ raises, self._toolset remains None and new_toolset is never entered or stored. This is a semantic behavior change (not just a refactor) — callers that previously relied on the new toolset being available after a failed transition will now see self._toolset as None. However, an exception propagating from for_run_step terminates the agent step anyway, so this is safe in practice.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

This comment was marked as low quality.

@DouweM DouweM self-assigned this Apr 23, 2026
…ting transition bug

Consolidate the duplicated try/except BaseException blocks in for_run_step and
__aenter__ into a single _enter_inner_toolset helper, following the project
rule to consolidate duplicated try/except and helper logic.

Also fixes a pre-existing issue in for_run_step: if the old toolsets __aexit__
raised, the finally block stored new_toolset on self without it ever being
entered, causing DynamicToolset.__aexit__ to later attempt __aexit__ on an
unentered toolset. Now the old toolset is detached before exit, and _toolset
is only set back to a toolset after its __aenter__ succeeds.

Add tests covering all three failure paths: new toolset __aenter__ failure
during for_run_step transition, outer __aenter__ failure, and old toolset
__aexit__ failure during for_run_step transition.
@github-actions github-actions Bot added size: M Medium PR (101-500 weighted lines) and removed size: S Small PR (≤100 weighted lines) labels Apr 23, 2026
… bodies

Coverage failed for 3 return statements inside never-called test helper methods
because the pragma was on the prior line, not the def. Moving the marker to the
def line excludes the whole method body.
@anishesg

This comment was marked as low quality.

@DouweM
Copy link
Copy Markdown
Collaborator

DouweM commented Apr 23, 2026

@anishesg Thanks Anish, I'll take it over from here!

@DouweM DouweM merged commit f9ac618 into pydantic:main Apr 23, 2026
45 checks passed
@DouweM DouweM changed the title fix(toolsets): prevent MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset fix(toolsets): prevent MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: M Medium PR (101-500 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dynamic MCP Toolset | ValueError: MCPServer.__aexit__ called more times than __aenter__

2 participants