From 418afacd7f3f455b0d1d461dff8226eff1a1c487 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Thu, 10 Sep 2026 07:38:04 +0000 Subject: [PATCH] fix(python): size MCP tool-call timeouts from accept maxTimeoutSeconds Paid MCP tool calls wait for the accept's maxTimeoutSeconds (default 300s). The initial 402 probe uses a 300s ceiling unless the caller sets read_timeout_seconds. Co-authored-by: phdargen --- python/x402/mcp/client.py | 41 +++- python/x402/mcp/client_async.py | 27 ++- .../tests/unit/mcp/test_client_timeout.py | 208 ++++++++++++++++++ 3 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 python/x402/tests/unit/mcp/test_client_timeout.py diff --git a/python/x402/mcp/client.py b/python/x402/mcp/client.py index 397abf190c..b4fb9a5e29 100644 --- a/python/x402/mcp/client.py +++ b/python/x402/mcp/client.py @@ -25,6 +25,7 @@ import re from contextlib import asynccontextmanager from dataclasses import dataclass, field +from datetime import timedelta from typing import Any from ..client import x402Client, x402ClientSync @@ -93,6 +94,7 @@ async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, + read_timeout_seconds: timedelta | None = None, ) -> MCPToolCallResult: """Call a tool with automatic x402 payment handling. @@ -104,14 +106,20 @@ async def call_tool( Args: name: Tool name to call. arguments: Arguments to pass to the tool. + read_timeout_seconds: MCP request timeout. Overrides accept + ``maxTimeoutSeconds``. The initial 402 probe uses 300s when omitted. Returns: MCPToolCallResult with content, payment info, and error status. """ + probe_timeout = ( + read_timeout_seconds if read_timeout_seconds is not None else timedelta(seconds=300) + ) # First call without payment result = await self._session.call_tool( name=name, arguments=arguments or {}, + read_timeout_seconds=probe_timeout, ) # If no error, return directly @@ -132,11 +140,22 @@ async def call_tool( # Serialize for transmission payload_dict = payment_payload.model_dump(by_alias=True) + accepted = getattr(payment_payload, "accepted", None) + max_timeout_seconds = ( + getattr(accepted, "max_timeout_seconds", None) if accepted is not None else None + ) + paid_timeout = ( + read_timeout_seconds + if read_timeout_seconds is not None + else timedelta(seconds=300 if max_timeout_seconds is None else max_timeout_seconds) + ) + # Retry with payment in _meta result = await self._session.call_tool( name=name, arguments=arguments or {}, meta={MCP_PAYMENT_META_KEY: payload_dict}, + read_timeout_seconds=paid_timeout, ) return self._build_result(result, payment_made=True) @@ -241,15 +260,21 @@ def call_tool( Args: name: Tool name args: Tool arguments - **kwargs: Additional MCP client options + **kwargs: Additional MCP client options. ``read_timeout_seconds`` + overrides accept ``maxTimeoutSeconds``. The initial 402 probe + uses 300s when omitted. Returns: MCPToolCallResult with content, payment info, and error status """ args = args or {} params = {"name": name, "arguments": args} + probe_timeout = kwargs.get("read_timeout_seconds") + if probe_timeout is None: + probe_timeout = timedelta(seconds=300) + probe_kwargs = {**kwargs, "read_timeout_seconds": probe_timeout} - result = self._mcp_client.call_tool(params, **kwargs) + result = self._mcp_client.call_tool(params, **probe_kwargs) mcp_result = convert_mcp_result(result) payment_required = extract_payment_required_from_result(mcp_result) @@ -274,7 +299,17 @@ def call_tool( "arguments": args, "_meta": {MCP_PAYMENT_META_KEY: payload_dict}, } - result = self._mcp_client.call_tool(params_with_meta, **kwargs) + accepted = getattr(payment_payload, "accepted", None) + max_timeout_seconds = ( + getattr(accepted, "max_timeout_seconds", None) if accepted is not None else None + ) + paid_timeout = kwargs.get("read_timeout_seconds") + if paid_timeout is None: + paid_timeout = timedelta( + seconds=300 if max_timeout_seconds is None else max_timeout_seconds + ) + paid_kwargs = {**kwargs, "read_timeout_seconds": paid_timeout} + result = self._mcp_client.call_tool(params_with_meta, **paid_kwargs) mcp_result = convert_mcp_result(result) return self._build_result(mcp_result, payment_made=True) diff --git a/python/x402/mcp/client_async.py b/python/x402/mcp/client_async.py index 20bf41363d..bc0a7ae8a0 100644 --- a/python/x402/mcp/client_async.py +++ b/python/x402/mcp/client_async.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Awaitable, Callable +from datetime import timedelta from typing import Any from ..schemas import PaymentPayload, PaymentRequired @@ -150,7 +151,9 @@ async def call_tool( Args: name: Tool name args: Tool arguments - **kwargs: Additional MCP client options + **kwargs: Additional MCP client options. ``read_timeout_seconds`` + overrides accept ``maxTimeoutSeconds``. The initial 402 probe + uses 300s when omitted. Returns: Tool call result with payment metadata @@ -160,7 +163,11 @@ async def call_tool( """ # First attempt without payment call_params = {"name": name, "arguments": args} - result = await self._call_mcp_tool(call_params, **kwargs) + probe_timeout = kwargs.get("read_timeout_seconds") + if probe_timeout is None: + probe_timeout = timedelta(seconds=300) + probe_kwargs = {**kwargs, "read_timeout_seconds": probe_timeout} + result = await self._call_mcp_tool(call_params, **probe_kwargs) # Check if this is a payment required response payment_required = extract_payment_required_from_result(result) @@ -238,7 +245,8 @@ async def call_tool_with_payment( name: Tool name args: Tool arguments payload: Payment payload - **kwargs: Additional MCP client options + **kwargs: Additional MCP client options. ``read_timeout_seconds`` + overrides accept ``maxTimeoutSeconds``. Returns: Tool call result with payment metadata @@ -246,8 +254,19 @@ async def call_tool_with_payment( # Build call params with payment in _meta call_params = attach_payment_to_meta({"name": name, "arguments": args}, payload) + accepted = getattr(payload, "accepted", None) + max_timeout_seconds = ( + getattr(accepted, "max_timeout_seconds", None) if accepted is not None else None + ) + paid_timeout = kwargs.get("read_timeout_seconds") + if paid_timeout is None: + paid_timeout = timedelta( + seconds=300 if max_timeout_seconds is None else max_timeout_seconds + ) + paid_kwargs = {**kwargs, "read_timeout_seconds": paid_timeout} + # Call with payment - result = await self._call_mcp_tool(call_params, **kwargs) + result = await self._call_mcp_tool(call_params, **paid_kwargs) # Extract payment response settle_response = extract_payment_response_from_meta(result) diff --git a/python/x402/tests/unit/mcp/test_client_timeout.py b/python/x402/tests/unit/mcp/test_client_timeout.py new file mode 100644 index 0000000000..d93ad760db --- /dev/null +++ b/python/x402/tests/unit/mcp/test_client_timeout.py @@ -0,0 +1,208 @@ +"""MCP client tool-call timeouts follow accept maxTimeoutSeconds.""" + +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from x402.mcp.client import x402MCPClientSync, x402MCPSession +from x402.mcp.client_async import x402MCPClient +from x402.schemas import PaymentPayload + + +def _payment_required_text(max_timeout_seconds: int = 300) -> str: + return ( + '{"x402Version":2,"accepts":[{"scheme":"exact","network":"eip155:84532",' + f'"amount":"1000","asset":"USDC","payTo":"0xrecipient",' + f'"maxTimeoutSeconds":{max_timeout_seconds}}}]}}' + ) + + +def _payload(max_timeout_seconds: int = 300) -> PaymentPayload: + return PaymentPayload( + x402_version=2, + accepted={ + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "USDC", + "pay_to": "0xrecipient", + "max_timeout_seconds": max_timeout_seconds, + }, + payload={"signature": "0x123"}, + ) + + +class _Text: + def __init__(self, text: str) -> None: + self.text = text + + +class _SessionResult: + def __init__(self, *, is_error: bool, text: str, meta: dict | None = None) -> None: + self.isError = is_error + self.content = [_Text(text)] + self.meta = meta + self.structuredContent = None + + +class _McpResult: + def __init__(self, *, is_error: bool, text: str, meta: dict | None = None) -> None: + self.content = [{"type": "text", "text": text}] + self.isError = is_error + self._meta = meta or {} + self.structuredContent = None + + +def _read_timeout(call) -> timedelta: + return call.kwargs["read_timeout_seconds"] + + +@pytest.mark.asyncio +async def test_session_probe_uses_300s_ceiling() -> None: + session = SimpleNamespace( + call_tool=AsyncMock(return_value=_SessionResult(is_error=False, text="pong")) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock()) + + result = await x402MCPSession(session, x402_client).call_tool("ping", {}) + + assert result.payment_made is False + assert result.content[0].text == "pong" + assert _read_timeout(session.call_tool.await_args) == timedelta(seconds=300) + x402_client.create_payment_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_session_paid_timeout_uses_accept_max_timeout_seconds() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(600)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + + result = await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + assert result.payment_made is True + assert result.content[0].text == "ok" + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == timedelta(seconds=300) + assert _read_timeout(paid_call) == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_session_paid_timeout_defaults_to_300s_without_accept() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text()), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + payload = SimpleNamespace(accepted=None, model_dump=lambda **_kwargs: {"payload": {}}) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=payload)) + + result = await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == timedelta(seconds=300) + assert _read_timeout(paid_call) == timedelta(seconds=300) + + +@pytest.mark.asyncio +async def test_session_explicit_timeout_overrides_accept() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(600)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + override = timedelta(seconds=12) + + await x402MCPSession(session, x402_client).call_tool( + "paid_tool", {}, read_timeout_seconds=override + ) + + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == override + assert _read_timeout(paid_call) == override + + +@pytest.mark.asyncio +async def test_async_client_probe_uses_300s_ceiling() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock(return_value=_McpResult(is_error=False, text="pong")) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock()) + + result = await x402MCPClient(mock_mcp, mock_payment).call_tool("ping", {}) + + assert result.payment_made is False + assert result.content[0]["text"] == "pong" + assert mock_mcp.call_tool.await_args.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + mock_payment.create_payment_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_client_paid_timeout_uses_accept_max_timeout_seconds() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(600)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + + result = await x402MCPClient(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = mock_mcp.call_tool.await_args_list + assert probe_call.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_async_client_call_tool_with_payment_uses_accept_timeout() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock(return_value=_McpResult(is_error=False, text="ok")) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock()) + + await x402MCPClient(mock_mcp, mock_payment).call_tool_with_payment( + "paid_tool", {}, _payload(90) + ) + + assert mock_mcp.call_tool.await_args.kwargs["read_timeout_seconds"] == timedelta(seconds=90) + + +def test_sync_client_paid_timeout_uses_accept_max_timeout_seconds() -> None: + mock_mcp = SimpleNamespace( + call_tool=Mock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(600)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=Mock(return_value=_payload(600))) + + result = x402MCPClientSync(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = mock_mcp.call_tool.call_args_list + assert probe_call.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600)