feat: add McpClient wrapper for MCP client transport parity#96
feat: add McpClient wrapper for MCP client transport parity#96brendanjryan wants to merge 1 commit intomainfrom
Conversation
Mark Python MCP Client as supported (MCP SDK transport) following tempoxyz/pympp#96 which adds the McpClient wrapper.
Mark Python MCP Client as supported (MCP SDK transport) following tempoxyz/pympp#96 which adds the McpClient wrapper.
6cd584c to
9111fcd
Compare
💡 Codex Reviewpympp/src/mpp/methods/tempo/intents.py Lines 242 to 245 in 6cd584c With replay protection enabled, this marks the hash as used before any RPC or log checks run. If Lines 18 to 22 in 6cd584c
pympp/src/mpp/extensions/mcp/client.py Lines 109 to 113 in 6cd584c
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Add McpClient that wraps an MCP SDK ClientSession with automatic payment handling - matching the TypeScript McpClient.wrap API. When call_tool gets a -32042 error, McpClient: 1. Parses challenges from error.data 2. Matches to an installed payment method by name+intent 3. Creates a credential and retries with it in _meta 4. Extracts receipts from the result - New: src/mpp/extensions/mcp/client.py (McpClient, McpToolResult) - Export McpClient and McpToolResult from mpp.extensions.mcp - 22 new tests covering free/paid tools, method matching, receipt extraction, error propagation, meta forwarding - Updated example client to use McpClient instead of manual flow - Updated example README
9111fcd to
31a5de2
Compare
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
This PR adds McpClient, a payment-aware wrapper around MCP ClientSession that automatically handles -32042 payment challenges. The implementation is clean and well-tested for the happy path, but has a meaningful input validation gap at the trust boundary with untrusted MCP servers and a return-type compatibility issue with the standard MCP SDK.
Reviewer Callouts
- ⚡ Challenge Trust Boundary:
-32042error data originates from remote MCP servers and should be treated as adversarial input. All field access should be guarded. - ⚡ Dependency Drift: Upstream
modelcontextprotocol/python-sdkmainline has renamedMcpErrortoMCPError. Not exploitable today, but a future integration break risk. - ⚡ API Surface Parity: The TypeScript
McpClient.wrappreserves the full client interface; the Python version currently only exposescall_tool. Integrations that pass the client object to frameworks expectingClientSessionmethods will fail.
| """ | ||
| for method in self._methods: | ||
| for cd in challenges_data: | ||
| if cd.get("method") == method.name and cd.get("intent") in self._intent_names( |
There was a problem hiding this comment.
🚨 [SECURITY] Malformed Server Challenges Crash McpClient via Unhandled Exceptions
_is_payment_required_error validates that challenges is a non-empty list but does not validate element types or required fields. _match_challenge then calls .get() on each element, which raises AttributeError if an element is a string instead of a dict. Even when elements are dicts, MCPChallenge.from_dict(cd) raises KeyError if required fields (id, realm, request) are absent, and cd.get("intent") in set(...) raises TypeError for unhashable values. These unhandled exceptions escape call_tool, bypassing standard McpError handlers and crashing caller workflows when connected to a malicious or misconfigured MCP server.
Recommended Fix: Add schema validation before matching: require each entry to be a dict with required string fields (id, realm, method, intent) and a dict request. Skip malformed entries with a warning. Also harden _is_payment_required_error with all(isinstance(c, dict) for c in challenges).
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class McpToolResult: |
There was a problem hiding this comment.
McpToolResult Breaks CallToolResult API Contract
McpClient.call_tool returns McpToolResult(result=..., receipt=...) instead of the standard MCP CallToolResult. Downstream code expecting result.content will crash since .content lives on result.result.content. The wrapper also omits list_tools, read_resource, initialize, and other ClientSession methods, so it cannot be used as a drop-in replacement. The upstream TypeScript McpClient.wrap preserves the original client surface via object spread.
Recommended Fix: Proxy attribute access to the underlying CallToolResult via __getattr__ on McpToolResult, and add __getattr__ on McpClient to delegate unknown methods to _session.
| if timeout is not None: | ||
| retry_kwargs["read_timeout_seconds"] = timeout | ||
|
|
||
| retry_result = await self._session.call_tool(name, arguments, **retry_kwargs) |
There was a problem hiding this comment.
🛡️ [DEFENSE-IN-DEPTH] No Retry State Exposed After Payment Credential Transmission
If the retry request fails due to a network timeout after the credential was already created and sent, call_tool surfaces the transport exception without indicating payment state. Fault-tolerant callers that retry call_tool will trigger a new challenge/credential cycle, potentially resulting in duplicate charges. This matches upstream TypeScript behavior but makes it difficult for SDK consumers to build resilient retry logic.
Recommended Fix: Consider exposing intermediate payment state (e.g., via a custom exception attribute or callback) so callers can distinguish "credential sent but response lost" from "no payment attempted".
Summary
Adds
McpClient, a payment-aware MCP client wrapper that brings Python to parity with the TypeScriptMcpClient.wrapAPI on the SDK features table.What it does
McpClientwraps an MCP SDKClientSessionand overridescall_toolwith automatic payment handling:-32042(Payment Required), parses the challenges_meta_metaUsage