From 96b0d045ce5e85b4a330103a685d7bc69a21db0c Mon Sep 17 00:00:00 2001 From: Paul Cayet Date: Mon, 15 Jun 2026 10:24:41 +0200 Subject: [PATCH] feat: add MCP retry policy to Agent Spec --- .../json_spec/agentspec_json_spec_26_2_0.json | 22 +++++ .../agentspec/language_spec_nightly.rst | 15 ++++ docs/pyagentspec/source/changelog.rst | 6 ++ .../source/code_examples/howto_mcp.py | 18 ++++ .../source/howtoguides/howto_mcp.rst | 12 +++ pyagentspec/src/pyagentspec/mcp/tools.py | 35 ++++++++ .../tests/serialization/test_mcp_tools.py | 88 +++++++++++++++++++ 7 files changed, 196 insertions(+) diff --git a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json index ea2b97ebe..1b3518fdb 100644 --- a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json +++ b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json @@ -3713,6 +3713,17 @@ "client_transport": { "$ref": "#/$defs/ClientTransport" }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, "$referenced_components": { "$ref": "#/$defs/ReferencedComponents" }, @@ -3772,6 +3783,17 @@ "client_transport": { "$ref": "#/$defs/ClientTransport" }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, "tool_filter": { "anyOf": [ { diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index d3d004b0a..260ec09a0 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -986,6 +986,7 @@ the correct built-in tool. class MCPTool(Tool): client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] class BuiltinTool(Tool): tool_type: str @@ -1059,11 +1060,17 @@ with mTLS) (details about the client transport can be found in class MCPTool(Tool): client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] MCP Tools follow the same security principles as other tools: no arbitrary code is embedded in the representation. Execution occurs remotely via the specified transport, with the runtime handling session management and data relay. +``retry_policy`` optionally specifies a ``RetryPolicy`` for direct MCP tool +resolution and execution. This is distinct from retry policies on remote MCP +transports, which apply to requests sent through the transport layer. Runtime +implementations decide which MCP resolution and execution failures are retryable. + For example, an MCP Tool might use an ``SSEmTLSTransport`` to securely call a remote function for data processing. @@ -1104,12 +1111,20 @@ from that server to components. class MCPToolBox(ToolBox): client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] tool_filter: Optional[List[Union[str, MCPToolSpec]]] The ``client_transport`` specifies the MCP ClientTransport used to discover remote MCP tools and route calls to them. +``retry_policy`` optionally specifies a ``RetryPolicy`` for MCP toolbox +discovery/resolution and for the execution of tools generated by the toolbox. +This is distinct from retry policies on remote MCP transports, which apply to +requests sent through the transport layer. Runtime implementations decide which +MCP toolbox discovery, resolution, and generated-tool execution failures are +retryable. + .. _mcp_toolfilter_rules: By default the ``tool_filter`` parameter is null and the MCPToolBox exposes all tools diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index 510b21746..7f7662a4d 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -19,6 +19,12 @@ Improvements New features ^^^^^^^^^^^^ +* **MCP tool retry policies** + + Added ``retry_policy`` support to ``MCPTool`` and ``MCPToolBox`` so runtimes can + configure retries for MCP tool resolution and execution separately from + transport-level request retries. + Breaking Changes ^^^^^^^^^^^^^^^^ diff --git a/docs/pyagentspec/source/code_examples/howto_mcp.py b/docs/pyagentspec/source/code_examples/howto_mcp.py index 0fe90995b..cd2c42a8b 100644 --- a/docs/pyagentspec/source/code_examples/howto_mcp.py +++ b/docs/pyagentspec/source/code_examples/howto_mcp.py @@ -86,6 +86,7 @@ def start_mcp_server() -> str: # .. start-##_Imports_for_this_guide from pyagentspec.agent import Agent +from pyagentspec import RetryPolicy from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge from pyagentspec.flows.flow import Flow from pyagentspec.flows.nodes import EndNode, StartNode, ToolNode @@ -108,6 +109,23 @@ def start_mcp_server() -> str: mcp_client_with_oauth = SSETransport(name="MCP Client", url=mcp_server_url, auth=oauth) # .. end-##_OAuth_in_MCP_Tools +# .. start-##_MCP_Retry_Policy +mcp_client_for_retry = SSETransport( + name="MCP Client", + url=mcp_server_url, +) +mcp_toolbox_with_retry = MCPToolBox( + name="Payslip MCP ToolBox", + client_transport=mcp_client_for_retry, + tool_filter=["get_user_session", "get_payslips"], + retry_policy=RetryPolicy( + max_attempts=3, + initial_retry_delay=0.25, + max_retry_delay=2.0, + ), +) +# .. end-##_MCP_Retry_Policy + # .. start-##_Connecting_an_agent_to_the_MCP_server mcp_client = SSETransport(name="MCP Client", url=mcp_server_url) diff --git a/docs/pyagentspec/source/howtoguides/howto_mcp.rst b/docs/pyagentspec/source/howtoguides/howto_mcp.rst index b3aafa431..6565af7cd 100644 --- a/docs/pyagentspec/source/howtoguides/howto_mcp.rst +++ b/docs/pyagentspec/source/howtoguides/howto_mcp.rst @@ -103,6 +103,18 @@ You can then equip an agent with the toolbox similarly to tools. for a tool (see :ref:`Tool `). This signals that execution environments should require user approval before running the tool, which is useful for tools performing sensitive actions. +Configuring MCP retry policies +------------------------------ + +``MCPTool`` and ``MCPToolBox`` accept an optional ``retry_policy``. Runtimes can +use this policy for MCP tool resolution and execution, while retry policies on +remote transports apply to requests sent through the transport layer. + +.. literalinclude:: ../code_examples/howto_mcp.py + :language: python + :start-after: .. start-##_MCP_Retry_Policy + :end-before: .. end-##_MCP_Retry_Policy + Agent Serialization ------------------- diff --git a/pyagentspec/src/pyagentspec/mcp/tools.py b/pyagentspec/src/pyagentspec/mcp/tools.py index c3e67c0ca..c9f7b1975 100644 --- a/pyagentspec/src/pyagentspec/mcp/tools.py +++ b/pyagentspec/src/pyagentspec/mcp/tools.py @@ -11,6 +11,7 @@ from pydantic import SerializeAsAny from pyagentspec.component import ComponentWithIO +from pyagentspec.retrypolicy import RetryPolicy from pyagentspec.tools.tool import Tool from pyagentspec.tools.toolbox import ToolBox from pyagentspec.versioning import AgentSpecVersionEnum @@ -24,6 +25,23 @@ class MCPTool(Tool): client_transport: SerializeAsAny[ClientTransport] """Transport to use for establishing and managing connections to the MCP server.""" + retry_policy: Optional[RetryPolicy] = None + """Optional retry configuration for MCP tool resolution and execution.""" + + def _versioned_model_fields_to_exclude( + self, agentspec_version: AgentSpecVersionEnum + ) -> set[str]: + fields_to_exclude = super()._versioned_model_fields_to_exclude(agentspec_version) + if agentspec_version < AgentSpecVersionEnum.v26_2_0: + fields_to_exclude.add("retry_policy") + return fields_to_exclude + + def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + min_version = super()._infer_min_agentspec_version_from_configuration() + if self.retry_policy is not None: + min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + return min_version + class MCPToolSpec(ComponentWithIO): """Specification of MCP tool""" @@ -55,6 +73,9 @@ class MCPToolBox(ToolBox): client_transport: ClientTransport """Transport to use for establishing and managing connections to the MCP server.""" + retry_policy: Optional[RetryPolicy] = None + """Optional retry configuration for MCP toolbox discovery and generated tool execution.""" + tool_filter: Optional[List[Union[MCPToolSpec, str]]] = None """ Optional filter to select specific tools. @@ -69,3 +90,17 @@ class MCPToolBox(ToolBox): * If provided, the outputs must be a single ``StringProperty`` with the expected tool output name and optional description. * If the tool requires confirmation before use, it overrides the exposed tool's confirmation flag. """ + + def _versioned_model_fields_to_exclude( + self, agentspec_version: AgentSpecVersionEnum + ) -> set[str]: + fields_to_exclude = super()._versioned_model_fields_to_exclude(agentspec_version) + if agentspec_version < AgentSpecVersionEnum.v26_2_0: + fields_to_exclude.add("retry_policy") + return fields_to_exclude + + def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + min_version = super()._infer_min_agentspec_version_from_configuration() + if self.retry_policy is not None: + min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + return min_version diff --git a/pyagentspec/tests/serialization/test_mcp_tools.py b/pyagentspec/tests/serialization/test_mcp_tools.py index bc9de2793..f49072a51 100644 --- a/pyagentspec/tests/serialization/test_mcp_tools.py +++ b/pyagentspec/tests/serialization/test_mcp_tools.py @@ -123,6 +123,94 @@ def test_agent_with_mcp_tool_and_transport_retry_policy_can_be_serialized_then_d assert AgentSpecSerializer().to_dict(loaded_agent) == dumped_agent +def test_mcp_tool_with_retry_policy_can_be_serialized_then_deserialized() -> None: + mcp_tool = MCPTool( + id="mcp_tool", + name="mcp_tool_with_retry", + client_transport=SSETransport( + id="client_transport_component_id", + name="sse_mcp_transport", + url="https://some.where/sse", + ), + retry_policy=RetryPolicy(max_attempts=3, initial_retry_delay=0.25), + ) + + dumped_tool = AgentSpecSerializer().to_dict(mcp_tool) + loaded_tool = AgentSpecDeserializer().from_dict(dumped_tool) + + assert dumped_tool["agentspec_version"] == AgentSpecVersionEnum.v26_2_0.value + assert dumped_tool["retry_policy"]["max_attempts"] == 3 + assert AgentSpecSerializer().to_dict(loaded_tool) == dumped_tool + + +def test_mcp_toolbox_with_retry_policy_can_be_serialized_then_deserialized() -> None: + mcp_toolbox = MCPToolBox( + id="mcp_toolbox", + name="mcp_toolbox_with_retry", + client_transport=SSETransport( + id="client_transport_component_id", + name="sse_mcp_transport", + url="https://some.where/sse", + ), + tool_filter=["mcp_tool"], + retry_policy=RetryPolicy(max_attempts=4, initial_retry_delay=0.5), + ) + + dumped_toolbox = AgentSpecSerializer().to_dict(mcp_toolbox) + loaded_toolbox = AgentSpecDeserializer().from_dict(dumped_toolbox) + + assert dumped_toolbox["agentspec_version"] == AgentSpecVersionEnum.v26_2_0.value + assert dumped_toolbox["retry_policy"]["max_attempts"] == 4 + assert AgentSpecSerializer().to_dict(loaded_toolbox) == dumped_toolbox + + +@pytest.mark.parametrize( + "component", + [ + MCPTool( + name="mcp_tool_with_retry", + client_transport=SSETransport(name="sse_mcp_transport", url="https://some.where/sse"), + retry_policy=RetryPolicy(max_attempts=3), + ), + MCPToolBox( + name="mcp_toolbox_with_retry", + client_transport=SSETransport(name="sse_mcp_transport", url="https://some.where/sse"), + retry_policy=RetryPolicy(max_attempts=3), + ), + ], +) +def test_mcp_retry_policy_requires_v26_2(component) -> None: + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecSerializer().to_dict(component, agentspec_version=AgentSpecVersionEnum.v26_1_2) + + dumped = AgentSpecSerializer().to_dict(component) + dumped["agentspec_version"] = AgentSpecVersionEnum.v26_1_2.value + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecDeserializer().from_dict(dumped) + + +@pytest.mark.parametrize( + "component", + [ + MCPTool( + name="mcp_tool_without_retry", + client_transport=SSETransport(name="sse_mcp_transport", url="https://some.where/sse"), + ), + MCPToolBox( + name="mcp_toolbox_without_retry", + client_transport=SSETransport(name="sse_mcp_transport", url="https://some.where/sse"), + ), + ], +) +def test_mcp_components_without_retry_policy_can_serialize_before_v26_2(component) -> None: + dumped = AgentSpecSerializer().to_dict( + component, + agentspec_version=AgentSpecVersionEnum.v26_1_2, + ) + + assert "retry_policy" not in dumped + + def test_deserializing_mcp_tool_box_with_confirmation_raises_on_version_less_than_26_2(): mcp_server_url = f"http://localhost:8080/sse" mcp_client = SSETransport(name="MCP Client", url=mcp_server_url)