Skip to content

Python: fix(core): do not replay MCP tools/call without an idempotency hint - #9243

Draft
Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/mcp-call-no-replay-without-idempotency
Draft

Yufeng He (he-yufeng) wants to merge 5 commits into
microsoft:mainfrom
he-yufeng:fix/mcp-call-no-replay-without-idempotency

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Motivation & Context

When the MCP connection drops during an ordinary tools/call, the request may already have executed server-side before the response was lost. The reconnect-and-retry added for #2884 re-issues the same call unconditionally on ClosedResourceError / session-terminated McpError, so a non-idempotent operation (a charge, a write, a ticket creation) can run twice. The long-running task path already refuses to re-issue an augmented call before a task_id is known for exactly this reason; the plain call path now follows the same policy.

Description & Review Guide

  • What are the major changes? _call_tool_with_retries now gates the replay on the tool's advertised ToolAnnotations (captured from tools/list into a new _tool_annotations_by_name snapshot alongside the existing meta/task-support maps). Tools with idempotentHint or readOnlyHint keep the optimistic reconnect-and-retry from Python: MCP connection not properly invalidated after async generator cleanup #2884. Any other tool raises ToolExecutionException stating that the connection was lost and the remote execution outcome is unknown, with the connection error as the inner exception, and leaves reconnection to the caller's own retry policy. tools/list, get_prompt, and the long-running task lifecycle keep their current retry behavior.
  • What is the impact of these changes? Non-annotated tools no longer get at-least-once execution on connection loss; servers that declare idempotency hints see no behavior change. This does change the error-path outcome for non-annotated tools (previously a transparent retry, now an explicit exception), which is the behavior the issue asks for.
  • What do you want reviewers to focus on? The replay gate in _call_tool_with_retries and whether idempotentHint / readOnlyHint is the right advertised signal; also the updated expectations in the two pre-existing reconnect tests, which now annotate their tools as replayable.

Related Issue

Fixes #9204

AI Assistance

  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: implementation, tests, and this PR description were prepared with AI assistance. Verification actually run: full packages/core/tests/core/test_mcp.py (396 passed, 2 skipped), poe syntax -P core (fmt + lint), poe pyright -P core (same 151 pre-existing errors as unmodified main, none in the changed files).

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…y hint

When the connection drops during an ordinary tools/call, the request
may already have executed server-side before the response was lost.
The reconnect-and-retry added for microsoft#2884 re-issues the same call
blindly, so a non-idempotent operation (charge, write, ticket) can run
twice. The long-running task path already refuses to re-issue an
augmented call before a task_id is known for exactly this reason.

Gate the replay on the tool's advertised ToolAnnotations: tools with
idempotentHint or readOnlyHint keep the optimistic reconnect-and-retry,
anything else raises ToolExecutionException stating the outcome is
unknown, with the connection error as the inner exception, and leaves
reconnection to the caller's own retry policy. tools/list, get_prompt,
and the task lifecycle keep their current retry behavior.

Fixes microsoft#9204
Copilot AI balanced review requested due to automatic review settings October 9, 2026 20:22
@agent-framework-automation agent-framework-automation Bot added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python labels Oct 9, 2026

Copilot AI 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.

🟡 Changes recommended

The annotation snapshot is not restored when connection discovery fails, allowing stale replay guarantees.

1 open finding
What changed in this PR

Prevents unsafe replay of MCP tool calls after connection loss unless the tool advertises read-only or idempotent behavior.

Changes:

  • Captures tool annotations and gates reconnect retries.
  • Adds regression tests for duplicate side effects.
  • Documents the replay policy.
File Description
python/​packages/​core/​agent_framework/​_mcp.py Implements annotation-based replay gating.
python/​packages/​core/​tests/​core/​test_mcp.py Tests replayable and non-replayable calls.
python/​packages/​core/​AGENTS.md Documents the new policy.

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

self._functions[:] = current_functions
self._tool_call_meta_by_name = tool_call_meta_by_name
self._tool_task_support_by_name = tool_task_support_by_name
self._tool_annotations_by_name = tool_annotations_by_name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, done in 479c00a. The annotations snapshot is now saved with the other maps before discovery and restored in the rollback, so a connect where load_tools succeeds and a later step fails no longer leaves the failed session's hints behind. Covered by test_connect_discovery_failure_rolls_back_annotations_snapshot: it seeds pre-discovery sentinels in all four maps, lets tool loading publish a fresh annotated tool, then fails prompt loading. Against the previous code the annotations map still holds the failed session's entry after the rollback (test fails); with the fix all four maps and the function list come back to their sentinels. Full test_mcp.py green, ruff check and format clean.

Asserting through server.session (ClientSession | None) tripped every
typing gate: the optional deref and await_count on a bound method. Keep
the AsyncMock on a declared attribute and assert on that instead.
…iscovery

_connect_on_owner rolled back the call-meta, task-support, and param-name
maps when a later discovery step failed, but not the annotations snapshot
taken from tools/list. A connect where load_tools succeeded and prompt
loading then failed left the failed session's annotations in place, so a
later replay decision could trust idempotency hints from a session whose
functions had already been rolled back.
the call would duplicate its side effects.
"""
annotations = self._tool_annotations_by_name.get(tool_name)
return bool(annotations and (annotations.idempotentHint or annotations.readOnlyHint))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) this still lets the MCP server authorize replay by setting idempotentHint or readOnlyHint. Those annotations are untrusted unless the client has separately trusted the server, and this package’s security layer already treats ToolAnnotations as restriction-only hints. On a lost response, a misannotated or untrusted server can still get the same tools/call executed twice. Please keep the ambiguous-response path as no-replay by default, or gate replay on an explicit client-owned trusted policy in addition to the annotation.

…y-without-idempotency

# Conflicts:
#	python/packages/core/AGENTS.md
#	python/packages/core/agent_framework/_mcp.py

This branch was successfully deployed

1 active deployment
github-app-auth — e3f95817 Deployed Oct 16, 2026 by he-yufeng via add_label #25601
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: MCP tools/call retry can duplicate non-idempotent side effects after connection loss

3 participants