Skip to content
Closed
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
41 changes: 38 additions & 3 deletions python/x402/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

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

Expand Down
27 changes: 23 additions & 4 deletions python/x402/mcp/client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -238,16 +245,28 @@ 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
"""
# 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)
Expand Down
208 changes: 208 additions & 0 deletions python/x402/tests/unit/mcp/test_client_timeout.py
Original file line number Diff line number Diff line change
@@ -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)
Loading