Repository navigation
Python: Harden MCP catalog discovery behavior - #10042
Jose Alvarez (jpalvarezl) wants to merge 14 commits into
Conversation
…ty for retry logic
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues are recorded for the MCP catalog discovery hardening.
No open issues 🎉
What changed in this PR
This PR hardens Python MCP catalog discovery with bounded requests and pagination while preserving complete catalog snapshots and caller-owned connection policy.
Changes:
- Adds configurable page and item limits, shared page-fetch recovery, and a 30-second default request timeout for framework-owned clients.
- Retains tool annotations with catalog snapshots so security labeling no longer lists tools independently.
- Adds tests and ownership guidance for discovery limits, recovery, and annotation refresh behavior.
| File | Description |
|---|---|
python/packages/core/tests/test_security.py |
Updates security tests to use retained annotation snapshots and explicit refreshes. |
python/packages/core/tests/core/test_mcp.py |
Tests limits, exact boundaries, opaque cursors, timeout defaults, and connection recovery. |
python/packages/core/AGENTS.md |
Documents request bounds, ownership policy, and atomic annotation refresh. |
python/packages/core/agent_framework/security.py |
Uses cached annotations and refreshes tools before reapplying proxy labels. |
python/packages/core/agent_framework/_mcp.py |
Implements bounded discovery, shared recovery, timeout defaults, and annotation retention. |
docs/decisions/0045-python-mcp-v2-client-lifecycle.md |
Clarifies timeout ownership, discovery bounds, and catalog publication responsibilities. |
Review effort: Balanced
…gination_hardening # Conflicts: # python/packages/core/agent_framework/_mcp.py
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (12 commit(s)): 5d32c1bd3d23, 37bd4437fd3e, 4b344e12a986, 9edeb705d1b2, 455f290dcd65, bd4e9f2ac202, 41eb445c870d, 4efc33245cb8, f358e659882a, 99ae067f1291, 863cb18fc4cd, 907d87eee6e3
Model: gpt-5.6-sol
Overview
The change adds per-fetch page and item limits, exact-boundary validation, atomic catalog publication, and retained annotation snapshots with conservative security-label defaults. The staged-state design and rollback tests provide strong protection against partial publication. Three compatibility gaps remain: the new timeout affects all MCP requests rather than discovery alone, label refresh can populate tools that were explicitly disabled, and wrapper entry points do not expose the new trusted-server opt-outs.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
3 verified findings remained after source verification (3 medium) across 2 files. Details are attached to the affected lines below.
Affected areas: python/packages/core/agent_framework/_mcp.py, python/packages/core/agent_framework/security.py
| read_timeout_seconds=( | ||
| self.request_timeout | ||
| if self.request_timeout is not None | ||
| else _MCP_DEFAULT_READ_TIMEOUT_SECONDS |
There was a problem hiding this comment.
request_timeout=None previously passed no SDK timeout, but this fallback gives every RPC on framework-owned clients, including initialization and tools/call, a 30-second ceiling. Long-running tools that previously succeeded now fail, and None no longer provides an opt-out. Please scope the finite default to catalog discovery or provide a separate explicit way to retain unlimited tool-call timing.
| raise RuntimeError("MCPTool is not connected. Connect before refreshing labels.") | ||
| callback_bound = self._bind_function_load_callback() | ||
| try: | ||
| await self._mcp_tool.load_tools() |
There was a problem hiding this comment.
When the wrapped tool was constructed with load_tools=False, this call still populates _functions because public load_tools() does not honor that flag, while invocation continues to reject every loaded tool. refresh_labels() therefore changes the exposed catalog and creates unusable functions instead of only refreshing current labels. Please preserve the opt-out, either by gating this reload or by refreshing annotations without publishing tools.
| use_progressive_disclosure: bool = False, | ||
| always_load: Collection[str] | None = None, | ||
| *, | ||
| max_discovery_pages: int | None = _DEFAULT_MCP_DISCOVERY_MAX_PAGES, |
There was a problem hiding this comment.
These non-None defaults are inherited by adapters that do not expose either option. In particular, SecureMCPToolProxy(url=...) and DefaultMCPToolHandler construct MCPStreamableHTTPTool without forwarding these settings, so a trusted catalog over 100 pages or 1,000 items now fails to connect through those APIs with no configuration-level opt-out. Please plumb the limits through those entry points and their schemas, or preserve the previous behavior where callers cannot configure them.
| return await self._try_list_page_with_recovery( | ||
| params, | ||
| kind="prompt", | ||
| is_supported=lambda: self._supports_prompts, | ||
| request_page=lambda page_params: self._require_connection().list_prompts_page(page_params), | ||
| ) |
There was a problem hiding this comment.
do we need this wrapper?
| self._tool_annotations_by_name = tool_annotations_by_name | ||
| self._progressive_loaded_tool_names.difference_update(existing_tools.keys() - reused_tool_names) | ||
|
|
||
| async def _try_list_tools_page_with_recovery( |
There was a problem hiding this comment.
same with this one
| async def request_page_with_retry() -> _MCPListResultT | None: | ||
| await self._ensure_connected() | ||
|
|
||
| if not is_supported(): |
There was a problem hiding this comment.
why are we checking this per page, can't we just skip this loading altogether?
| params, | ||
| kind="prompt", | ||
| is_supported=lambda: self._supports_prompts, | ||
| request_page=lambda page_params: self._require_connection().list_prompts_page(page_params), |
There was a problem hiding this comment.
wouldn't this be equivalent?
| request_page=lambda page_params: self._require_connection().list_prompts_page(page_params), | |
| request_page=self._require_connection().list_prompts_page, |
| self._max_discovery_pages = max_discovery_pages | ||
| self._max_discovery_items = max_discovery_items |
There was a problem hiding this comment.
| self._max_discovery_pages = max_discovery_pages | |
| self._max_discovery_items = max_discovery_items | |
| self.max_discovery_pages = max_discovery_pages | |
| self.max_discovery_items = max_discovery_items |
| task_options: Deprecated compatibility parameter. Non-``None`` values are ignored with a warning. | ||
| max_discovery_pages: Maximum number of pages accepted while discovering tools or prompts. | ||
| ``None`` disables the limit. | ||
| max_discovery_items: Maximum number of tools or prompts accepted across all discovery pages. |
There was a problem hiding this comment.
it is not clear whether this is a single limit for tools+prompts or a limit for each one?
Motivation & Context
MCP servers can expose paginated tool and prompt catalogs whose size and response time vary significantly. Catalog discovery should remain predictable, preserve the previous complete snapshot when refresh fails, and make framework-owned versus caller-owned request policy explicit.
Description & Review Guide
Related Issue
N/A - maintainer-directed behavior hardening with no public tracking issue.
AI Assistance
AI assistance details: Copilot assisted with design review, implementation guidance, tests, documentation, and this PR description.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and the title prefix in sync automatically.