Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3713,6 +3713,17 @@
"client_transport": {
"$ref": "#/$defs/ClientTransport"
},
"retry_policy": {
"anyOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/RetryPolicy"
}
],
"default": null
},
"$referenced_components": {
"$ref": "#/$defs/ReferencedComponents"
},
Expand Down Expand Up @@ -3772,6 +3783,17 @@
"client_transport": {
"$ref": "#/$defs/ClientTransport"
},
"retry_policy": {
"anyOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/RetryPolicy"
}
],
"default": null
},
"tool_filter": {
"anyOf": [
{
Expand Down
15 changes: 15 additions & 0 deletions docs/pyagentspec/source/agentspec/language_spec_nightly.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

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.

could explain more clearly what "resolution" and "execution" mean

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.

Expand Down Expand Up @@ -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

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.

I am not sure about what does this involve (same for the equivalent paragraph in MCPTool). What kind of failures does it apply to? Does it mean that, for example, service_error_retry_on_any_5xx and recoverable_statuses don't apply (in which case we should make clear that these attributes should not be part of it)?

I think we need to be more clear on what this applies to, as we say "it's not the same as this other thing", but I feel like we don't say what it is. Also, whenever there's a "Runtime implementations decide ..." we are giving up on cross-framework consistency at spec level. I think we need to say what it should apply to, and if runtimes don't support it, that's on the runtime adapter.

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
Expand Down
6 changes: 6 additions & 0 deletions docs/pyagentspec/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^^^^^^^^^

Expand Down
18 changes: 18 additions & 0 deletions docs/pyagentspec/source/code_examples/howto_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(

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.

should we have an example with both the retry on the transport and the retry on the MCP tool?

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)

Expand Down
12 changes: 12 additions & 0 deletions docs/pyagentspec/source/howtoguides/howto_mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ You can then equip an agent with the toolbox similarly to tools.
for a tool (see :ref:`Tool <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
-------------------

Expand Down
35 changes: 35 additions & 0 deletions pyagentspec/src/pyagentspec/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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.
Expand All @@ -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
88 changes: 88 additions & 0 deletions pyagentspec/tests/serialization/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading