diff --git a/python/x402/mcp/client.py b/python/x402/mcp/client.py index 3ed9b43f0c..46075430bb 100644 --- a/python/x402/mcp/client.py +++ b/python/x402/mcp/client.py @@ -33,6 +33,7 @@ from .constants import MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY from .utils import ( convert_mcp_result, + extract_payment_required_from_error, extract_payment_required_from_result, extract_payment_response_from_meta, ) @@ -241,7 +242,14 @@ def call_tool( args = args or {} params = {"name": name, "arguments": args} - result = self._mcp_client.call_tool(params, **kwargs) + try: + result = self._mcp_client.call_tool(params, **kwargs) + except Exception as exc: + payment_required = extract_payment_required_from_error(exc) + if payment_required is None: + raise + return self._handle_payment_required(name, args, payment_required, **kwargs) + mcp_result = convert_mcp_result(result) payment_required = extract_payment_required_from_result(mcp_result) @@ -251,12 +259,26 @@ def call_tool( if not self._auto_payment: return self._build_result(mcp_result, payment_made=False) + return self._handle_payment_required(name, args, payment_required, **kwargs) + + def _handle_payment_required( + self, + name: str, + args: dict[str, Any], + payment_required: PaymentRequired, + **kwargs: Any, + ) -> MCPToolCallResult: + """Handle a payment-required signal (from isError result or thrown exception).""" if self._on_payment_requested: approved = self._on_payment_requested( type("Ctx", (), {"payment_required": payment_required})() ) if not approved: - return self._build_result(mcp_result, payment_made=False) + return MCPToolCallResult( + content=[], + is_error=True, + payment_made=False, + ) payment_payload = self._payment_client.create_payment_payload(payment_required) payload_dict = payment_payload.model_dump(by_alias=True) diff --git a/python/x402/mcp/client_async.py b/python/x402/mcp/client_async.py index 35d07592b0..b9494c0f3a 100644 --- a/python/x402/mcp/client_async.py +++ b/python/x402/mcp/client_async.py @@ -18,6 +18,7 @@ from .utils import ( attach_payment_to_meta, convert_mcp_result, + extract_payment_required_from_error, extract_payment_required_from_result, extract_payment_response_from_meta, register_schemes, @@ -161,7 +162,14 @@ async def call_tool( """ # First attempt without payment call_params = {"name": name, "arguments": args} - result = await self._call_mcp_tool(call_params, **kwargs) + try: + result = await self._call_mcp_tool(call_params, **kwargs) + except Exception as exc: + # Check if the thrown exception carries payment data (e.g. McpError -32042) + payment_required = extract_payment_required_from_error(exc) + if payment_required is None: + raise + return await self._handle_payment_required(name, args, payment_required, **kwargs) # Check if this is a payment required response payment_required = extract_payment_required_from_result(result) @@ -174,7 +182,19 @@ async def call_tool( payment_made=False, ) - # Payment required - run hooks first + return await self._handle_payment_required(name, args, payment_required, **kwargs) + + async def _handle_payment_required( + self, + name: str, + args: dict[str, Any], + payment_required: PaymentRequired, + **kwargs: Any, + ) -> MCPToolCallResult: + """Handle a payment-required signal (from isError result or thrown exception). + + Runs hooks, checks auto_payment, creates payment, and retries. + """ payment_required_context = PaymentRequiredContext( tool_name=name, arguments=args, @@ -291,7 +311,10 @@ async def get_tool_payment_requirements( PaymentRequired if found, None otherwise """ call_params = {"name": name, "arguments": args} - result = await self._call_mcp_tool(call_params, **kwargs) + try: + result = await self._call_mcp_tool(call_params, **kwargs) + except Exception as exc: + return extract_payment_required_from_error(exc) return extract_payment_required_from_result(result) async def _call_mcp_tool(self, params: dict[str, Any], **kwargs: Any) -> MCPToolResult: diff --git a/python/x402/mcp/tests/test_mcperror.py b/python/x402/mcp/tests/test_mcperror.py new file mode 100644 index 0000000000..d6bb835ab1 --- /dev/null +++ b/python/x402/mcp/tests/test_mcperror.py @@ -0,0 +1,396 @@ +"""Unit tests for McpError(-32042) payment challenge handling. + +Tests the ability to extract PaymentRequired from thrown exceptions +(SEP-1036 UrlElicitationRequired) in addition to the existing isError +result path. Mirrors the TypeScript tests in PR #1728. +""" + +from unittest.mock import AsyncMock + +import pytest + +from x402.mcp import PaymentRequiredError, x402MCPClient +from x402.mcp.client import x402MCPClientSync +from x402.mcp.types import JSONRPC_PAYMENT_REQUIRED_CODE, MCP_PAYMENT_REQUIRED_CODE +from x402.mcp.utils import ( + extract_payment_required_from_error, + is_payment_required_error, +) +from x402.schemas import PaymentPayload + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +PAYMENT_REQUIRED_DATA = { + "x402Version": 2, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "USDC", + "payTo": "0xrecipient", + "maxTimeoutSeconds": 300, + } + ], +} + + +class McpError(Exception): + """Simulates the MCP SDK McpError that servers throw.""" + + def __init__(self, code: int, message: str, data=None): + super().__init__(message) + self.code = code + self.data = data + + +class MockAsyncMCPResult: + def __init__(self, content=None, is_error=False, meta=None, structured_content=None): + self.content = content or [{"type": "text", "text": "pong"}] + self.isError = is_error + self._meta = meta or {} + self.structuredContent = structured_content + + +class MockAsyncMCPClient: + def __init__(self): + self.call_tool = AsyncMock() + + +class MockAsyncPaymentClient: + def __init__(self): + self.create_payment_payload = AsyncMock( + return_value=PaymentPayload( + x402_version=2, + accepted={ + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "USDC", + "payTo": "0xrecipient", + "maxTimeoutSeconds": 300, + }, + payload={"signature": "0xabc"}, + ) + ) + + +class MockSyncMCPClient: + def __init__(self): + from unittest.mock import MagicMock + + self.call_tool = MagicMock() + + +class MockSyncPaymentClient: + def __init__(self): + from unittest.mock import MagicMock + + self.create_payment_payload = MagicMock( + return_value=PaymentPayload( + x402_version=2, + accepted={ + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "USDC", + "payTo": "0xrecipient", + "maxTimeoutSeconds": 300, + }, + payload={"signature": "0xabc"}, + ) + ) + + +# =================================================================== +# extract_payment_required_from_error — unit tests +# =================================================================== + + +class TestExtractPaymentRequiredFromError: + """Tests for the updated extract_payment_required_from_error utility.""" + + def test_direct_exception_with_payment_data(self): + """McpError(-32042) with PaymentRequired directly in data.""" + err = McpError(-32042, "payment required", data=PAYMENT_REQUIRED_DATA) + pr = extract_payment_required_from_error(err) + assert pr is not None + assert pr.x402_version == 2 + assert len(pr.accepts) == 1 + + def test_namespaced_exception(self): + """McpError(-32042) with PaymentRequired under data.x402.""" + err = McpError( + -32042, + "payment required", + data={"x402": PAYMENT_REQUIRED_DATA}, + ) + pr = extract_payment_required_from_error(err) + assert pr is not None + assert pr.x402_version == 2 + + def test_non_payment_error_returns_none(self): + """McpError with a different code returns None.""" + err = McpError(-32600, "invalid request", data=PAYMENT_REQUIRED_DATA) + pr = extract_payment_required_from_error(err) + assert pr is None + + def test_no_data_returns_none(self): + """McpError(-32042) without data returns None.""" + err = McpError(-32042, "payment required", data=None) + pr = extract_payment_required_from_error(err) + assert pr is None + + def test_empty_data_returns_none(self): + """McpError(-32042) with empty dict data returns None.""" + err = McpError(-32042, "payment required", data={}) + pr = extract_payment_required_from_error(err) + assert pr is None + + def test_dict_error_402(self): + """Legacy dict-like error with code 402 still works.""" + err = {"code": 402, "data": PAYMENT_REQUIRED_DATA} + pr = extract_payment_required_from_error(err) + assert pr is not None + assert pr.x402_version == 2 + + def test_dict_error_32042(self): + """Dict-like error with code -32042 works.""" + err = {"code": -32042, "data": PAYMENT_REQUIRED_DATA} + pr = extract_payment_required_from_error(err) + assert pr is not None + + def test_dict_error_wrong_code(self): + """Dict-like error with wrong code returns None.""" + err = {"code": 500, "data": PAYMENT_REQUIRED_DATA} + pr = extract_payment_required_from_error(err) + assert pr is None + + def test_plain_exception_returns_none(self): + """Regular Exception without code/data returns None.""" + err = Exception("something broke") + pr = extract_payment_required_from_error(err) + assert pr is None + + +# =================================================================== +# is_payment_required_error — unit tests +# =================================================================== + + +class TestIsPaymentRequiredError: + """Tests for the updated is_payment_required_error utility.""" + + def test_payment_required_error_instance(self): + err = PaymentRequiredError("pay up") + assert is_payment_required_error(err) is True + + def test_mcperror_32042(self): + err = McpError(-32042, "payment required") + assert is_payment_required_error(err) is True + + def test_mcperror_402(self): + err = McpError(402, "payment required") + assert is_payment_required_error(err) is True + + def test_mcperror_other_code(self): + err = McpError(-32600, "bad request") + assert is_payment_required_error(err) is False + + def test_plain_exception(self): + err = Exception("nope") + assert is_payment_required_error(err) is False + + +# =================================================================== +# Async client — McpError(-32042) handling +# =================================================================== + + +@pytest.mark.asyncio +async def test_async_client_handles_thrown_32042(): + """Async client catches McpError(-32042) and proceeds with payment.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + # First call throws McpError(-32042) + mock_mcp.call_tool.side_effect = [ + McpError(-32042, "payment required", data=PAYMENT_REQUIRED_DATA), + MockAsyncMCPResult(content=[{"type": "text", "text": "success"}]), + ] + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=True) + result = await client.call_tool("paid_tool", {"q": "test"}) + + assert result.payment_made is True + assert not result.is_error + mock_payment.create_payment_payload.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_client_handles_namespaced_32042(): + """Async client handles namespaced data.x402 in McpError(-32042).""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = [ + McpError(-32042, "payment required", data={"x402": PAYMENT_REQUIRED_DATA}), + MockAsyncMCPResult(content=[{"type": "text", "text": "success"}]), + ] + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=True) + result = await client.call_tool("paid_tool", {}) + + assert result.payment_made is True + mock_payment.create_payment_payload.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_client_rethrows_non_payment_error(): + """Async client re-raises non-payment exceptions.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError(-32600, "invalid request") + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=True) + with pytest.raises(McpError, match="invalid request"): + await client.call_tool("tool", {}) + + mock_payment.create_payment_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_client_rethrows_32042_without_valid_data(): + """Async client re-raises -32042 if data doesn't contain valid PaymentRequired.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError(-32042, "no payment data", data={"foo": "bar"}) + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=True) + with pytest.raises(McpError, match="no payment data"): + await client.call_tool("tool", {}) + + +@pytest.mark.asyncio +async def test_async_client_auto_payment_false_raises(): + """Async client raises PaymentRequiredError when auto_payment=False and -32042 thrown.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError( + -32042, "payment required", data=PAYMENT_REQUIRED_DATA + ) + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=False) + with pytest.raises(PaymentRequiredError): + await client.call_tool("tool", {}) + + +@pytest.mark.asyncio +async def test_async_get_tool_payment_requirements_from_32042(): + """get_tool_payment_requirements extracts from thrown McpError(-32042).""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError( + -32042, "payment required", data=PAYMENT_REQUIRED_DATA + ) + + client = x402MCPClient(mock_mcp, mock_payment) + pr = await client.get_tool_payment_requirements("tool", {}) + + assert pr is not None + assert pr.x402_version == 2 + assert len(pr.accepts) == 1 + + +@pytest.mark.asyncio +async def test_async_get_tool_payment_requirements_non_payment_returns_none(): + """get_tool_payment_requirements returns None for non-payment exceptions.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError(-32600, "bad request") + + client = x402MCPClient(mock_mcp, mock_payment) + pr = await client.get_tool_payment_requirements("tool", {}) + assert pr is None + + +@pytest.mark.asyncio +async def test_async_client_backward_compat_iserror(): + """Existing isError path still works alongside -32042 handling.""" + mock_mcp = MockAsyncMCPClient() + mock_payment = MockAsyncPaymentClient() + + # Return payment required via isError (legacy path) + import json + + mock_mcp.call_tool.side_effect = [ + MockAsyncMCPResult( + content=[{"type": "text", "text": json.dumps(PAYMENT_REQUIRED_DATA)}], + is_error=True, + ), + MockAsyncMCPResult(content=[{"type": "text", "text": "success"}]), + ] + + client = x402MCPClient(mock_mcp, mock_payment, auto_payment=True) + result = await client.call_tool("tool", {}) + + assert result.payment_made is True + mock_payment.create_payment_payload.assert_called_once() + + +# =================================================================== +# Sync client — McpError(-32042) handling +# =================================================================== + + +def test_sync_client_handles_thrown_32042(): + """Sync client catches McpError(-32042) and proceeds with payment.""" + mock_mcp = MockSyncMCPClient() + mock_payment = MockSyncPaymentClient() + + # First call throws, second succeeds + success_result = MockAsyncMCPResult(content=[{"type": "text", "text": "ok"}]) + mock_mcp.call_tool.side_effect = [ + McpError(-32042, "payment required", data=PAYMENT_REQUIRED_DATA), + success_result, + ] + + client = x402MCPClientSync(mock_mcp, mock_payment, auto_payment=True) + result = client.call_tool("tool", {}) + + assert result.payment_made is True + mock_payment.create_payment_payload.assert_called_once() + + +def test_sync_client_rethrows_non_payment_error(): + """Sync client re-raises non-payment exceptions.""" + mock_mcp = MockSyncMCPClient() + mock_payment = MockSyncPaymentClient() + + mock_mcp.call_tool.side_effect = McpError(-32600, "invalid request") + + client = x402MCPClientSync(mock_mcp, mock_payment, auto_payment=True) + with pytest.raises(McpError, match="invalid request"): + client.call_tool("tool", {}) + + +# =================================================================== +# Constants +# =================================================================== + + +def test_jsonrpc_payment_required_code_value(): + """Verify the constant matches SEP-1036.""" + assert JSONRPC_PAYMENT_REQUIRED_CODE == -32042 + + +def test_mcp_payment_required_code_unchanged(): + """Verify the original 402 constant is unchanged.""" + assert MCP_PAYMENT_REQUIRED_CODE == 402 diff --git a/python/x402/mcp/types.py b/python/x402/mcp/types.py index fde734d545..1951bdace0 100644 --- a/python/x402/mcp/types.py +++ b/python/x402/mcp/types.py @@ -11,6 +11,7 @@ # Protocol constants for MCP x402 payment integration. MCP_PAYMENT_REQUIRED_CODE = 402 +JSONRPC_PAYMENT_REQUIRED_CODE = -32042 MCP_PAYMENT_META_KEY = "x402/payment" MCP_PAYMENT_RESPONSE_META_KEY = "x402/payment-response" diff --git a/python/x402/mcp/utils.py b/python/x402/mcp/utils.py index 6f1b62c6fa..5ad9522fde 100644 --- a/python/x402/mcp/utils.py +++ b/python/x402/mcp/utils.py @@ -5,6 +5,7 @@ from ..schemas import PaymentPayload, PaymentRequired, SettleResponse from .types import ( + JSONRPC_PAYMENT_REQUIRED_CODE, MCP_PAYMENT_META_KEY, MCP_PAYMENT_REQUIRED_CODE, MCP_PAYMENT_RESPONSE_META_KEY, @@ -238,44 +239,58 @@ def create_payment_required_error( def extract_payment_required_from_error(error: Any) -> PaymentRequired | None: """Extract PaymentRequired from an MCP JSON-RPC error. - This function checks if the error is a 402 payment required error and extracts - the PaymentRequired data from the error's data field. + Handles both thrown exceptions (with .code and .data attributes) and + dict-like error objects. Recognises code 402 (legacy) and -32042 + (SEP-1036 UrlElicitationRequired). Args: - error: The error object from a JSON-RPC response + error: The error object — an Exception with ``code``/``data`` attrs, + or a dict with ``code``/``data`` keys. Returns: - PaymentRequired if this is a 402 error, None otherwise - - Example: - ```python - from x402.mcp import extract_payment_required_from_error - - try: - result = client.call_tool("tool", {}) - except Exception as err: - pr = extract_payment_required_from_error(err) - if pr: - # Handle payment required - pass - ``` + PaymentRequired if valid payment data is found, None otherwise. """ - if not is_object(error): - return None + _PAYMENT_CODES = {MCP_PAYMENT_REQUIRED_CODE, JSONRPC_PAYMENT_REQUIRED_CODE} + + # --- Exception path (McpError with .code / .data) --- + if isinstance(error, Exception): + code = getattr(error, "code", None) + if code not in _PAYMENT_CODES: + return None + data = getattr(error, "data", None) + if not is_object(data): + return None + return _try_parse_payment_data(data) + + # --- Dict path (legacy JSON-RPC error dicts) --- + if is_object(error): + code = error.get("code") + if code not in _PAYMENT_CODES: + return None + data = error.get("data") + if not is_object(data): + return None + return _try_parse_payment_data(data) - # Check if this is a 402 payment required error - code = error.get("code") - if code != MCP_PAYMENT_REQUIRED_CODE: - return None + return None - # Extract and validate the data field - data = error.get("data") - if not is_object(data): + +def _try_parse_payment_data(data: dict[str, Any]) -> PaymentRequired | None: + """Try to parse PaymentRequired from a data dict. + + Handles both direct PaymentRequired data and namespaced ``data.x402``. + """ + if not data: return None - # Normalize camelCase to snake_case for Pydantic - normalized_data = {("x402_version" if k == "x402Version" else k): v for k, v in data.items()} - return _extract_payment_required_from_object(normalized_data) + # Check for namespaced x402 key first + if "x402" in data and is_object(data["x402"]): + pr = _extract_payment_required_from_object(data["x402"]) + if pr is not None: + return pr + + # Try direct extraction + return _extract_payment_required_from_object(data) def convert_mcp_result(mcp_result: Any) -> "MCPToolResult": @@ -339,14 +354,20 @@ def register_schemes(payment_client: Any, schemes: list[dict[str, Any]]) -> None def is_payment_required_error(error: Exception) -> bool: - """Check if an error is a PaymentRequiredError. + """Check if an error is a payment-required error. + + Returns True for PaymentRequiredError instances as well as any exception + whose ``code`` attribute equals 402 or -32042. Args: error: The error to check Returns: - True if the error is a PaymentRequiredError + True if the error indicates payment is required """ from .types import PaymentRequiredError - return isinstance(error, PaymentRequiredError) + if isinstance(error, PaymentRequiredError): + return True + code = getattr(error, "code", None) + return code in {MCP_PAYMENT_REQUIRED_CODE, JSONRPC_PAYMENT_REQUIRED_CODE}