Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changelog/agent-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added a shared payment runtime for payment-aware HTTP requests and MCP tool calls.
37 changes: 15 additions & 22 deletions src/mpp/client/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any

import httpx

Expand All @@ -27,24 +27,14 @@
EventHandler,
Unsubscribe,
)
from mpp.runtime import Method, PaymentRuntime

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from collections.abc import Sequence


@runtime_checkable
class Method(Protocol):
"""Payment method interface for client-side credential creation."""

name: str

async def create_credential(self, challenge: Challenge) -> Credential:
"""Create a credential to satisfy the given challenge."""
...


def _client_payment_failed_payload(
*,
challenge: Challenge | None,
Expand Down Expand Up @@ -90,10 +80,11 @@ def __init__(
methods: Sequence[Method],
inner: httpx.AsyncBaseTransport | None = None,
events: EventDispatcher | None = None,
runtime: PaymentRuntime | None = None,
) -> None:
self._methods = {m.name: m for m in methods}
self._runtime = runtime or PaymentRuntime(methods, events=events)
self._inner = inner or httpx.AsyncHTTPTransport()
self._events = events or EventDispatcher()
self._events = self._runtime.events

def on(self, name: str, handler: EventHandler) -> Unsubscribe:
"""Register a client payment event handler."""
Expand Down Expand Up @@ -139,13 +130,15 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
continue
challenges.append(parsed)

challenge = None
matched_method = None
for parsed in challenges:
if parsed.method in self._methods:
challenge = parsed
matched_method = self._methods[parsed.method]
break
try:
challenge, matched_method = self._runtime.match_challenge(
challenges,
prefer_method_order=False,
require_intent_match=False,
)
except ValueError:
challenge = None
matched_method = None

if not challenge or not matched_method:
if parse_error is not None or challenges:
Expand Down Expand Up @@ -205,7 +198,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
credential = (
event_credential
if isinstance(event_credential, Credential)
else await matched_method.create_credential(challenge)
else await self._runtime.create_credential(challenge, matched_method)
)
await self._events.emit(
CREDENTIAL_CREATED,
Expand Down
83 changes: 13 additions & 70 deletions src/mpp/extensions/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@

import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from typing import Any

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
from mpp.extensions.mcp.types import MCPChallenge, MCPReceipt
from mpp.runtime import Method, PaymentRuntime

logger = logging.getLogger(__name__)

Expand All @@ -51,17 +49,6 @@ def __init__(self, challenge: MCPChallenge, cause: Exception) -> None:
)


@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 _error_code(error: Exception) -> int | None:
code = getattr(error, "code", None)
if code is not None:
Expand Down Expand Up @@ -173,7 +160,7 @@ class McpClient:

def __init__(self, session: Any, methods: list[Method]) -> None:
self._session = session
self._methods = methods
self._runtime = PaymentRuntime(methods)

def __getattr__(self, name: str) -> Any:
return getattr(self._session, name)
Expand Down Expand Up @@ -206,72 +193,28 @@ async def call_tool(
PaymentOutcomeUnknownError: If the paid retry fails after sending a credential.
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 = _extract_challenges(e)
if not challenges:
raise ValueError("Server returned malformed payment challenges") from e

challenge, method = self._match_challenge(challenges)

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

try:
retry_result = await self._session.call_tool(name, arguments, **retry_kwargs)
except Exception as exc:
raise PaymentOutcomeUnknownError(challenge, exc) from exc

receipt = self._extract_receipt(retry_result)
return McpToolResult(result=retry_result, receipt=receipt)
result = await self._runtime.call_mcp_tool(
self._session.call_tool,
name,
arguments,
**call_kwargs,
)
receipt = self._extract_receipt(result)
return McpToolResult(result=result, receipt=receipt)

def _match_challenge(self, challenges: list[MCPChallenge]) -> 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:
supported_intents = self._intent_names(method)
for challenge in challenges:
if challenge.method == method.name and challenge.intent in supported_intents:
return challenge, method

available = [challenge.method for challenge in challenges]
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"}
return self._runtime.match_mcp_challenge(challenges)

@staticmethod
def _extract_receipt(result: Any) -> MCPReceipt | None:
Expand Down
Loading
Loading