fix(toolsets): prevent MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset#5171
Conversation
…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]>
| finally: | ||
| self._toolset = new_toolset |
There was a problem hiding this comment.
📝 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
…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.
… 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.
This comment was marked as low quality.
This comment was marked as low quality.
|
@anishesg Thanks Anish, I'll take it over from here! |
MCPServer.__aexit__ called more times than __aenter__ in DynamicToolset
When a
DynamicToolsetfactory creates a newMCPServerinstance on each run step (the common pattern for per-user authentication), thefor_run_stepmethod exits the old server and then enters the new one. If__aenter__fails on the new server,self._toolsetstill points to it. When the outer context manager then callsDynamicToolset.__aexit__, it tries to exit this unentered server, triggeringValueError: MCPServer.__aexit__ called more times than __aenter__.The fix in
pydantic_ai/toolsets/_dynamic.pywraps the__aenter__calls in bothfor_run_stepand__aenter__with a try/except that clearsself._toolsettoNoneon failure, ensuringDynamicToolset.__aexit__will not attempt to exit a context manager that was never entered.Fixes #3542