diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index 9740357..251ee14 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -6,7 +6,7 @@ Payment-protected MCP tools using Server-Sent Events (SSE). This example demonstrates: - **Server**: SSE-based MCP server with free and paid tools -- **Client**: Connects to server and handles the payment flow +- **Client**: Connects to server and handles payment automatically via `McpClient` The server and client run in separate terminals, communicating via SSE. @@ -73,15 +73,9 @@ Available tools: 1. Calling free tool (echo)... Result: Echo: Hello, world! -2. Calling paid tool without credential (premium_echo)... - Got error code: -32042 - Challenge ID: abc123... - -3. Creating payment credential... - Credential created for challenge: abc123... - -4. Retrying with credential... +2. Calling paid tool (premium_echo)... Result: ✨ Premium Echo ✨: Hello, premium! (paid by 0x..., tx: 0x...) + Receipt: success, ref=0x... ``` ## Server Implementations diff --git a/examples/mcp-server/client.py b/examples/mcp-server/client.py index c6bd8a4..42e83cd 100644 --- a/examples/mcp-server/client.py +++ b/examples/mcp-server/client.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -"""MCP client demonstrating the payment flow. +"""MCP client demonstrating automatic payment handling. Connects to an already-running MCP server via SSE and demonstrates: 1. Calling a free tool (echo) -2. Calling a paid tool without credentials (gets -32042 error) -3. Parsing the challenge, creating a credential, and retrying +2. Calling a paid tool (premium_echo) with automatic payment + +Uses McpClient to handle the payment flow automatically—no manual +challenge parsing or credential creation needed. Usage: # Terminal 1: Start the server @@ -27,11 +29,7 @@ from mcp import ClientSession from mcp.client.sse import sse_client -from mpp.extensions.mcp import ( - CODE_PAYMENT_REQUIRED, - MCPChallenge, - MCPCredential, -) +from mpp.extensions.mcp import McpClient from mpp.methods.tempo import ChargeIntent, TempoAccount, tempo SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://127.0.0.1:8000/sse") @@ -57,69 +55,30 @@ async def run_client() -> None: async with ClientSession(streams[0], streams[1]) as session: await session.initialize() + # Wrap the session with automatic payment handling + client = McpClient(session, methods=[method]) + tools = await session.list_tools() print("Available tools:") for tool in tools.tools: print(f" - {tool.name}: {tool.description}") print() + # 1. Free tool — works without payment print("1. Calling free tool (echo)...") - result = await session.call_tool("echo", {"message": "Hello, world!"}) - print(f" Result: {result.content[0].text}") + result = await client.call_tool("echo", {"message": "Hello, world!"}) + print(f" Result: {result.result.content[0].text}") print() - print("2. Calling paid tool without credential (premium_echo)...") - try: - result = await session.call_tool( - "premium_echo", {"message": "Hello, premium!"} - ) - print(f" Result: {result.content[0].text}") - except Exception as e: - error_data = getattr(e, "error", None) or {} - error_code = ( - error_data.get("code") - if isinstance(error_data, dict) - else getattr(error_data, "code", None) - ) - - print(f" Got error code: {error_code}") - - if error_code == CODE_PAYMENT_REQUIRED: - data = ( - error_data.get("data", {}) - if isinstance(error_data, dict) - else getattr(error_data, "data", {}) - ) - challenges = ( - data.get("challenges", []) if isinstance(data, dict) else [] - ) - - if challenges: - challenge_data = challenges[0] - print(f" Challenge ID: {challenge_data.get('id', 'unknown')}") - print() - - print("3. Creating payment credential...") - challenge = MCPChallenge.from_dict(challenge_data) - core_credential = await method.create_credential( - challenge.to_core() - ) - - mcp_credential = MCPCredential.from_core( - core_credential, challenge - ) - print(f" Credential created for challenge: {challenge.id}") - print() - - print("4. Retrying with credential...") - result = await session.call_tool( - "premium_echo", - {"message": "Hello, premium!"}, - meta=mcp_credential.to_meta(), - ) - print(f" Result: {result.content[0].text}") - else: - print(f" Unexpected error: {e}") + # 2. Paid tool — McpClient handles payment automatically + print("2. Calling paid tool (premium_echo)...") + result = await client.call_tool( + "premium_echo", {"message": "Hello, premium!"} + ) + print(f" Result: {result.result.content[0].text}") + if result.receipt: + print(f" Receipt: {result.receipt.status}, ref={result.receipt.reference}") + print() def main() -> None: diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index 948d45a..1dcfd8b 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -59,6 +59,7 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: """ from mpp.extensions.mcp.capabilities import payment_capabilities +from mpp.extensions.mcp.client import McpClient, McpToolResult from mpp.extensions.mcp.constants import ( CODE_MALFORMED_CREDENTIAL, CODE_PAYMENT_REQUIRED, diff --git a/src/mpp/extensions/mcp/client.py b/src/mpp/extensions/mcp/client.py new file mode 100644 index 0000000..859ef69 --- /dev/null +++ b/src/mpp/extensions/mcp/client.py @@ -0,0 +1,210 @@ +"""Payment-aware MCP client wrapper. + +Wraps an MCP SDK ``ClientSession`` with automatic payment handling. +When a tool call returns a ``-32042`` payment required error, the wrapper +creates a Credential and retries the call—mirroring the TypeScript +``McpClient.wrap`` API. + +Example: + from mcp import ClientSession + from mcp.client.sse import sse_client + from mpp.extensions.mcp import McpClient + from mpp.methods.tempo import tempo, TempoAccount, ChargeIntent + + account = TempoAccount.from_key("0x...") + method = tempo(account=account, intents={"charge": ChargeIntent()}) + + async with sse_client("http://localhost:8000/sse") as streams: + async with ClientSession(streams[0], streams[1]) as session: + await session.initialize() + + client = McpClient(session, methods=[method]) + result = await client.call_tool("premium_tool", {"query": "hello"}) + print(result.receipt) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from mpp.extensions.mcp.constants import CODE_PAYMENT_REQUIRED, META_RECEIPT +from mpp.extensions.mcp.types import MCPChallenge, MCPCredential, MCPReceipt + +if TYPE_CHECKING: + from mpp import Challenge, Credential + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class Method(Protocol): + """Payment method interface for MCP client credential creation.""" + + name: str + + async def create_credential(self, challenge: Challenge) -> Credential: + """Create a credential to satisfy the given challenge.""" + ... + + +def _is_payment_required_error(error: Exception) -> bool: + """Check whether an MCP error is a -32042 payment required error. + + Distinguishes payment errors from other uses of -32042 (such as + URL elicitation) by checking for a ``challenges`` array in ``error.data``. + """ + code = getattr(error, "code", None) + if code != CODE_PAYMENT_REQUIRED: + return False + data = getattr(error, "data", None) + if not isinstance(data, dict): + return False + challenges = data.get("challenges") + return isinstance(challenges, list) and len(challenges) > 0 + + +def _extract_challenges(error: Exception) -> list[dict[str, Any]]: + """Extract the challenges array from a payment required error.""" + data = getattr(error, "data", {}) + return data.get("challenges", []) if isinstance(data, dict) else [] + + +@dataclass(frozen=True, slots=True) +class McpToolResult: + """Result of a payment-aware tool call. + + Wraps the raw MCP ``CallToolResult`` and surfaces the payment receipt. + """ + + result: Any + receipt: MCPReceipt | None = None + + +class McpClient: + """Payment-aware MCP client wrapper. + + Wraps an MCP SDK ``ClientSession`` and overrides ``call_tool`` with + automatic payment handling. When a tool call returns ``-32042``, the + wrapper matches the challenge to an installed payment method, creates + a credential, and retries. + + Args: + session: An initialized ``mcp.ClientSession``. + methods: Payment methods available for credential creation. + + Example: + client = McpClient(session, methods=[tempo(...)]) + result = await client.call_tool("premium_tool", {"query": "hello"}) + print(result.receipt) + """ + + def __init__(self, session: Any, methods: list[Method]) -> None: + self._session = session + self._methods = methods + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + timeout: float | None = None, + meta: dict[str, Any] | None = None, + ) -> McpToolResult: + """Call an MCP tool with automatic payment handling. + + On a ``-32042`` error, matches the challenge to an installed method, + creates a credential, and retries the call with the credential in + ``params._meta``. + + Args: + name: Tool name. + arguments: Tool arguments. + timeout: Per-call timeout override (passed as ``read_timeout_seconds``). + meta: Additional ``_meta`` fields to include in the request. + + Returns: + An ``McpToolResult`` with the tool result and an optional receipt. + + Raises: + McpError: If the error is not payment-related or no method matches. + ValueError: If no installed method matches the server's challenge. + """ + from mcp.shared.exceptions import McpError + + call_kwargs: dict[str, Any] = {} + if timeout is not None: + call_kwargs["read_timeout_seconds"] = timeout + if meta is not None: + call_kwargs["meta"] = meta + + try: + result = await self._session.call_tool(name, arguments, **call_kwargs) + receipt = self._extract_receipt(result) + return McpToolResult(result=result, receipt=receipt) + + except McpError as e: + if not _is_payment_required_error(e): + raise + + challenges_data = _extract_challenges(e) + challenge, method = self._match_challenge(challenges_data) + + core_credential = await method.create_credential(challenge.to_core()) + mcp_credential = MCPCredential.from_core(core_credential, challenge) + + retry_meta = dict(meta) if meta else {} + retry_meta.update(mcp_credential.to_meta()) + + retry_kwargs: dict[str, Any] = {"meta": retry_meta} + if timeout is not None: + retry_kwargs["read_timeout_seconds"] = timeout + + retry_result = await self._session.call_tool(name, arguments, **retry_kwargs) + receipt = self._extract_receipt(retry_result) + return McpToolResult(result=retry_result, receipt=receipt) + + def _match_challenge( + self, challenges_data: list[dict[str, Any]] + ) -> tuple[MCPChallenge, Method]: + """Match a challenge to an installed method. + + Iterates installed methods in order (client preference) and returns + the first match by ``name`` and ``intent``. + """ + for method in self._methods: + for cd in challenges_data: + if cd.get("method") == method.name and cd.get("intent") in self._intent_names( + method + ): + return MCPChallenge.from_dict(cd), method + + available = [cd.get("method") for cd in challenges_data] + installed = [m.name for m in self._methods] + raise ValueError( + f"No compatible payment method. Server offered: {available}, client has: {installed}" + ) + + @staticmethod + def _intent_names(method: Method) -> set[str]: + """Get intent names supported by a method.""" + intents = getattr(method, "intents", None) or getattr(method, "_intents", None) + if isinstance(intents, dict): + return set(intents.keys()) + return {"charge"} + + @staticmethod + def _extract_receipt(result: Any) -> MCPReceipt | None: + """Extract a payment receipt from a tool result's _meta.""" + meta = getattr(result, "meta", None) + if not meta or not isinstance(meta, dict): + return None + receipt_data = meta.get(META_RECEIPT) + if receipt_data is None: + return None + try: + return MCPReceipt.from_dict(receipt_data) + except (KeyError, TypeError): + logger.warning("Failed to parse receipt from _meta") + return None diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py new file mode 100644 index 0000000..405c00a --- /dev/null +++ b/tests/test_mcp_client.py @@ -0,0 +1,426 @@ +"""Tests for MCP client wrapper (McpClient).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from mpp import Challenge, Credential +from mpp.extensions.mcp import ( + META_CREDENTIAL, + META_RECEIPT, + McpClient, + McpToolResult, +) +from mpp.extensions.mcp.client import _is_payment_required_error +from mpp.extensions.mcp.types import MCPChallenge, MCPReceipt + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeCallToolResult: + """Mimics mcp.types.CallToolResult.""" + + def __init__(self, content: list[dict] | None = None, meta: dict | None = None): + self.content = content or [{"type": "text", "text": "ok"}] + self.meta = meta + + +class FakeMcpError(Exception): + """Mimics mcp.shared.exceptions.McpError with code/data attributes.""" + + def __init__(self, code: int, message: str = "", data: Any = None): + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +@dataclass +class FakeMethod: + """Fake payment method for testing.""" + + name: str = "tempo" + _intents: dict[str, Any] | None = None + _credential_to_return: Credential | None = None + + def __post_init__(self): + if self._intents is None: + self._intents = {"charge": True} + if self._credential_to_return is None: + from mpp import ChallengeEcho + + echo = ChallengeEcho( + id="test-id", + realm="test.example.com", + method="tempo", + intent="charge", + request="e30", + ) + self._credential_to_return = Credential( + challenge=echo, + payload={"type": "transaction", "signature": "0xabc"}, + source="did:pkh:eip155:42431:0x1234", + ) + + async def create_credential(self, challenge: Challenge) -> Credential: + return self._credential_to_return # type: ignore[return-value] + + +def _make_challenge_dict( + method: str = "tempo", + intent: str = "charge", +) -> dict[str, Any]: + return { + "id": "ch_test123", + "realm": "api.example.com", + "method": method, + "intent": intent, + "request": {"amount": "1000", "currency": "0x20c0", "recipient": "0xdead"}, + "expires": "2099-01-01T00:00:00Z", + } + + +def _make_receipt_meta() -> dict[str, Any]: + return { + META_RECEIPT: { + "status": "success", + "challengeId": "ch_test123", + "method": "tempo", + "timestamp": "2025-06-15T12:00:00Z", + "reference": "0xtxhash", + } + } + + +# --------------------------------------------------------------------------- +# _is_payment_required_error +# --------------------------------------------------------------------------- + + +class TestIsPaymentRequiredError: + def test_correct_error(self) -> None: + err = FakeMcpError(-32042, data={"challenges": [_make_challenge_dict()]}) + assert _is_payment_required_error(err) is True + + def test_wrong_code(self) -> None: + err = FakeMcpError(-32000, data={"challenges": [_make_challenge_dict()]}) + assert _is_payment_required_error(err) is False + + def test_no_challenges(self) -> None: + err = FakeMcpError(-32042, data={}) + assert _is_payment_required_error(err) is False + + def test_empty_challenges(self) -> None: + err = FakeMcpError(-32042, data={"challenges": []}) + assert _is_payment_required_error(err) is False + + def test_no_data(self) -> None: + err = FakeMcpError(-32042) + assert _is_payment_required_error(err) is False + + def test_plain_exception(self) -> None: + err = Exception("not an MCP error") + assert _is_payment_required_error(err) is False + + +# --------------------------------------------------------------------------- +# McpClient +# --------------------------------------------------------------------------- + + +class TestMcpClientFreeTool: + """Free tool calls pass through without payment handling.""" + + @pytest.mark.asyncio + async def test_free_tool_returns_result(self) -> None: + session = AsyncMock() + session.call_tool = AsyncMock(return_value=FakeCallToolResult()) + + client = McpClient(session, methods=[FakeMethod()]) + result = await client.call_tool("echo", {"message": "hi"}) + + assert isinstance(result, McpToolResult) + assert result.result.content[0]["text"] == "ok" + assert result.receipt is None + session.call_tool.assert_called_once() + + @pytest.mark.asyncio + async def test_free_tool_with_receipt(self) -> None: + session = AsyncMock() + session.call_tool = AsyncMock(return_value=FakeCallToolResult(meta=_make_receipt_meta())) + + client = McpClient(session, methods=[FakeMethod()]) + result = await client.call_tool("tool", {}) + + assert result.receipt is not None + assert isinstance(result.receipt, MCPReceipt) + assert result.receipt.reference == "0xtxhash" + assert result.receipt.challenge_id == "ch_test123" + + +class TestMcpClientPaidTool: + """Paid tool calls handle the -32042 → credential → retry flow.""" + + @pytest.mark.asyncio + async def test_payment_flow(self) -> None: + """First call raises -32042, retry succeeds with receipt.""" + session = AsyncMock() + + import sys + from unittest.mock import MagicMock + + # Ensure mcp.shared.exceptions is importable with our FakeMcpError + mcp_mock = MagicMock() + mcp_mock.shared.exceptions.McpError = FakeMcpError + original_modules = {} + for mod_name in ["mcp", "mcp.shared", "mcp.shared.exceptions"]: + original_modules[mod_name] = sys.modules.get(mod_name) + sys.modules[mod_name] = ( + mcp_mock + if mod_name == "mcp" + else getattr( + mcp_mock, + mod_name.split(".", 1)[-1].replace(".", ".") if "." in mod_name else mod_name, + mcp_mock, + ) + ) + # Set it properly + sys.modules["mcp.shared"] = mcp_mock.shared + sys.modules["mcp.shared.exceptions"] = mcp_mock.shared.exceptions + + try: + payment_error = FakeMcpError( + -32042, + message="Payment Required", + data={ + "httpStatus": 402, + "challenges": [_make_challenge_dict()], + }, + ) + + retry_result = FakeCallToolResult( + content=[{"type": "text", "text": "premium result"}], + meta=_make_receipt_meta(), + ) + + session.call_tool = AsyncMock(side_effect=[payment_error, retry_result]) + + client = McpClient(session, methods=[FakeMethod()]) + result = await client.call_tool("premium_tool", {"query": "test"}) + + assert result.result.content[0]["text"] == "premium result" + assert result.receipt is not None + assert result.receipt.status == "success" + assert result.receipt.reference == "0xtxhash" + + assert session.call_tool.call_count == 2 + + # Verify retry included credential in meta + retry_call_kwargs = session.call_tool.call_args_list[1] + retry_meta = retry_call_kwargs.kwargs.get("meta") or retry_call_kwargs[1].get("meta") + assert META_CREDENTIAL in retry_meta + finally: + for mod_name, orig in original_modules.items(): + if orig is None: + sys.modules.pop(mod_name, None) + else: + sys.modules[mod_name] = orig + + @pytest.mark.asyncio + async def test_no_matching_method_raises(self) -> None: + """Raises ValueError when no installed method matches the challenge.""" + session = AsyncMock() + + import sys + from unittest.mock import MagicMock + + mcp_mock = MagicMock() + mcp_mock.shared.exceptions.McpError = FakeMcpError + original_modules = {} + for mod_name in ["mcp", "mcp.shared", "mcp.shared.exceptions"]: + original_modules[mod_name] = sys.modules.get(mod_name) + sys.modules["mcp"] = mcp_mock + sys.modules["mcp.shared"] = mcp_mock.shared + sys.modules["mcp.shared.exceptions"] = mcp_mock.shared.exceptions + + try: + payment_error = FakeMcpError( + -32042, + data={"challenges": [_make_challenge_dict(method="stripe")]}, + ) + session.call_tool = AsyncMock(side_effect=payment_error) + + client = McpClient(session, methods=[FakeMethod(name="tempo")]) + + with pytest.raises(ValueError, match="No compatible payment method"): + await client.call_tool("tool", {}) + finally: + for mod_name, orig in original_modules.items(): + if orig is None: + sys.modules.pop(mod_name, None) + else: + sys.modules[mod_name] = orig + + @pytest.mark.asyncio + async def test_non_payment_error_propagates(self) -> None: + """Non-payment McpErrors propagate unchanged.""" + session = AsyncMock() + + import sys + from unittest.mock import MagicMock + + mcp_mock = MagicMock() + mcp_mock.shared.exceptions.McpError = FakeMcpError + original_modules = {} + for mod_name in ["mcp", "mcp.shared", "mcp.shared.exceptions"]: + original_modules[mod_name] = sys.modules.get(mod_name) + sys.modules["mcp"] = mcp_mock + sys.modules["mcp.shared"] = mcp_mock.shared + sys.modules["mcp.shared.exceptions"] = mcp_mock.shared.exceptions + + try: + other_error = FakeMcpError(-32601, message="Method not found") + session.call_tool = AsyncMock(side_effect=other_error) + + client = McpClient(session, methods=[FakeMethod()]) + + with pytest.raises(FakeMcpError) as exc_info: + await client.call_tool("tool", {}) + assert exc_info.value.code == -32601 + finally: + for mod_name, orig in original_modules.items(): + if orig is None: + sys.modules.pop(mod_name, None) + else: + sys.modules[mod_name] = orig + + @pytest.mark.asyncio + async def test_timeout_forwarded(self) -> None: + """Timeout is forwarded as read_timeout_seconds to session.call_tool.""" + session = AsyncMock() + session.call_tool = AsyncMock(return_value=FakeCallToolResult()) + + client = McpClient(session, methods=[FakeMethod()]) + await client.call_tool("tool", {}, timeout=60.0) + + _, kwargs = session.call_tool.call_args + assert kwargs.get("read_timeout_seconds") == 60.0 + + @pytest.mark.asyncio + async def test_meta_forwarded(self) -> None: + """Custom meta is forwarded to the session.""" + session = AsyncMock() + session.call_tool = AsyncMock(return_value=FakeCallToolResult()) + + client = McpClient(session, methods=[FakeMethod()]) + await client.call_tool("tool", {}, meta={"custom": "value"}) + + _, kwargs = session.call_tool.call_args + assert kwargs["meta"]["custom"] == "value" + + @pytest.mark.asyncio + async def test_meta_preserved_on_retry(self) -> None: + """Custom meta is preserved and merged with credential on retry.""" + session = AsyncMock() + + import sys + from unittest.mock import MagicMock + + mcp_mock = MagicMock() + mcp_mock.shared.exceptions.McpError = FakeMcpError + original_modules = {} + for mod_name in ["mcp", "mcp.shared", "mcp.shared.exceptions"]: + original_modules[mod_name] = sys.modules.get(mod_name) + sys.modules["mcp"] = mcp_mock + sys.modules["mcp.shared"] = mcp_mock.shared + sys.modules["mcp.shared.exceptions"] = mcp_mock.shared.exceptions + + try: + payment_error = FakeMcpError( + -32042, + data={"challenges": [_make_challenge_dict()]}, + ) + retry_result = FakeCallToolResult(meta=_make_receipt_meta()) + session.call_tool = AsyncMock(side_effect=[payment_error, retry_result]) + + client = McpClient(session, methods=[FakeMethod()]) + await client.call_tool("tool", {}, meta={"trace_id": "abc"}) + + retry_kwargs = session.call_tool.call_args_list[1].kwargs + retry_meta = retry_kwargs.get("meta", {}) + assert retry_meta.get("trace_id") == "abc" + assert META_CREDENTIAL in retry_meta + finally: + for mod_name, orig in original_modules.items(): + if orig is None: + sys.modules.pop(mod_name, None) + else: + sys.modules[mod_name] = orig + + +class TestMcpClientMethodMatching: + """Tests for challenge-to-method matching logic.""" + + def test_match_by_method_and_intent(self) -> None: + method = FakeMethod(name="tempo", _intents={"charge": True}) + client = McpClient(AsyncMock(), methods=[method]) + + challenge, matched = client._match_challenge([_make_challenge_dict()]) + assert isinstance(challenge, MCPChallenge) + assert matched is method + + def test_match_prefers_client_order(self) -> None: + """Methods are matched in client-preference order.""" + stripe_method = FakeMethod(name="stripe", _intents={"charge": True}) + tempo_method = FakeMethod(name="tempo", _intents={"charge": True}) + + client = McpClient(AsyncMock(), methods=[stripe_method, tempo_method]) + + challenges = [ + _make_challenge_dict(method="tempo"), + _make_challenge_dict(method="stripe"), + ] + _, matched = client._match_challenge(challenges) + assert matched is stripe_method + + def test_no_match_raises(self) -> None: + method = FakeMethod(name="tempo", _intents={"charge": True}) + client = McpClient(AsyncMock(), methods=[method]) + + with pytest.raises(ValueError, match="No compatible payment method"): + client._match_challenge([_make_challenge_dict(method="stripe")]) + + def test_intent_mismatch(self) -> None: + method = FakeMethod(name="tempo", _intents={"session": True}) + client = McpClient(AsyncMock(), methods=[method]) + + with pytest.raises(ValueError, match="No compatible payment method"): + client._match_challenge([_make_challenge_dict(method="tempo", intent="charge")]) + + +class TestMcpClientReceiptExtraction: + """Tests for receipt extraction from _meta.""" + + def test_extracts_receipt(self) -> None: + result = FakeCallToolResult(meta=_make_receipt_meta()) + receipt = McpClient._extract_receipt(result) + assert receipt is not None + assert receipt.status == "success" + + def test_no_meta(self) -> None: + result = FakeCallToolResult(meta=None) + assert McpClient._extract_receipt(result) is None + + def test_no_receipt_key(self) -> None: + result = FakeCallToolResult(meta={"other": "data"}) + assert McpClient._extract_receipt(result) is None + + def test_malformed_receipt(self) -> None: + result = FakeCallToolResult(meta={META_RECEIPT: "not a dict"}) + assert McpClient._extract_receipt(result) is None