Repository navigation
Python: fix(core): do not replay MCP tools/call without an idempotency hint #9243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
254b89b
56d0dd8
13c789d
b6246e3
6303696
9e17ca1
31d2547
0d553af
53f1de1
5b0bb51
bc22c21
df44595
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1187,6 +1187,7 @@ def __init__( | |
| max_discovery_items: int | None = _DEFAULT_MCP_DISCOVERY_MAX_ITEMS, | ||
| 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. | ||
|
|
||
|
|
@@ -1244,6 +1245,7 @@ def __init__( | |
| self.max_discovery_pages = max_discovery_pages | ||
| self.max_discovery_items = max_discovery_items | ||
| 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() | ||
|
|
@@ -1290,6 +1292,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._tool_annotations_by_name: dict[str, Any] = {} | ||
| self._global_extra_arg_names, self._tool_extra_arg_names = _normalize_additional_tool_argument_names( | ||
|
|
@@ -2338,6 +2341,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._tool_annotations_by_name.clear() | ||
| self._progressive_loaded_tool_names.clear() | ||
|
|
@@ -3348,6 +3352,7 @@ def stage_tool_page(tool_list: types.ListToolsResult) -> 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| self._tool_param_names_by_name = tool_param_names_by_name | ||
| self._tool_annotations_by_name = tool_annotations_by_name | ||
| self._progressive_loaded_tool_names.difference_update(existing_tools.keys() - reused_tool_names) | ||
|
|
@@ -3526,6 +3531,8 @@ async def _call_tool_with_retries( | |
| from anyio import ClosedResourceError | ||
| from mcp import MCPError | ||
|
|
||
| # A catalog refresh cannot retroactively make the first dispatch safe to repeat. | ||
| replay_eligible = self._is_call_replayable(tool_name) | ||
| for attempt in range(2): | ||
| span_error_recorded = False | ||
| try: | ||
|
|
@@ -3570,6 +3577,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 replay_eligible or 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. | ||
|
|
@@ -3595,6 +3617,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()) | ||
|
|
@@ -3826,6 +3864,7 @@ def __init__( | |
| max_discovery_items: int | None = _DEFAULT_MCP_DISCOVERY_MAX_ITEMS, | ||
| 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. | ||
|
|
@@ -3943,6 +3982,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: | ||
|
|
@@ -4001,6 +4045,7 @@ def __init__( | |
| max_discovery_items=max_discovery_items, | ||
| 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 [] | ||
|
|
@@ -4098,6 +4143,7 @@ def __init__( | |
| max_discovery_items: int | None = _DEFAULT_MCP_DISCOVERY_MAX_ITEMS, | ||
| 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. | ||
|
|
@@ -4286,6 +4332,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: | ||
|
|
@@ -4344,6 +4395,7 @@ def __init__( | |
| max_discovery_items=max_discovery_items, | ||
| 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 | ||
|
|
@@ -4777,6 +4829,7 @@ def __init__( | |
| max_discovery_items: int | None = _DEFAULT_MCP_DISCOVERY_MAX_ITEMS, | ||
| 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. | ||
|
|
@@ -4879,6 +4932,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__( | ||
|
|
@@ -4906,6 +4964,7 @@ def __init__( | |
| max_discovery_items=max_discovery_items, | ||
| 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 | ||
|
|
||
There was a problem hiding this comment.
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 attribute is declared twice —
_tool_annotations_by_nameis initialized here and declared again two lines later with a different type. Strict Pyright reportsreportRedeclaration, so the required code-quality check cannot pass. Please keep one annotated initialization and update the existing declaration instead of adding a second one.