From fe4faf15a7e51888f945ae74481453b172276164 Mon Sep 17 00:00:00 2001 From: Amit Acharya Date: Sat, 30 May 2026 22:55:11 -0400 Subject: [PATCH] Add MCPError.paymentRequired for the MPP Payment scheme over JSON-RPC 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. --- Sources/MCP/Base/Error.swift | 36 ++++++++++ Tests/MCPTests/ErrorTests.swift | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 Tests/MCPTests/ErrorTests.swift diff --git a/Sources/MCP/Base/Error.swift b/Sources/MCP/Base/Error.swift index b7196f07..acfb4e69 100644 --- a/Sources/MCP/Base/Error.swift +++ b/Sources/MCP/Base/Error.swift @@ -40,6 +40,11 @@ public enum MCPError: Swift.Error, Sendable { // MCP specific errors case urlElicitationRequired(message: String, elicitations: [URLElicitationInfo]) // -32042 + // Payment required, per the MPP "Payment" authentication scheme bound to JSON-RPC. Carries the + // error `data` verbatim (e.g. `{ httpStatus, challenges, problem? }`) so a client can read the + // offered challenges. Decoded when an error's `data` contains a `challenges` array. + case paymentRequired(code: Int, message: String, data: [String: Value]) // -32042 / -32043 + // Transport specific errors case connectionClosed case transportError(Swift.Error) @@ -54,6 +59,7 @@ public enum MCPError: Swift.Error, Sendable { case .internalError: return -32603 case .serverError(let code, _): return code case .urlElicitationRequired: return -32042 + case .paymentRequired(let code, _, _): return code case .connectionClosed: return -32000 case .transportError: return -32001 } @@ -93,6 +99,8 @@ extension MCPError: LocalizedError { return "Server error: \(message)" case .urlElicitationRequired(let message, _): return "URL elicitation required: \(message)" + case .paymentRequired(_, let message, _): + return "Payment required: \(message)" case .connectionClosed: return "Connection closed" case .transportError(let error): @@ -116,6 +124,8 @@ extension MCPError: LocalizedError { return "Server-defined error occurred" case .urlElicitationRequired: return "The server requires user authentication or input via external URL" + case .paymentRequired: + return "The server requires payment before fulfilling this request" case .connectionClosed: return "The connection to the server was closed" case .transportError(let error): @@ -138,6 +148,8 @@ extension MCPError: LocalizedError { return "Visit \(first.url) to complete the required authentication or input" } return "Complete the required URL-based elicitation" + case .paymentRequired: + return "Provide a payment credential in the request metadata and retry" case .connectionClosed: return "Try reconnecting to the server" default: @@ -203,6 +215,10 @@ extension MCPError: Codable { ["elicitations": Value.array(elicitationsData.map { .object($0) })], forKey: .data ) + case .paymentRequired(_, let message, let data): + // Encode the raw message + the error data verbatim (carries `challenges`). + try container.encode(message, forKey: .message) + try container.encode(data, forKey: .data) case .connectionClosed: try container.encode(errorDescription ?? "Unknown error", forKey: .message) case .transportError(let error): @@ -239,6 +255,12 @@ extension MCPError: Codable { case -32603: self = .internalError(unwrapDetail(nil)) case -32042: + // -32042 is shared: MPP "Payment Required" carries a `challenges` array, while MCP + // URL elicitation carries an `elicitations` array. Disambiguate by payload. + if case .array = data?["challenges"] { + self = .paymentRequired(code: code, message: message, data: data ?? [:]) + break + } // Extract elicitations array from data var elicitations: [URLElicitationInfo] = [] if case .array(let items) = data?["elicitations"] { @@ -257,6 +279,14 @@ extension MCPError: Codable { } } self = .urlElicitationRequired(message: message, elicitations: elicitations) + case -32043: + // MPP "Payment Verification Failed" carries a fresh `challenges` array; without one it + // is an ordinary server error. + if case .array = data?["challenges"] { + self = .paymentRequired(code: code, message: message, data: data ?? [:]) + } else { + self = .serverError(code: code, message: message) + } case -32000: self = .connectionClosed case -32001: @@ -293,6 +323,8 @@ extension MCPError: Equatable { return c1 == c2 && m1 == m2 case (.urlElicitationRequired(let m1, let e1), .urlElicitationRequired(let m2, let e2)): return m1 == m2 && e1 == e2 + case (.paymentRequired(let c1, let m1, let d1), .paymentRequired(let c2, let m2, let d2)): + return c1 == c2 && m1 == m2 && d1 == d2 case (.connectionClosed, .connectionClosed): return true case (.transportError(let a), .transportError(let b)): return a.localizedDescription == b.localizedDescription @@ -322,6 +354,10 @@ extension MCPError: Hashable { case .urlElicitationRequired(let message, let elicitations): hasher.combine(message) hasher.combine(elicitations) + case .paymentRequired(let code, let message, let data): + hasher.combine(code) + hasher.combine(message) + hasher.combine(data) case .connectionClosed: break case .transportError(let error): diff --git a/Tests/MCPTests/ErrorTests.swift b/Tests/MCPTests/ErrorTests.swift new file mode 100644 index 00000000..843a7cad --- /dev/null +++ b/Tests/MCPTests/ErrorTests.swift @@ -0,0 +1,120 @@ +import Testing + +import struct Foundation.Data +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Error Tests") +struct ErrorTests { + /// A representative payment-required `data` payload: an `httpStatus` and a `challenges` array, + /// matching the MPP "Payment" scheme bound to JSON-RPC. + private func paymentData(httpStatus: Int = 402) -> [String: Value] { + [ + "httpStatus": .int(httpStatus), + "challenges": .array([ + .object([ + "id": .string("chal-1"), + "realm": .string("example"), + "method": .string("tempo"), + "intent": .string("charge"), + ]) + ]), + ] + } + + @Test("paymentRequired round-trips and preserves code, message, and data") + func testPaymentRequiredRoundTrip() throws { + let error = MCPError.paymentRequired( + code: -32042, message: "Payment Required", data: paymentData()) + + let data = try JSONEncoder().encode(error) + let decoded = try JSONDecoder().decode(MCPError.self, from: data) + + guard case .paymentRequired(let code, let message, let payload) = decoded else { + #expect(Bool(false), "Expected .paymentRequired, got \(decoded)") + return + } + #expect(code == -32042) + #expect(message == "Payment Required") + #expect(payload["httpStatus"] == .int(402)) + if case .array(let challenges) = payload["challenges"] { + #expect(challenges.count == 1) + } else { + #expect(Bool(false), "Expected a challenges array in the decoded data") + } + } + + @Test("code -32042 with challenges decodes to paymentRequired, not urlElicitationRequired") + func testPaymentRequiredDisambiguatedFromElicitation() throws { + let error = MCPError.paymentRequired( + code: -32042, message: "Payment Required", data: paymentData()) + + let data = try JSONEncoder().encode(error) + let decoded = try JSONDecoder().decode(MCPError.self, from: data) + + if case .urlElicitationRequired = decoded { + #expect(Bool(false), "A -32042 with `challenges` must not decode as URL elicitation") + } + #expect(decoded == error) + } + + @Test("code -32042 with elicitations still decodes to urlElicitationRequired") + func testElicitationStillDecodes() throws { + let error = MCPError.urlElicitationRequired( + message: "Auth required", + elicitations: [ + URLElicitationInfo(elicitationId: "e1", url: "https://example.com", message: "Sign in") + ]) + + let data = try JSONEncoder().encode(error) + let decoded = try JSONDecoder().decode(MCPError.self, from: data) + + guard case .urlElicitationRequired(let message, let elicitations) = decoded else { + #expect(Bool(false), "Expected .urlElicitationRequired, got \(decoded)") + return + } + #expect(message == "Auth required") + #expect(elicitations.count == 1) + #expect(elicitations.first?.url == "https://example.com") + } + + @Test("code -32043 with challenges decodes to paymentRequired") + func testVerificationFailedWithChallenges() throws { + let error = MCPError.paymentRequired( + code: -32043, message: "Payment verification failed", data: paymentData()) + + let data = try JSONEncoder().encode(error) + let decoded = try JSONDecoder().decode(MCPError.self, from: data) + + guard case .paymentRequired(let code, _, _) = decoded else { + #expect(Bool(false), "Expected .paymentRequired, got \(decoded)") + return + } + #expect(code == -32043) + #expect(decoded.code == -32043) + } + + @Test("a bare -32043 without challenges stays a serverError") + func testVerificationCodeWithoutChallengesIsServerError() throws { + // Encode a JSON-RPC error object with code -32043 and no `data`. + let json = #"{"code":-32043,"message":"Service degraded"}"# + let decoded = try JSONDecoder().decode(MCPError.self, from: Data(json.utf8)) + + guard case .serverError(let code, let message) = decoded else { + #expect(Bool(false), "Expected .serverError, got \(decoded)") + return + } + #expect(code == -32043) + #expect(message == "Service degraded") + } + + @Test("paymentRequired exposes the carried code") + func testCarriedCode() { + #expect( + MCPError.paymentRequired(code: -32042, message: "x", data: [:]).code == -32042) + #expect( + MCPError.paymentRequired(code: -32043, message: "x", data: [:]).code == -32043) + } +}