Skip to content

Add MCPError.paymentRequired for the MPP Payment scheme over JSON-RPC#229

Open
amitach wants to merge 1 commit into
modelcontextprotocol:mainfrom
amitach:feat/payment-required-error
Open

Add MCPError.paymentRequired for the MPP Payment scheme over JSON-RPC#229
amitach wants to merge 1 commit into
modelcontextprotocol:mainfrom
amitach:feat/payment-required-error

Conversation

@amitach
Copy link
Copy Markdown

@amitach amitach commented May 31, 2026

Summary

JSON-RPC servers that implement the Machine Payments Protocol ("Payment" HTTP auth scheme, bound to JSON-RPC/MCP) signal payment required with error code -32042 (and -32043 for verification failed), carrying the offered challenges in error.data.challenges.

MCPError currently can't represent that:

  • serverError(code:message:) has no data, so the challenge set can't be carried.
  • the decoder maps -32042 unconditionally to .urlElicitationRequired, which reads data["elicitations"] and discards any challenges.

So a client built on this SDK can neither emit nor read a payment-required error.

Change

Add an additive case:

case paymentRequired(code: Int, message: String, data: [String: Value])  // -32042 / -32043

data is [String: Value] to match the decoder's existing decodeIfPresent([String: Value]), and so the SDK carries the JSON-RPC data object verbatim without taking on any payment-protocol types.

The decoder disambiguates by payload, so existing behavior is preserved:

Wire Decodes to
-32042 with data.challenges .paymentRequired
-32042 with data.elicitations (or neither) .urlElicitationRequired (unchanged)
-32043 with data.challenges .paymentRequired
-32043 without data.challenges .serverError (unchanged)

No existing case signature changes; no existing decode path changes.

Tests

New Tests/MCPTests/ErrorTests.swift (swift-testing): round-trip of paymentRequired, the -32042 payment-vs-elicitation disambiguation in both directions, -32043 with/without challenges, and the carried-code accessor. Full suite green (557 tests).

Notes

-32042 is shared between this SDK's URL-elicitation feature and the MPP payment scheme; disambiguating on the presence of challenges vs elicitations lets both coexist on the wire without a breaking change.

JSON-RPC servers implementing the Machine Payments Protocol "Payment"
authentication scheme (paymentauth.org) signal payment required with
error code -32042 (and -32043 for verification failed), carrying the
offered challenges in `error.data.challenges`. MCPError could not
represent that: `serverError` has no `data`, and -32042 decoded
unconditionally to `urlElicitationRequired`, discarding the challenges.

Add an additive `paymentRequired(code:message:data:)` case. The decoder
disambiguates by payload: a -32042/-32043 error whose `data` carries a
`challenges` array decodes to `.paymentRequired`; a -32042 with
`elicitations` still decodes to `.urlElicitationRequired`, and a bare
-32043 stays `.serverError`. No existing case signature changes and no
existing decode behavior changes.
amitach added a commit to amitach/mpp-swift that referenced this pull request May 31, 2026
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.
amitach added a commit to amitach/mpp-swift that referenced this pull request May 31, 2026
* feat(mcp): MPPMCP, the JSON-RPC / MCP payment rail

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).

* ci(audit): allowlist self-authored revision pins in the OSV gate

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.

* fix(mcp): fail closed on a non-string opaque; guard the Xcode 26 select

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.

* test(mcp): live cross-SDK conformance: mppx mcp-sdk client pays our Swift 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.

* fix(mcp): harden the untrusted-input boundary (DoS + malformed-credential)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant