Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID
and reconnect or effective header-identity changes replace a framework-owned Client's per-Client cache. Agent
Framework does not expose shared-store or cache-mode configuration.
- **MCP Tasks extension** - MCP Python SDK 2.2 does not implement the 2026 `io.modelcontextprotocol/tasks` runtime. Tools that advertise `execution.taskSupport == "required"` are skipped with a warning; optional and task-free tools continue through ordinary `tools/call`. Restoration of the experimental Tasks API and removed sample is tracked in microsoft/agent-framework#8245.
- **Plain `tools/call` replay gating** - On `ClosedResourceError` / session-terminated `MCPError` the request may already have executed server-side, so `_call_tool_with_retries` only replays after reconnect when the server advertised the tool with `idempotentHint` or `readOnlyHint` and the client opted in with `trust_server_idempotency_hints` (`_is_call_replayable`, backed by the `_tool_annotations_by_name` snapshot from `tools/list`); any other tool raises `ToolExecutionException` stating the outcome is unknown once the connection is back up, with the connection error as the inner exception, and leaves the retry decision to the caller. Caller-owned connections keep failing with their existing 'cannot be recreated' error before any of this. Read-only discovery (`tools/list`) and `get_prompt` keep their reconnect-and-retry behavior.

### File Access Harness (`_harness/_file_access.py`)

Expand Down
59 changes: 59 additions & 0 deletions python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,7 @@ def __init__(
*,
max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES,
tool_result_content: MCPToolResultContentMode = "structured_first",
trust_server_idempotency_hints: bool = False,
) -> None:
"""Initialize the MCP Tool base.

Expand Down Expand Up @@ -1161,6 +1162,7 @@ def __init__(
self.load_prompts_flag = load_prompts
self.parse_prompt_results = parse_prompt_results
self.max_host_payload_size_bytes = max_host_payload_size_bytes
self.trust_server_idempotency_hints = trust_server_idempotency_hints
self._exit_stack = AsyncExitStack()
self._lifecycle_lock = asyncio.Lock()
self._lifecycle_request_lock = asyncio.Lock()
Expand Down Expand Up @@ -1207,6 +1209,7 @@ def __init__(
self._progressive_loader_functions: list[FunctionTool] | None = None
self._progressive_loaded_tool_names: set[str] = set()
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
self._tool_annotations_by_name: dict[str, types.ToolAnnotations | None] = {}
self._tool_param_names_by_name: dict[str, set[str]] = {}
self._global_extra_arg_names, self._tool_extra_arg_names = _normalize_additional_tool_argument_names(
additional_tool_argument_names
Expand Down Expand Up @@ -2254,6 +2257,7 @@ def _reset_session_discovery_state(self) -> None:
if not isinstance((function.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY), str)
]
self._tool_call_meta_by_name.clear()
self._tool_annotations_by_name.clear()
self._tool_param_names_by_name.clear()
self._progressive_loaded_tool_names.clear()

Expand Down Expand Up @@ -2485,6 +2489,7 @@ async def _connect_on_owner(
functions_before_discovery = self._functions.copy()
call_meta_before_discovery = self._tool_call_meta_by_name
param_names_before_discovery = self._tool_param_names_by_name
annotations_before_discovery = self._tool_annotations_by_name
try:
connection = self._require_connection()
logger.debug("Connected to MCP server: %s", connection.client or connection.session)
Expand Down Expand Up @@ -2520,6 +2525,7 @@ async def _connect_on_owner(
self._functions[:] = functions_before_discovery
self._tool_call_meta_by_name = call_meta_before_discovery
self._tool_param_names_by_name = param_names_before_discovery
self._tool_annotations_by_name = annotations_before_discovery
raise

async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None:
Expand Down Expand Up @@ -3207,6 +3213,7 @@ async def _load_tools_locked(self) -> None:
self._function_load_callback(function, tool_annotations_by_name[remote_name])
self._functions[:] = current_functions
self._tool_call_meta_by_name = tool_call_meta_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.

self._tool_param_names_by_name = tool_param_names_by_name
self._progressive_loaded_tool_names.difference_update(existing_tools.keys() - reused_tool_names)

Expand Down Expand Up @@ -3428,6 +3435,21 @@ async def _call_tool_with_retries(
if span.is_recording():
set_mcp_span_error(span, type(recovery_error).__name__, str(recovery_error))
raise
if not self._is_call_replayable(tool_name):
# The request may have reached the server before the connection
# dropped, so replaying it could execute a non-idempotent operation
# twice. Surface the unknown outcome instead of a replay; the
# connection is already back up for whoever decides to retry.
if span.is_recording():
set_mcp_span_error(span, type(call_ex).__name__, str(call_ex))
raise _mcp_tool_execution_exception(
f"Failed to call tool '{tool_name}' - connection lost and the remote "
"execution outcome is unknown. The connection was re-established but "
"the call was not replayed, because replay requires both a server "
"idempotent/read-only hint and the client-owned "
"trust_server_idempotency_hints opt-in; retry explicitly if safe.",
inner_exception=call_ex,
) from call_ex
continue

# Second attempt also failed, give up.
Expand All @@ -3453,6 +3475,22 @@ async def _call_tool_with_retries(
set_mcp_span_error(span, type(retries_exhausted).__name__, str(retries_exhausted))
raise retries_exhausted

def _is_call_replayable(self, tool_name: str) -> bool:
"""Return True if *tool_name* may be replayed after connection loss.

Replay requires both a client-owned trust decision
(``trust_server_idempotency_hints=True``) and a server annotation carrying
``idempotentHint`` or ``readOnlyHint``. Annotations are supplied by the
server and are untrusted by default, consistent with how the rest of the
package treats ``ToolAnnotations`` as restriction-only hints: without the
client's explicit opt-in, a misannotated or untrusted server could get a
non-idempotent ``tools/call`` executed twice on a lost response.
"""
if not self.trust_server_idempotency_hints:
return False
annotations = self._tool_annotations_by_name.get(tool_name)
return bool(annotations and (annotations.idempotent_hint or annotations.read_only_hint))

def _resolved_extra_args(self, tool_name: str) -> set[str]:
"""Return the user-configured extra argument names allowed for a tool."""
return self._global_extra_arg_names | self._tool_extra_arg_names.get(tool_name, set())
Expand Down Expand Up @@ -3682,6 +3720,7 @@ def __init__(
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES,
tool_result_content: MCPToolResultContentMode = "structured_first",
trust_server_idempotency_hints: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the MCP stdio tool.
Expand Down Expand Up @@ -3789,6 +3828,11 @@ def __init__(
transports. ``None`` disables the limit.
tool_result_content: How to choose model-visible text when both ``content`` and
``structuredContent`` are present. See :data:`MCPToolResultContentMode`.
trust_server_idempotency_hints: When ``True``, treat server-declared
``idempotentHint``/``readOnlyHint`` tool annotations as trustworthy, allowing
a ``tools/call`` whose response was lost to a dropped connection to be retried
once after reconnect. Annotations are server-supplied and untrusted by default,
so the default ``False`` never replays an ambiguous call.
kwargs: Additional arguments for framework-owned ``StdioServerParameters`` construction.
"""
if mcp_client is not None and session is not None:
Expand Down Expand Up @@ -3842,6 +3886,7 @@ def __init__(
task_options=task_options,
max_host_payload_size_bytes=max_host_payload_size_bytes,
tool_result_content=tool_result_content,
trust_server_idempotency_hints=trust_server_idempotency_hints,
)
self.command = command
self.args = args or []
Expand Down Expand Up @@ -3937,6 +3982,7 @@ def __init__(
task_options: Any = None,
max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES,
tool_result_content: MCPToolResultContentMode = "structured_first",
trust_server_idempotency_hints: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the MCP streamable HTTP tool.
Expand Down Expand Up @@ -4115,6 +4161,11 @@ def __init__(
transports. ``None`` disables the limit.
tool_result_content: How to choose model-visible text when both ``content`` and
``structuredContent`` are present. See :data:`MCPToolResultContentMode`.
trust_server_idempotency_hints: When ``True``, treat server-declared
``idempotentHint``/``readOnlyHint`` tool annotations as trustworthy, allowing
a ``tools/call`` whose response was lost to a dropped connection to be retried
once after reconnect. Annotations are server-supplied and untrusted by default,
so the default ``False`` never replays an ambiguous call.
kwargs: Additional keyword arguments (accepted for backward compatibility but not used).
"""
if mcp_client is not None and session is not None:
Expand Down Expand Up @@ -4168,6 +4219,7 @@ def __init__(
task_options=task_options,
max_host_payload_size_bytes=max_host_payload_size_bytes,
tool_result_content=tool_result_content,
trust_server_idempotency_hints=trust_server_idempotency_hints,
)
self.url = url
self.terminate_on_close = terminate_on_close
Expand Down Expand Up @@ -4599,6 +4651,7 @@ def __init__(
task_options: Any = None,
max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES,
tool_result_content: MCPToolResultContentMode = "structured_first",
trust_server_idempotency_hints: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the deprecated MCP WebSocket compatibility wrapper.
Expand Down Expand Up @@ -4691,6 +4744,11 @@ def __init__(
transports. ``None`` disables the limit.
tool_result_content: How to choose model-visible text when both ``content`` and
``structuredContent`` are present. See :data:`MCPToolResultContentMode`.
trust_server_idempotency_hints: When ``True``, treat server-declared
``idempotentHint``/``readOnlyHint`` tool annotations as trustworthy, allowing
a ``tools/call`` whose response was lost to a dropped connection to be retried
once after reconnect. Annotations are server-supplied and untrusted by default,
so the default ``False`` never replays an ambiguous call.
kwargs: Deprecated compatibility arguments. They are not used.
"""
super().__init__(
Expand All @@ -4716,6 +4774,7 @@ def __init__(
task_options=task_options,
max_host_payload_size_bytes=max_host_payload_size_bytes,
tool_result_content=tool_result_content,
trust_server_idempotency_hints=trust_server_idempotency_hints,
)
self.url = url
self._client_kwargs = kwargs
Expand Down
Loading
Loading