feat(mcp): MPPMCP, the JSON-RPC / MCP payment rail#78
Merged
Conversation
Binds the MPP 402 flow (challenge -> credential -> receipt) to JSON-RPC / Model Context Protocol, on the official MCP Swift SDK. Rail-agnostic: the server gate reuses MPPServerMiddleware's mint/verify pipeline and the client wrapper reuses the 402 method-selection step; the payment method (e.g. the Tempo zero-amount proof) is injected. - MCPValueBridge: JSONValue <-> MCP.Value, failing closed on a non-integer number / binary data so the protocol's integer-only invariant holds. - MCPPaymentCodec: Challenge/Credential/Receipt <-> the native-JSON the MCP transport carries (request decoded to/from EncodedJSON so the challenge-id binding recomputes; JCS parity vs the peer is byte-verified), plus the -32042 error.data frame and the receipt's challengeId. Receipt/ProblemDetails reuse their own Codable via a JSON data hop. - MCPPaymentServer.gated: wraps a tools/call handler; reuses MPPServerMiddleware.evaluate via a Credential.headerValue adapter (empty body, no digest over MCP); -32042 when no credential, -32043 when one is rejected; attaches the receipt to result._meta. - MCPPaymentClient: pays a -32042 transparently (select -> build -> retry once), reading the receipt from result._meta via the RequestContext callTool overload. - Lift PaymentClient.select(from:) -> shared selectPaymentMethod(for:from:) in MPPClient (no duplication; HTTP client behavior unchanged, its 29 tests green). Tests (26): value bridge, codec (incl. parsing a real captured mppx frame), server gate, client wrapper, and an in-process MCP.Client <-> MCP.Server e2e with the Tempo proof method. Peer-parity (G7.5): ported the union of mppx's mcp-sdk client + server test scenarios. Depends on a fork of modelcontextprotocol/swift-sdk pinned to an immutable commit (adds MCPError.paymentRequired; the stock SDK can't carry error.data and hardwires -32042 to URL elicitation). Upstream PR: swift-sdk#229; the pin swaps to a tagged release once it merges. The MCP SDK's transitive graph (swift-log 1.13) raises the package floor to Swift 6.2: tools-version, README/ARCHITECTURE badges, CI containers swift:6.0 -> swift:6.2, and macOS jobs select Xcode 26. Deferred to a follow-up (devlog): the live mppx-process cross-SDK conformance harness + a CI job. Cross-SDK fidelity is covered hermetically meanwhile (real captured frame, byte-proven JCS parity, in-process e2e).
The OSV gate fails closed on revision/branch pins (they have no SemVer for OSV to range-match). Our fork dependency github.com/amitach/swift-sdk is a revision pin, so the gate failed. The fork is our own 36-line reviewed patch (MCPError.paymentRequired, upstreamed as modelcontextprotocol/swift-sdk#229), not a third-party dependency OSV tracks. Add a documented allowlist for self-authored revision pins: an accepted entry logs a CI warning, any other revision pin still fails closed, and the fork's TRANSITIVE deps (swift-log, swift-nio, ...) remain versioned pins that are scanned. Remove the entry and pin a tagged upstream release once #229 merges.
Address Devin review on #78: - 🚩 MCPPaymentCodec.challenge(from:) parsed opaque via stringValue.map, which would SILENTLY DROP a non-string opaque, changing the challenge-id binding input and surfacing as an opaque verification failure. Fail closed instead (invalidField("opaque")). Tests: string opaque round-trips; a native-object opaque is rejected. - 📝 The macOS xcode-select step now guards that an Xcode 26 install exists and emits a clear ::error:: if not, instead of failing opaquely on an empty glob. The other 3 Devin notes are intentional/documented (the headerValue adapter round-trip, the server-authoritative expiry divergence, the deferred live mppx conformance) and are resolved with rationale on the threads.
…wift MCP server Answers 'is this proven against the real peer?' The reference mppx mcp-sdk CLIENT pays our Swift MCP server over a real stdio transport, end to end: mppx reads our -32042 challenge, builds the Tempo zero-amount proof credential, sends it in params._meta, our server (MPPMCPConformanceServer = MCP.Server + the MPPMCP gate + TempoProofVerifier) verifies it and mints a receipt into result._meta, and mppx reads the receipt back. Offline + deterministic (ecrecover, no RPC). - MPPMCPConformanceServer: dev-only MCP server over stdio (no product), gates a premium tool behind a zero-amount Tempo proof via MPPMCP. - Scripts/conformance/mcp-client.mjs: the mppx mcp-sdk client (spawns the Swift server via StdioClientTransport, pays, asserts the receipt + content). - run-mcp.sh + a step in the Conformance (local) CI job. - Adds @modelcontextprotocol/sdk (>=1.25.0) to the dev-only harness (npm ci --ignore-scripts; not shipped, not in the SwiftPM graph). Reverse direction (our Swift client -> mppx mcp-sdk server) remains a follow-up; the forward run + the hermetic suite already prove the wire frame and the flow.
…tial) From the security crawl + Devin review on #78: - 🚩 A present-but-malformed credential in params._meta threw CodecError out of the gate handler, which the SDK wraps as -32603 internalError. Map a credential parse failure to -32602 invalidParams (a client error, matching the reference peer's MalformedCredentialError); an ABSENT credential still yields -32042. - DoS: the gate passes body: Data() so MPPServerMiddleware's maxBodyBytes never bounds the MCP credential, and JSONValue decode/canonicalize recurse without a depth guard. Add a depth bound (100; payment payloads are ~depth 5) to the Value->JSONValue bridge so a hostile, deeply-nested credential/challenge fails closed (BridgeError.tooDeep) instead of risking a stack overflow. Tests: malformed credential -> -32602; deeply-nested value -> tooDeep; within-limit still converts. Container conversion extracted to keep cyclomatic complexity <= 10.
amitach
added a commit
that referenced
this pull request
May 31, 2026
…cp-sdk server (#79) Closes the remaining MPPMCP conformance gap (the symmetric direction to the forward run shipped in #78). OUR Swift MCP client spawns the reference mppx mcp-sdk SERVER over a real stdio transport and pays its payment-gated tool: our client reads mppx's -32042 challenge, builds the Tempo zero-amount proof credential, sends it in params._meta, mppx verifies it and mints a receipt, and our client reads the receipt back. Offline + deterministic (ecrecover). - mcp-server.mjs: the mppx mcp-sdk server (McpServer + Transport.mcpSdk() + StdioServerTransport), gating a premium tool behind a zero-amount proof. - MPPMCPConformanceClient: dev-only MCP client (no product) that spawns the Node server (Process + pipes), wires MCP.StdioTransport to its FDs, and pays via MCPPaymentClient + TempoProofMethod. - run-mcp-reverse.sh + a step in the Conformance (local) CI job. Both cross-SDK MCP directions are now proven live against the real peer in CI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Binds the MPP 402 flow (challenge → credential → receipt) to JSON-RPC / Model Context Protocol, on the official MCP Swift SDK. Spec:
draft-payment-transport-mcp-00. Rail-agnostic: the server gate reusesMPPServerMiddleware's mint/verify pipeline and the client wrapper reuses the 402 method-selection step; the payment method (e.g. the Tempo zero-amount proof) is injected.What's here
MCPValueBridge—JSONValue⇄MCP.Value, failing closed on a non-integer number / binary data so the protocol's integer-only invariant holds.MCPPaymentCodec—Challenge/Credential/Receipt⇄ the native-JSON the MCP transport carries (the challengerequestis decoded to/fromEncodedJSONso the challenge-id binding recomputes; JCS canonicalization parity vs the peer is byte-verified), plus the-32042error.dataframe and the receipt'schallengeId.Receipt/ProblemDetailsreuse their ownCodablevia a JSON data hop.MCPPaymentServer.gated— wraps atools/callhandler; reusesMPPServerMiddleware.evaluatevia aCredential.headerValueadapter (empty body, no digest over MCP);-32042when no credential,-32043when one is rejected; attaches the receipt toresult._meta.MCPPaymentClient— pays a-32042transparently (select → build → retry once), reading the receipt fromresult._metavia theRequestContextcallTooloverload.PaymentClient.select(from:)→ sharedselectPaymentMethod(for:from:)in MPPClient (no duplication; HTTP client behavior unchanged, its 29 tests still green).Tests (26) + peer parity
Value bridge, codec (including parsing a real captured mppx frame), server gate, client wrapper, and an in-process
MCP.Client⇄MCP.Servere2e through the gate with the Tempo proof method. G7.5 peer-parity: ported the union ofwevm/mppx'smcp-sdkclient + server test scenarios (no-credential → -32042, valid → proceed + receipt, replay → -32043 + problem, receipt-attach preserves existing_meta/content, pass-through when unpaid, non-payment error rethrown). One reconciled divergence (G3.5): mppx skips expired challenges client-side; our shared selection defers expiry to the server, consistent with our existing HTTP client.Dependency + the Swift 6.2 floor
Depends on a fork of
modelcontextprotocol/swift-sdkpinned to an immutable commit — it addsMCPError.paymentRequiredbecause the stock SDK can't carryerror.dataand hardwires-32042to URL elicitation. Upstream PR: modelcontextprotocol/swift-sdk#229; the pin swaps to a tagged release once it merges.The MCP SDK's transitive graph (swift-log 1.13 → tools 6.2, swift-nio 2.100 + eventsource → 6.1) raises the package floor to Swift 6.2:
swift-tools-version, the README/ARCHITECTURE badges, CI Linux containersswift:6.0→swift:6.2, and the macOS jobsxcode-selectthe runner's Xcode 26. Verified: full graph + all 26 tests build/pass onswift:6.2Linux (6.2.4) and macOS.Deferred (follow-up, see devlog)
The live mppx-process cross-SDK conformance harness (forward + reverse) + its CI job. Cross-SDK fidelity is covered hermetically meanwhile: a real captured frame is parsed, JCS parity is byte-proven, and the in-process e2e exercises the full client↔server flow.