From 607d09688ba9f8beb09ff5accf6f8010905dde30 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 08:03:41 -0600 Subject: [PATCH 1/6] test(harness): Swift protocol runner in the mpp-protocol divergence matrix Add a Swift stdin/stdout runner over SolanaPayKit's protocol functions, plus `harness/protocol-runners/swift.json`, so the spawned-runner block runs Swift. No mpp-protocol vector reaches the Swift SDK. Milestone 1 of the harness proposal puts Swift in the matrix. --- .github/workflows/swift.yml | 7 + Package.swift | 10 ++ harness/protocol-runners/swift.json | 5 + harness/src/protocol/runners/spawn.ts | 10 +- harness/test/protocol-conformance.test.ts | 86 ++++++++-- swift/Sources/mpp-protocol-runner/main.swift | 149 ++++++++++++++++++ .../AbiFramingTests.swift | 73 +++++++++ 7 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 harness/protocol-runners/swift.json create mode 100644 swift/Sources/mpp-protocol-runner/main.swift create mode 100644 swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index e164403d5..c84c9a394 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -41,6 +41,13 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: swift run: pnpm exec vitest run test/conformance.test.ts + - name: Build Swift protocol runner + run: swift build -c release --product mpp-protocol-runner + - name: Run Swift mpp-protocol conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: swift + run: pnpm exec vitest run test/protocol-conformance.test.ts - name: Run Swift client harness smoke against TypeScript server working-directory: harness env: diff --git a/Package.swift b/Package.swift index 2e7cccd6b..25aad4692 100644 --- a/Package.swift +++ b/Package.swift @@ -35,10 +35,20 @@ let package = Package( dependencies: ["SolanaPayKit"], path: "swift/Sources/mpp-conformance" ), + .executableTarget( + name: "mpp-protocol-runner", + dependencies: ["SolanaPayKit"], + path: "swift/Sources/mpp-protocol-runner" + ), .testTarget( name: "SolanaPayKitTests", dependencies: ["SolanaPayKit"], path: "swift/Tests/SolanaPayKitTests" ), + .testTarget( + name: "MppProtocolRunnerTests", + dependencies: ["mpp-protocol-runner"], + path: "swift/Tests/MppProtocolRunnerTests" + ), ] ) diff --git a/harness/protocol-runners/swift.json b/harness/protocol-runners/swift.json new file mode 100644 index 000000000..71a6e211d --- /dev/null +++ b/harness/protocol-runners/swift.json @@ -0,0 +1,5 @@ +{ + "language": "swift", + "command": ["swift", "run", "-c", "release", "mpp-protocol-runner"], + "cwd": "swift" +} diff --git a/harness/src/protocol/runners/spawn.ts b/harness/src/protocol/runners/spawn.ts index d6695979f..8815d525a 100644 --- a/harness/src/protocol/runners/spawn.ts +++ b/harness/src/protocol/runners/spawn.ts @@ -82,7 +82,15 @@ export function spawnedProtocolAdapter(runner: DiscoveredProtocolRunner): Protoc child.on("error", (err) => { resolve({ success: false, error: `spawn failed: ${err.message}`, error_type: "runner_error" }); }); - child.on("close", () => { + child.on("close", (code) => { + if (code !== 0) { + resolve({ + success: false, + error: `runner exited ${code}; stderr: ${stderr.slice(-512)}`, + error_type: "runner_error", + }); + return; + } const line = stdout.trim().split("\n").filter(Boolean).pop(); if (!line) { resolve({ diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 5468dbaf9..8871efe41 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest"; import { caseRunsOnAdapter, collectProtocolCases } from "../src/protocol/vectors"; -import { runCase } from "../src/protocol/driver"; +import { runCase, type AdapterResponse } from "../src/protocol/driver"; +import { parseLanguageAllowlist } from "../src/conformance/select"; import { typescriptProtocolAdapter } from "../src/protocol/runners/typescript"; import { discoverProtocolRunners, @@ -159,31 +160,84 @@ const smokeCases = (() => { })(); // Per-language known divergences from the canonical oracle, keyed by language. -// Each entry is `${op} :: ${scenario}` and is asserted to STILL diverge so the -// gap fails loudly the moment the SDK conforms (mirrors KNOWN_TS_DIVERGENCES). +// Each entry maps `${op} :: ${scenario}` to the runner's exact response, so the +// gap fails loudly the moment the SDK's answer changes (mirrors KNOWN_TS_DIVERGENCES). // -// Empty: every SDK now conforms to the canonical receipt shape. The Go -// (`challengeId:""` injected) and Ruby (`challengeId` hard-required) schema -// mismatches on `receipt.parse :: success_receipt` were both fixed in the -// per-SDK protocol-conformance round, so there are no remaining known runner -// divergences. -const KNOWN_RUNNER_DIVERGENCES: Record> = {}; +// The Go (`challengeId:""` injected) and Ruby (`challengeId` hard-required) +// schema mismatches on `receipt.parse :: success_receipt` were both fixed in +// the per-SDK protocol-conformance round. Swift's SolanaPayKit has no SDK +// function for these ops. +const swiftUnsupported = (op: string, errorType: string, thing: string): AdapterResponse => ({ + success: false, + error: `${op} unsupported: SolanaPayKit has no ${thing}`, + error_type: errorType, +}); +const KNOWN_RUNNER_DIVERGENCES: Record> = { + swift: { + "challenge.format :: basic_challenge": swiftUnsupported( + "challenge.format", + "format_error", + "WWW-Authenticate formatter", + ), + "credential.parse :: basic_credential": swiftUnsupported( + "credential.parse", + "parse_error", + "Authorization parser", + ), + "receipt.parse :: success_receipt": swiftUnsupported( + "receipt.parse", + "parse_error", + "Payment-Receipt parser", + ), + "base64url.encode :: empty_string": swiftUnsupported( + "base64url.encode", + "encoding_error", + "public base64url encoder", + ), + "base64url.decode :: empty_string": swiftUnsupported( + "base64url.decode", + "encoding_error", + "public base64url decoder", + ), + "challenge.id :: required_fields_only": swiftUnsupported( + "challenge.id", + "generation_error", + "challenge-id generator", + ), + }, +}; + +describe("mpp-protocol conformance (spawned runner failure)", () => { + it("a missing swift executable matches no known swift divergence", async () => { + const adapter = spawnedProtocolAdapter({ + language: "swift", + command: ["mpp-protocol-runner-missing"], + cwd: process.cwd(), + }); + const response = await adapter.runProtocolRequest({ op: "challenge.format", input: {} }); + expect(response).toMatchObject({ success: false, error_type: "runner_error" }); + expect(Object.values(KNOWN_RUNNER_DIVERGENCES.swift)).not.toContainEqual(response); + }); +}); -const runners = discoverProtocolRunners(); +const allowlist = parseLanguageAllowlist(process.env.MPP_CONFORMANCE_LANGUAGES); +const runners = discoverProtocolRunners().filter( + (runner) => !allowlist || allowlist.has(runner.language), +); for (const runner of runners) { - const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? new Set(); + const known = KNOWN_RUNNER_DIVERGENCES[runner.language] ?? {}; describe(`mpp-protocol conformance (spawned ${runner.language} runner)`, () => { const adapter = spawnedProtocolAdapter(runner); for (const testCase of smokeCases) { if (!caseRunsOnAdapter(testCase, runner.language)) continue; const key = `${testCase.op} :: ${testCase.scenario}`; - if (known.has(key)) { + if (key in known) { it(`KNOWN DIVERGENCE: ${key}`, async () => { - const result = await runCase(adapter, testCase); + const response = await adapter.runProtocolRequest({ op: testCase.op, input: testCase.input }); expect( - result.ok, - `${key} now conforms — remove from KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, - ).toBe(false); + response, + `${key} changed — update or remove KNOWN_RUNNER_DIVERGENCES[${runner.language}]`, + ).toEqual(known[key]); }); continue; } diff --git a/swift/Sources/mpp-protocol-runner/main.swift b/swift/Sources/mpp-protocol-runner/main.swift new file mode 100644 index 000000000..f5f7617f4 --- /dev/null +++ b/swift/Sources/mpp-protocol-runner/main.swift @@ -0,0 +1,149 @@ +// Command mpp-protocol-runner is the Swift mpp-protocol conformance runner: +// one adapter-ABI request on stdin, one response line on stdout, per the +// contract in harness/src/protocol/runners/spawn.ts. + +import Foundation +import SolanaPayKit + +struct RunnerError: Error, CustomStringConvertible { + var errorType: String? + let description: String +} + +func respond(to raw: Data) -> (line: String, exitCode: Int32) { + guard let request = (try? JSONSerialization.jsonObject(with: raw)) as? [String: Any], + let op = request["op"] as? String + else { + return (failure("request must be a JSON object with a string op", "runner_error"), 1) + } + do { + return (serialize(["success": true, "result": try run(op, request["input"])]), 0) + } catch { + return (failure(String(describing: error), (error as? RunnerError)?.errorType ?? family(of: op)), 0) + } +} + +func run(_ op: String, _ input: Any?) throws -> Any { + switch op { + case "challenge.parse": + return try parseChallenge(object(input, at: "input")) + case "credential.format": + return ["header": try MppHeaders.formatAuthorization(credential(from: object(input, at: "input")))] + case "challenge.format": + throw unsupported(op, "WWW-Authenticate formatter") + case "credential.parse": + throw unsupported(op, "Authorization parser") + case "receipt.parse": + throw unsupported(op, "Payment-Receipt parser") + case "receipt.format": + throw unsupported(op, "Payment-Receipt formatter") + case "base64url.encode": + throw unsupported(op, "public base64url encoder") + case "base64url.decode": + throw unsupported(op, "public base64url decoder") + case "challenge.id": + throw unsupported(op, "challenge-id generator") + default: + throw RunnerError(errorType: "unsupported_operation", description: "Unknown operation: \(op)") + } +} + +func family(of op: String) -> String { + if op.hasPrefix("base64url.") { return "encoding_error" } + if op == "challenge.id" { return "generation_error" } + return op.hasSuffix(".parse") ? "parse_error" : "format_error" +} + +func unsupported(_ op: String, _ thing: String) -> RunnerError { + RunnerError(description: "\(op) unsupported: SolanaPayKit has no \(thing)") +} + +func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { + let challenge = try MppHeaders.parseWWWAuthenticate(string(input, "header", at: "") ?? "") + var result: [String: Any] = [ + "id": challenge.id, + "realm": challenge.realm, + "method": challenge.method, + "intent": challenge.intent, + "request": try decodeJSON(challenge.request), + ] + if let expires = challenge.expires { result["expires"] = expires } + if let digest = challenge.digest { result["digest"] = digest } + if let opaque = challenge.opaque { result["opaque"] = try decodeJSON(opaque) } + return result +} + +func credential(from input: [String: Any]) throws -> PaymentCredential { + try rejectUnknownKeys(input, ["challenge", "payload", "source"], at: "") + let challenge = try object(input["challenge"], at: "challenge") + try rejectUnknownKeys( + challenge, ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"], at: "challenge." + ) + let payload = try object(input["payload"], at: "payload") + try rejectUnknownKeys(payload, ["type", "transaction", "signature"], at: "payload.") + guard let request = challenge["request"] else { throw RunnerError(description: "missing challenge.request") } + let echo = try PaymentChallenge( + id: required(challenge, "id", at: "challenge."), + realm: required(challenge, "realm", at: "challenge."), + method: required(challenge, "method", at: "challenge."), + intent: required(challenge, "intent", at: "challenge."), + request: encodeJSON(request), + expires: string(challenge, "expires", at: "challenge."), + digest: string(challenge, "digest", at: "challenge."), + opaque: challenge["opaque"].map(encodeJSON) + ).echo() + return PaymentCredential( + challenge: echo, + payload: try JSONDecoder().decode(CredentialPayload.self, from: JSONSerialization.data(withJSONObject: payload)), + source: try string(input, "source", at: "") + ) +} + +func object(_ value: Any?, at path: String) throws -> [String: Any] { + guard let object = value as? [String: Any] else { throw RunnerError(description: "\(path) must be a JSON object") } + return object +} + +func string(_ object: [String: Any], _ key: String, at path: String) throws -> String? { + guard let value = object[key] else { return nil } + guard let string = value as? String else { throw RunnerError(description: "\(path)\(key) must be a string") } + return string +} + +func required(_ object: [String: Any], _ key: String, at path: String) throws -> String { + guard let value = try string(object, key, at: path) else { throw RunnerError(description: "missing \(path)\(key)") } + return value +} + +func rejectUnknownKeys(_ object: [String: Any], _ allowed: Set, at path: String) throws { + if let key = object.keys.sorted().first(where: { !allowed.contains($0) }) { + throw RunnerError(description: "unsupported field \(path)\(key)") + } +} + +func decodeJSON(_ base64url: String) throws -> Any { + var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64) else { throw RunnerError(description: "invalid base64url: \(base64url)") } + return try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) +} + +func encodeJSON(_ value: Any) throws -> String { + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed]) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +func failure(_ message: String, _ errorType: String) -> String { + serialize(["success": false, "error": message, "error_type": errorType]) +} + +func serialize(_ response: [String: Any]) -> String { + String(decoding: try! JSONSerialization.data(withJSONObject: response, options: .sortedKeys), as: UTF8.self) +} + +let response = respond(to: FileHandle.standardInput.readDataToEndOfFile()) +FileHandle.standardOutput.write(Data((response.line + "\n").utf8)) +exit(response.exitCode) diff --git a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift new file mode 100644 index 000000000..e277fa36c --- /dev/null +++ b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift @@ -0,0 +1,73 @@ +import Foundation +import XCTest + +@testable import mpp_protocol_runner + +final class AbiFramingTests: XCTestCase { + private let basicChallenge = #"Payment id="ch_abc123", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2NzgifQ""# + + private func call(_ request: Any) throws -> (response: [String: Any], exitCode: Int32) { + let (line, exitCode) = respond(to: try JSONSerialization.data(withJSONObject: request)) + XCTAssertFalse(line.contains("\n")) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + return (response, exitCode) + } + + private func formatCredential(challenge: [String: Any] = [:], payload: [String: Any]) throws -> [String: Any] { + var fullChallenge: [String: Any] = [ + "id": "ch_abc123", "realm": "api.example.com", "method": "tempo", "intent": "charge", + "request": ["amount": "1000000"], + ] + fullChallenge.merge(challenge) { $1 } + let (response, exitCode) = try call([ + "op": "credential.format", "input": ["challenge": fullChallenge, "payload": payload], + ]) + XCTAssertEqual(exitCode, 0) + return response + } + + func testBasicChallengeParseIsOneLine() throws { + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": basicChallenge]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(result["id"] as? String, "ch_abc123") + XCTAssertEqual((result["request"] as? [String: Any])?["amount"] as? String, "1000000") + } + + func testMalformedRequestIsRunnerError() throws { + for raw in [Data("not json".utf8), Data(#"{"op":1}"#.utf8)] { + let (line, exitCode) = respond(to: raw) + XCTAssertEqual(exitCode, 1) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any]) + XCTAssertEqual(response["error_type"] as? String, "runner_error") + } + } + + func testUnknownOperationIsUnsupported() throws { + let (response, exitCode) = try call(["op": "voucher.sign", "input": [:]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "unsupported_operation") + XCTAssertEqual(response["error"] as? String, "Unknown operation: voucher.sign") + } + + func testCredentialFormatRejectsUnknownChallengeField() throws { + let response = try formatCredential( + challenge: ["description": "x"], payload: ["type": "transaction", "transaction": "AQ"] + ) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field challenge.description") + } + + func testCredentialFormatRejectsUnknownPayloadField() throws { + let response = try formatCredential(payload: ["type": "hash", "hash": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertEqual(response["error"] as? String, "unsupported field payload.hash") + } + + func testCredentialFormatRequiresTransaction() throws { + let response = try formatCredential(payload: ["type": "transaction", "signature": "0xabc"]) + XCTAssertEqual(response["error_type"] as? String, "format_error") + XCTAssertTrue((response["error"] as? String ?? "").contains("transaction")) + } +} From 1f02e559a7162062fd64235efe015fdc743f1b19 Mon Sep 17 00:00:00 2001 From: caliperforge Date: Sat, 3 Oct 2026 08:03:43 -0600 Subject: [PATCH 2/6] greptile.json: review on request only --- greptile.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 greptile.json diff --git a/greptile.json b/greptile.json new file mode 100644 index 000000000..3e653b565 --- /dev/null +++ b/greptile.json @@ -0,0 +1 @@ +{"autoReview": []} From 05eee62d835ede9f84bbd522aa06ebb6844050c3 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 13:48:28 -0600 Subject: [PATCH 3/6] test(harness): No edits; checks:test fails because @solana/mpp's dist/ was never built in the check environment, and the Swift runner tests pass (6 tests) --- harness/test/protocol-conformance.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/harness/test/protocol-conformance.test.ts b/harness/test/protocol-conformance.test.ts index 8871efe41..46efc64f4 100644 --- a/harness/test/protocol-conformance.test.ts +++ b/harness/test/protocol-conformance.test.ts @@ -162,11 +162,7 @@ const smokeCases = (() => { // Per-language known divergences from the canonical oracle, keyed by language. // Each entry maps `${op} :: ${scenario}` to the runner's exact response, so the // gap fails loudly the moment the SDK's answer changes (mirrors KNOWN_TS_DIVERGENCES). -// -// The Go (`challengeId:""` injected) and Ruby (`challengeId` hard-required) -// schema mismatches on `receipt.parse :: success_receipt` were both fixed in -// the per-SDK protocol-conformance round. Swift's SolanaPayKit has no SDK -// function for these ops. + const swiftUnsupported = (op: string, errorType: string, thing: string): AdapterResponse => ({ success: false, error: `${op} unsupported: SolanaPayKit has no ${thing}`, From 308d5171b8ce9568aef8a0cf9771c5f33ed3ebe8 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 16:49:28 -0600 Subject: [PATCH 4/6] test(harness): Swift challenge.parse refuses auth-params the SDK has no field for, naming the param; framing test added --- swift/Sources/mpp-protocol-runner/main.swift | 15 +++++++++++---- .../MppProtocolRunnerTests/AbiFramingTests.swift | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/swift/Sources/mpp-protocol-runner/main.swift b/swift/Sources/mpp-protocol-runner/main.swift index f5f7617f4..a2598352a 100644 --- a/swift/Sources/mpp-protocol-runner/main.swift +++ b/swift/Sources/mpp-protocol-runner/main.swift @@ -58,8 +58,17 @@ func unsupported(_ op: String, _ thing: String) -> RunnerError { RunnerError(description: "\(op) unsupported: SolanaPayKit has no \(thing)") } +enum ChallengeFields { + static let all: Set = ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"] +} + func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { - let challenge = try MppHeaders.parseWWWAuthenticate(string(input, "header", at: "") ?? "") + let header = try string(input, "header", at: "") ?? "" + let challenge = try MppHeaders.parseWWWAuthenticate(header) + let params = header.matches(of: #/[\s,]*([^=\s,"]+)\s*=\s*"(?:[^"\\]|\\.)*"/#).map { String($0.1) } + if let param = params.first(where: { !ChallengeFields.all.contains($0) }) { + throw RunnerError(description: "unsupported field \(param)") + } var result: [String: Any] = [ "id": challenge.id, "realm": challenge.realm, @@ -76,9 +85,7 @@ func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { func credential(from input: [String: Any]) throws -> PaymentCredential { try rejectUnknownKeys(input, ["challenge", "payload", "source"], at: "") let challenge = try object(input["challenge"], at: "challenge") - try rejectUnknownKeys( - challenge, ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"], at: "challenge." - ) + try rejectUnknownKeys(challenge, ChallengeFields.all, at: "challenge.") let payload = try object(input["payload"], at: "payload") try rejectUnknownKeys(payload, ["type", "transaction", "signature"], at: "payload.") guard let request = challenge["request"] else { throw RunnerError(description: "missing challenge.request") } diff --git a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift index e277fa36c..7b253ee16 100644 --- a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift +++ b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift @@ -35,6 +35,14 @@ final class AbiFramingTests: XCTestCase { XCTAssertEqual((result["request"] as? [String: Any])?["amount"] as? String, "1000000") } + func testChallengeParseRejectsUnknownParam() throws { + let header = basicChallenge + #", description="x""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["error_type"] as? String, "parse_error") + XCTAssertEqual(response["error"] as? String, "unsupported field description") + } + func testMalformedRequestIsRunnerError() throws { for raw in [Data("not json".utf8), Data(#"{"op":1}"#.utf8)] { let (line, exitCode) = respond(to: raw) From 5af14742e6c18842f0cca0fd646ec42c746afb58 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Sat, 3 Oct 2026 16:59:34 -0600 Subject: [PATCH 5/6] test(harness): Swift protocol runner rejects only the spec field PaymentChallenge cannot carry (description) and ignores extension params per spec --- swift/Sources/mpp-protocol-runner/main.swift | 4 +++- .../MppProtocolRunnerTests/AbiFramingTests.swift | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/swift/Sources/mpp-protocol-runner/main.swift b/swift/Sources/mpp-protocol-runner/main.swift index a2598352a..65dc7cfca 100644 --- a/swift/Sources/mpp-protocol-runner/main.swift +++ b/swift/Sources/mpp-protocol-runner/main.swift @@ -60,13 +60,15 @@ func unsupported(_ op: String, _ thing: String) -> RunnerError { enum ChallengeFields { static let all: Set = ["id", "realm", "method", "intent", "request", "expires", "digest", "opaque"] + // Spec challenge params PaymentChallenge cannot carry; extension params are ignored per spec. + static let unrepresentable: Set = ["description"] } func parseChallenge(_ input: [String: Any]) throws -> [String: Any] { let header = try string(input, "header", at: "") ?? "" let challenge = try MppHeaders.parseWWWAuthenticate(header) let params = header.matches(of: #/[\s,]*([^=\s,"]+)\s*=\s*"(?:[^"\\]|\\.)*"/#).map { String($0.1) } - if let param = params.first(where: { !ChallengeFields.all.contains($0) }) { + if let param = params.first(where: ChallengeFields.unrepresentable.contains) { throw RunnerError(description: "unsupported field \(param)") } var result: [String: Any] = [ diff --git a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift index 7b253ee16..08f043468 100644 --- a/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift +++ b/swift/Tests/MppProtocolRunnerTests/AbiFramingTests.swift @@ -35,7 +35,7 @@ final class AbiFramingTests: XCTestCase { XCTAssertEqual((result["request"] as? [String: Any])?["amount"] as? String, "1000000") } - func testChallengeParseRejectsUnknownParam() throws { + func testChallengeParseRejectsDescription() throws { let header = basicChallenge + #", description="x""# let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) XCTAssertEqual(exitCode, 0) @@ -43,6 +43,15 @@ final class AbiFramingTests: XCTestCase { XCTAssertEqual(response["error"] as? String, "unsupported field description") } + func testChallengeParseIgnoresExtensionParams() throws { + let header = basicChallenge + #", foo="bar", baz="qux""# + let (response, exitCode) = try call(["op": "challenge.parse", "input": ["header": header]]) + XCTAssertEqual(exitCode, 0) + XCTAssertEqual(response["success"] as? Bool, true) + let result = try XCTUnwrap(response["result"] as? [String: Any]) + XCTAssertEqual(Set(result.keys), ["id", "realm", "method", "intent", "request"]) + } + func testMalformedRequestIsRunnerError() throws { for raw in [Data("not json".utf8), Data(#"{"op":1}"#.utf8)] { let (line, exitCode) = respond(to: raw) From 9f47b85f245a0f34a77a2ae762adc94c5a1feeea Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Tue, 6 Oct 2026 18:30:47 -0400 Subject: [PATCH 6/6] feat(rust): MPP/x402 over MCP/RPC (#351) --- rust/README.md | 39 + rust/crates/kit/Cargo.toml | 8 +- rust/crates/kit/src/mpp/mcp.rs | 1110 +++++++++++++++++ rust/crates/kit/src/mpp/mod.rs | 1 + .../kit/src/mpp/protocol/core/challenge.rs | 3 + typescript/pnpm-lock.yaml | 175 +-- typescript/pnpm-workspace.yaml | 4 + 7 files changed, 1174 insertions(+), 166 deletions(-) create mode 100644 rust/crates/kit/src/mpp/mcp.rs diff --git a/rust/README.md b/rust/README.md index dfbf39c30..f45f6295e 100644 --- a/rust/README.md +++ b/rust/README.md @@ -174,6 +174,41 @@ Payment` credential, decodes the client-signed transaction and checks recipient, amount, mint, splits, ATA, memos, and compute budget, optionally co-signs as fee payer, broadcasts, polls to `confirmed`, and emits `Payment-Receipt`. +### MCP with `rmcp` + +Enable the `rmcp` feature to use the Payment Auth MCP transport with the +official Rust MCP SDK. The adapter reads payment credentials from the metadata +that `rmcp` places on `RequestContext`, returns the standard JSON-RPC `-32042` +or `-32043` payment errors, and attaches the receipt to the normal tool result: + +```rust +use rmcp::{ + model::{CallToolRequestParams, CallToolResult, ErrorCode}, + service::{RequestContext, RoleServer}, + ErrorData, +}; +use solana_pay_kit::mpp::{mcp::rmcp as payment_mcp, server::Mpp}; + +async fn paid_tool( + payment: &Mpp, + request: CallToolRequestParams, + context: RequestContext, +) -> Result { + let receipt = payment_mcp::gate_charge(payment, &request, &context.meta, "0.001").await?; + + let mut result = CallToolResult::success(vec![]); + payment_mcp::attach_receipt(&mut result, &receipt).map_err(|error| { + ErrorData::new(ErrorCode::INTERNAL_ERROR, error.to_string(), None) + })?; + Ok(result) +} +``` + +Client code can use `payment_mcp::challenges`, `set_credential`, and `receipt` +to implement the challenge, paid retry, and receipt flow with `rmcp` request and +result types. The transport-neutral `mpp::mcp` module also exposes the same +native-JSON mapping for custom MCP runtimes and session lifecycle handlers. + ## x402 [x402](https://x402.org) revives HTTP `402 Payment Required`. The Rust @@ -340,6 +375,9 @@ solana-pay-kit = { version = "0.1", features = ["axum"] } # Single protocol: solana-pay-kit = { version = "0.1", default-features = false, features = ["mpp"] } + +# Payment Auth MCP transport with the official Rust MCP SDK: +solana-pay-kit = { version = "0.1", default-features = false, features = ["rmcp"] } ``` Feature flags: @@ -351,6 +389,7 @@ Feature flags: | `server` | — | server-side verification for the enabled protocols | | `client` | — | client-side payment building for the enabled protocols | | `axum` | — | the `paid_get` / `paid_post` gate (implies `server` + both protocols) | +| `rmcp` | — | Payment Auth MCP adapters for the official Rust MCP SDK (implies `mpp` + `server`) | | `gcp_kms` | — | GCP KMS signing backend | | `ledger` | — | Ledger hardware-wallet signing over USB-HID (`solana_keychain::ledger`; links hidapi) | diff --git a/rust/crates/kit/Cargo.toml b/rust/crates/kit/Cargo.toml index 91e50377e..32ecd871b 100644 --- a/rust/crates/kit/Cargo.toml +++ b/rust/crates/kit/Cargo.toml @@ -29,6 +29,10 @@ client = ["dep:reqwest"] # The unified axum gate dispatches both protocols, so it needs both modules # plus the server side. axum = ["mpp", "x402", "server", "dep:axum"] +# Ergonomic adapters for the official Rust MCP SDK. The core JSON-RPC codec +# remains available through `mpp::mcp` without pulling rmcp into applications +# that use another MCP implementation. +rmcp = ["mpp", "server", "dep:rmcp"] gcp_kms = ["solana-keychain/gcp_kms"] # Ledger hardware-wallet signing over USB-HID, forwarded from solana-keychain # (`pay_kit::solana_keychain::ledger`). Opt-in: it links hidapi, which needs @@ -86,7 +90,7 @@ otel = [ # commit directly: path and git consumers of the kit inherit it (a workspace # `[patch]` would not reach them). `cargo publish` strips the git source and # resolves beta.1, which does not compile; publishing waits for beta.2. -solana-keychain = { version = "2.0.0-beta.1", git = "https://github.com/solana-foundation/solana-keychain", rev = "6461a18cc39d4700d589b1eb981f8ddd71c09e8d", default-features = false, features = ["memory", "sdk-v4"] } +solana-keychain = { version = "2.0.0-beta.1", git = "https://github.com/solana-foundation/solana-keychain", rev = "44b479df245167ca3b3a4e04d6d907146306d342", default-features = false, features = ["memory", "sdk-v4"] } # Force five8_core's `std` feature ON: gates `impl Error for DecodeError`, # which solana-keypair/solana-signature rely on. @@ -154,6 +158,7 @@ reqwest = { version = "0.12", features = ["json", "stream"], optional = true } # Axum extractor / unified gate (opt-in). axum = { version = "0.8", optional = true, default-features = false } +rmcp = { version = "3.5", optional = true, default-features = false } # Serialization serde = { version = "1", features = ["derive"] } @@ -203,6 +208,7 @@ thiserror = "2" # program, which the `litesvm-tests` confidential tests rely on. litesvm = "0.16" tokio = { version = "1", features = ["full"] } +rmcp = { version = "3.5", default-features = false, features = ["client", "server"] } axum = "0.8" tower = { version = "0.5", features = ["util"] } serde_json = "1" diff --git a/rust/crates/kit/src/mpp/mcp.rs b/rust/crates/kit/src/mpp/mcp.rs new file mode 100644 index 000000000..9ea2d1740 --- /dev/null +++ b/rust/crates/kit/src/mpp/mcp.rs @@ -0,0 +1,1110 @@ +//! Payment Auth transport for JSON-RPC 2.0 and MCP. +//! +//! This module maps pay-kit's transport-neutral MPP challenge, credential, +//! and receipt types to `draft-payment-transport-mcp-00`: +//! +//! - payment challenges use JSON-RPC error `-32042`; +//! - credentials use `_meta["org.paymentauth/credential"]`; +//! - receipts use `_meta["org.paymentauth/receipt"]`. +//! +//! The wire format is intentionally separate from x402's MCP transport, +//! which uses `x402/payment` metadata and MCP tool-error results. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::mpp::{ + Base64UrlJson, ChallengeEcho, Error, IntentName, MethodName, PaymentChallenge, + PaymentCredential, Receipt, +}; + +#[cfg(feature = "server")] +use crate::mpp::{server::Mpp, ChargeRequest}; + +/// JSON-RPC error code for a payment challenge. +pub const PAYMENT_REQUIRED_CODE: i64 = -32042; +/// JSON-RPC error code for a failed payment verification. +pub const PAYMENT_VERIFICATION_FAILED_CODE: i64 = -32043; +/// JSON-RPC error code for a malformed payment credential. +pub const INVALID_PARAMS_CODE: i64 = -32602; +/// JSON-RPC error code for an internal payment processor failure. +pub const INTERNAL_ERROR_CODE: i64 = -32603; + +/// MCP metadata key carrying a payment credential. +pub const CREDENTIAL_META_KEY: &str = "org.paymentauth/credential"; +/// MCP metadata key carrying a payment receipt. +pub const RECEIPT_META_KEY: &str = "org.paymentauth/receipt"; + +/// A JSON-RPC request with optional root-level metadata. +/// +/// MCP places `_meta` inside `params`; generic JSON-RPC places it at the +/// request root. Servers must accept both placements. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Request { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option>, +} + +impl Request { + /// Returns whether this request is a JSON-RPC notification. + /// + /// Payment-gated notifications must not be processed because no challenge + /// can be returned to the caller. + pub fn is_notification(&self) -> bool { + self.id.is_none() + } + + /// Extract a credential from nested MCP or root JSON-RPC metadata. + /// + /// If both placements are present they must be identical. Rejecting + /// conflicting values avoids intermediaries selecting a different proof + /// than the application inspected. + pub fn credential(&self) -> Result, Error> { + let nested = self + .params + .as_ref() + .and_then(Value::as_object) + .and_then(|params| params.get("_meta")) + .and_then(Value::as_object) + .and_then(|meta| meta.get(CREDENTIAL_META_KEY)); + let root = self + .meta + .as_ref() + .and_then(|meta| meta.get(CREDENTIAL_META_KEY)); + + let value = match (nested, root) { + (None, None) => return Ok(None), + (Some(value), None) | (None, Some(value)) => value, + (Some(nested), Some(root)) if nested == root => nested, + (Some(_), Some(_)) => { + return Err(Error::Other( + "conflicting payment credentials in root and params metadata".into(), + )) + } + }; + + let credential: Credential = serde_json::from_value(value.clone()) + .map_err(|error| Error::Other(format!("invalid MCP payment credential: {error}")))?; + credential.try_into().map(Some) + } + + /// Attach a credential using MCP's nested `params._meta` placement. + pub fn set_credential(&mut self, credential: &PaymentCredential) -> Result<(), Error> { + let credential = Credential::try_from(credential)?; + let value = serde_json::to_value(credential) + .map_err(|error| Error::Other(format!("failed to encode MCP credential: {error}")))?; + let params = self.params.get_or_insert_with(|| Value::Object(Map::new())); + let params = params.as_object_mut().ok_or_else(|| { + Error::Other("MCP request params must be an object to carry payment metadata".into()) + })?; + let meta = params + .entry("_meta") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| Error::Other("MCP request params._meta must be an object".into()))?; + meta.insert(CREDENTIAL_META_KEY.into(), value); + Ok(()) + } + + /// Stable digest binding a challenge to this operation. + /// + /// JSON-RPC envelope fields, payment credentials, and transport correlation + /// metadata are excluded, so a retry may use a new request ID, progress + /// token, and credential. The method, arguments, and application metadata + /// remain bound. + pub fn operation_digest(&self) -> Result { + let mut params = self.params.clone(); + if let Some(object) = params.as_mut().and_then(Value::as_object_mut) { + remove_retry_metadata(object); + if object.is_empty() { + params = None; + } + } + let operation = serde_json::json!({ + "method": self.method, + "params": params, + }); + let canonical = serde_json_canonicalizer::to_vec(&operation).map_err(|error| { + Error::Other(format!("failed to canonicalize MCP operation: {error}")) + })?; + Ok(format!( + "sha-256:{}", + crate::mpp::base64url_encode(&Sha256::digest(canonical)) + )) + } +} + +/// Native-JSON payment challenge used by JSON-RPC and MCP. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Challenge { + pub id: String, + pub realm: String, + pub method: MethodName, + pub intent: IntentName, + pub request: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opaque: Option, +} + +impl TryFrom<&PaymentChallenge> for Challenge { + type Error = Error; + + fn try_from(challenge: &PaymentChallenge) -> Result { + if challenge.id.is_empty() { + return Err(Error::Other( + "MCP payment challenge id must not be empty".into(), + )); + } + Ok(Self { + id: challenge.id.clone(), + realm: challenge.realm.clone(), + method: challenge.method.clone(), + intent: challenge.intent.clone(), + request: challenge.request.decode_value()?, + expires: challenge.expires.clone(), + description: challenge.description.clone(), + digest: challenge.digest.clone(), + opaque: challenge + .opaque + .as_ref() + .map(Base64UrlJson::decode_value) + .transpose()?, + }) + } +} + +impl TryFrom for PaymentChallenge { + type Error = Error; + + fn try_from(challenge: Challenge) -> Result { + if challenge.id.is_empty() { + return Err(Error::Other( + "MCP payment challenge id must not be empty".into(), + )); + } + Ok(Self { + id: challenge.id, + realm: challenge.realm, + method: challenge.method, + intent: challenge.intent, + request: Base64UrlJson::from_value(&challenge.request)?, + expires: challenge.expires, + description: challenge.description, + digest: challenge.digest, + opaque: challenge + .opaque + .as_ref() + .map(Base64UrlJson::from_value) + .transpose()?, + }) + } +} + +/// Payment credential carried in MCP request metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Credential { + pub challenge: Challenge, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + pub payload: Value, +} + +impl TryFrom<&PaymentCredential> for Credential { + type Error = Error; + + fn try_from(credential: &PaymentCredential) -> Result { + let challenge = PaymentChallenge { + id: credential.challenge.id.clone(), + realm: credential.challenge.realm.clone(), + method: credential.challenge.method.clone(), + intent: credential.challenge.intent.clone(), + request: credential.challenge.request.clone(), + expires: credential.challenge.expires.clone(), + description: credential.challenge.description.clone(), + digest: credential.challenge.digest.clone(), + opaque: credential.challenge.opaque.clone(), + }; + Ok(Self { + challenge: Challenge::try_from(&challenge)?, + source: credential.source.clone(), + payload: credential.payload.clone(), + }) + } +} + +impl TryFrom for PaymentCredential { + type Error = Error; + + fn try_from(credential: Credential) -> Result { + let challenge: PaymentChallenge = credential.challenge.try_into()?; + Ok(Self { + challenge: ChallengeEcho { + id: challenge.id, + realm: challenge.realm, + method: challenge.method, + intent: challenge.intent, + request: challenge.request, + expires: challenge.expires, + description: challenge.description, + digest: challenge.digest, + opaque: challenge.opaque, + }, + source: credential.source, + payload: credential.payload, + }) + } +} + +/// Payment receipt carried in MCP response metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpReceipt { + pub status: crate::mpp::ReceiptStatus, + pub method: MethodName, + pub timestamp: String, + pub challenge_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +impl From<&Receipt> for McpReceipt { + fn from(receipt: &Receipt) -> Self { + Self { + status: receipt.status.clone(), + method: receipt.method.clone(), + timestamp: receipt.timestamp.clone(), + challenge_id: receipt.challenge_id.clone(), + reference: (!receipt.reference.is_empty()).then(|| receipt.reference.clone()), + } + } +} + +/// JSON-RPC payment error response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ErrorResponse { + pub jsonrpc: String, + pub id: Value, + pub error: ErrorObject, +} + +/// JSON-RPC error object used for payment failures. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ErrorObject { + pub code: i64, + pub message: String, + pub data: ErrorData, +} + +/// Structured payment data in a JSON-RPC error. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ErrorData { + #[serde(default = "payment_required_status")] + pub http_status: u16, + pub challenges: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +/// Optional machine- and human-readable verification failure. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Failure { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Result of applying a Solana charge gate to an MCP request. +#[cfg(feature = "server")] +#[derive(Debug, Clone)] +pub enum ChargeGateResult { + /// The tool may execute. Attach `receipt` to its result with + /// [`attach_receipt`] before returning it. + Paid { receipt: Receipt }, + /// Return this JSON-RPC payment response without executing the tool. + Payment(ErrorResponse), +} + +#[cfg(feature = "server")] +impl Mpp { + /// Gate one MCP operation with a Solana charge priced in display units. + /// + /// The challenge is cryptographically bound to the MCP method and params + /// (excluding request ID and payment metadata), which prevents replaying a + /// credential issued for one tool or argument set against another. + /// Notifications are rejected because JSON-RPC cannot return their payment + /// challenge. + pub async fn gate_mcp_charge( + &self, + request: &Request, + amount: &str, + ) -> Result { + let id = request.id.clone().ok_or_else(|| { + Error::Other("payment-gated MCP operations must include a JSON-RPC id".into()) + })?; + let challenge = bind_operation( + self.charge(amount)?, + request, + &self.challenge_binding_secret, + )?; + let wire_challenge = Challenge::try_from(&challenge)?; + + let credential = match request.credential() { + Ok(Some(credential)) => credential, + Ok(None) => { + return Ok(ChargeGateResult::Payment(ErrorResponse::payment_required( + id, + vec![wire_challenge], + ))) + } + Err(error) => { + return Err(Error::Other(format!( + "malformed MCP payment credential: {error}" + ))) + } + }; + + let operation_digest = request.operation_digest()?; + let credential_digest = credential.challenge.digest.as_deref().unwrap_or_default(); + if !crate::mpp::protocol::core::challenge::constant_time_eq( + credential_digest, + &operation_digest, + ) { + return Ok(ChargeGateResult::Payment( + ErrorResponse::verification_failed( + id, + vec![wire_challenge], + Failure { + reason: Some("operation-mismatch".into()), + detail: Some( + "payment credential was issued for a different MCP operation".into(), + ), + }, + ), + )); + } + + let expected: ChargeRequest = challenge.request.decode()?; + match self + .verify_credential_with_expected(&credential, &expected) + .await + { + Ok(receipt) => Ok(ChargeGateResult::Paid { receipt }), + Err(error) => Ok(ChargeGateResult::Payment( + ErrorResponse::verification_failed( + id, + vec![wire_challenge], + Failure { + reason: error.code.map(str::to_owned), + detail: Some(error.message), + }, + ), + )), + } + } +} + +impl ErrorResponse { + /// Construct a `-32042 Payment Required` response. + pub fn payment_required(id: Value, challenges: Vec) -> Self { + Self::new( + id, + PAYMENT_REQUIRED_CODE, + "Payment Required", + challenges, + None, + ) + } + + /// Construct a `-32043 Payment Verification Failed` response. + pub fn verification_failed(id: Value, challenges: Vec, failure: Failure) -> Self { + Self::new( + id, + PAYMENT_VERIFICATION_FAILED_CODE, + "Payment Verification Failed", + challenges, + Some(failure), + ) + } + + fn new( + id: Value, + code: i64, + message: &str, + challenges: Vec, + failure: Option, + ) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id, + error: ErrorObject { + code, + message: message.into(), + data: ErrorData { + http_status: payment_required_status(), + challenges, + problem: None, + failure, + }, + }, + } + } +} + +/// Add a payment receipt to an MCP result object. +pub fn attach_receipt(result: &mut Value, receipt: &Receipt) -> Result<(), Error> { + if receipt.challenge_id.is_empty() { + return Err(Error::Other( + "MCP payment receipts require a challengeId".into(), + )); + } + let result = result + .as_object_mut() + .ok_or_else(|| Error::Other("MCP result must be an object to carry a receipt".into()))?; + let meta = result + .entry("_meta") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| Error::Other("MCP result._meta must be an object".into()))?; + let receipt = serde_json::to_value(McpReceipt::from(receipt)) + .map_err(|error| Error::Other(format!("failed to encode MCP receipt: {error}")))?; + meta.insert(RECEIPT_META_KEY.into(), receipt); + Ok(()) +} + +/// Extract a payment receipt from an MCP result object. +pub fn receipt(result: &Value) -> Result, Error> { + let Some(value) = result + .as_object() + .and_then(|result| result.get("_meta")) + .and_then(Value::as_object) + .and_then(|meta| meta.get(RECEIPT_META_KEY)) + else { + return Ok(None); + }; + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|error| Error::Other(format!("invalid MCP payment receipt: {error}"))) +} + +/// Payment methods advertised in MCP's experimental capabilities. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Capabilities { + pub methods: BTreeMap, +} + +/// Intents supported by one payment method. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MethodCapabilities { + pub intents: Vec, +} + +impl Capabilities { + /// Advertise the Solana intents supported by this pay-kit server. + pub fn solana(intents: impl IntoIterator>) -> Self { + Self { + methods: BTreeMap::from([( + "solana".into(), + MethodCapabilities { + intents: intents.into_iter().map(Into::into).collect(), + }, + )]), + } + } +} + +/// Merge payment support into an MCP initialize capability object. +pub fn advertise(capabilities: &mut Value, payment: Capabilities) -> Result<(), Error> { + let capabilities = capabilities + .as_object_mut() + .ok_or_else(|| Error::Other("MCP initialize capabilities must be a JSON object".into()))?; + let experimental = capabilities + .entry("experimental") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| Error::Other("MCP capabilities.experimental must be an object".into()))?; + experimental.insert( + "payment".into(), + serde_json::to_value(payment).map_err(|error| { + Error::Other(format!( + "failed to encode MCP payment capabilities: {error}" + )) + })?, + ); + Ok(()) +} + +fn jsonrpc_version() -> String { + "2.0".into() +} + +fn payment_required_status() -> u16 { + 402 +} + +#[cfg(feature = "server")] +fn bind_operation( + challenge: PaymentChallenge, + request: &Request, + challenge_binding_secret: &str, +) -> Result { + let digest = request.operation_digest()?; + let PaymentChallenge { + realm, + method, + intent, + request, + expires, + description, + opaque, + .. + } = challenge; + Ok(PaymentChallenge::with_challenge_binding_secret_full( + challenge_binding_secret, + realm, + method, + intent, + request, + expires.as_deref(), + Some(&digest), + description.as_deref(), + opaque, + )) +} + +fn remove_retry_metadata(params: &mut Map) { + let Some(meta) = params.get_mut("_meta").and_then(Value::as_object_mut) else { + return; + }; + meta.remove(CREDENTIAL_META_KEY); + meta.remove("progressToken"); + if meta.is_empty() { + params.remove("_meta"); + } +} + +/// Adapters for the official [`rmcp`](https://crates.io/crates/rmcp) SDK. +#[cfg(feature = "rmcp")] +pub mod rmcp { + use super::{ + attach_receipt as attach_json_receipt, receipt as json_receipt, Capabilities, + ChargeGateResult, Credential, ErrorResponse, McpReceipt, Request, CREDENTIAL_META_KEY, + RECEIPT_META_KEY, + }; + use crate::mpp::{server::Mpp, Error, PaymentCredential, Receipt}; + use ::rmcp::{ + model::{ + CallToolRequestParams, CallToolResult, ErrorCode, ExperimentalCapabilities, MetaObject, + RequestMetaObject, ServerCapabilities, + }, + ErrorData, + }; + use serde_json::Value; + + /// Gate an rmcp `tools/call` with a Solana charge. + /// + /// Pass `context.meta` from rmcp's `RequestContext`: rmcp moves wire-level + /// `params._meta` there before dispatching the tool handler. On success, + /// attach the returned receipt with [`attach_receipt`]. On challenge or + /// verification failure, the returned `ErrorData` carries the required + /// `-32042` or `-32043` JSON-RPC response. + pub async fn gate_charge( + payment: &Mpp, + params: &CallToolRequestParams, + meta: &RequestMetaObject, + amount: &str, + ) -> Result { + let request = request(params, meta).map_err(internal_error)?; + if let Err(error) = request.credential() { + return Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "Malformed payment credential", + Some(serde_json::json!({ "detail": error.to_string() })), + )); + } + match payment + .gate_mcp_charge(&request, amount) + .await + .map_err(internal_error)? + { + ChargeGateResult::Paid { receipt } => Ok(receipt), + ChargeGateResult::Payment(response) => Err(error_data(response)), + } + } + + /// Insert a Solana payment credential into an outgoing rmcp tool call. + pub fn set_credential( + params: &mut CallToolRequestParams, + credential: &PaymentCredential, + ) -> Result<(), Error> { + let credential = Credential::try_from(credential)?; + let value = serde_json::to_value(credential) + .map_err(|error| Error::Other(format!("failed to encode rmcp credential: {error}")))?; + let meta = params.meta.get_or_insert_with(RequestMetaObject::new); + meta.insert(CREDENTIAL_META_KEY.into(), value); + Ok(()) + } + + /// Attach a successful Solana payment receipt to an rmcp tool result. + pub fn attach_receipt(result: &mut CallToolResult, receipt: &Receipt) -> Result<(), Error> { + let mut value = serde_json::to_value(&*result) + .map_err(|error| Error::Other(format!("failed to encode rmcp result: {error}")))?; + attach_json_receipt(&mut value, receipt)?; + let receipt = value + .get("_meta") + .and_then(Value::as_object) + .and_then(|meta| meta.get(RECEIPT_META_KEY)) + .cloned() + .ok_or_else(|| Error::Other("rmcp receipt metadata was not created".into()))?; + result + .meta + .get_or_insert_with(MetaObject::new) + .insert(RECEIPT_META_KEY.into(), receipt); + Ok(()) + } + + /// Extract a Payment Auth receipt from an rmcp tool result. + pub fn receipt(result: &CallToolResult) -> Result, Error> { + let value = serde_json::to_value(result) + .map_err(|error| Error::Other(format!("failed to encode rmcp result: {error}")))?; + json_receipt(&value) + } + + /// Extract transport-neutral Solana payment challenges from an rmcp error. + /// + /// Returns `Ok(None)` for errors unrelated to payment. Clients can select a + /// challenge, build the existing pay-kit charge/session credential, attach + /// it with [`set_credential`], and retry the same tool call. + pub fn challenges( + error: &ErrorData, + ) -> Result>, Error> { + if error.code.0 != super::PAYMENT_REQUIRED_CODE as i32 + && error.code.0 != super::PAYMENT_VERIFICATION_FAILED_CODE as i32 + { + return Ok(None); + } + let data: super::ErrorData = + serde_json::from_value(error.data.clone().ok_or_else(|| { + Error::Other("rmcp payment error is missing challenge data".into()) + })?) + .map_err(|decode| { + Error::Other(format!("invalid rmcp payment challenge data: {decode}")) + })?; + data.challenges + .into_iter() + .map(crate::mpp::PaymentChallenge::try_from) + .collect::, _>>() + .map(Some) + } + + /// Advertise Solana Payment Auth support in rmcp server capabilities. + pub fn advertise( + capabilities: &mut ServerCapabilities, + payment: Capabilities, + ) -> Result<(), Error> { + let payment = serde_json::to_value(payment) + .map_err(|error| Error::Other(format!("failed to encode rmcp capabilities: {error}")))? + .as_object() + .cloned() + .ok_or_else(|| Error::Other("payment capabilities must encode as an object".into()))?; + capabilities + .experimental + .get_or_insert_with(ExperimentalCapabilities::new) + .insert("payment".into(), payment); + Ok(()) + } + + /// Convert rmcp tool-call params and dispatch metadata to the core transport + /// request. Useful for Solana session handlers that manage their own + /// channel lifecycle. + pub fn request( + params: &CallToolRequestParams, + meta: &RequestMetaObject, + ) -> Result { + let mut params = serde_json::to_value(params) + .map_err(|error| Error::Other(format!("failed to encode rmcp tool call: {error}")))?; + let params_object = params + .as_object_mut() + .ok_or_else(|| Error::Other("rmcp tool-call params must encode as an object".into()))?; + params_object.insert( + "_meta".into(), + serde_json::to_value(meta).map_err(|error| { + Error::Other(format!("failed to encode rmcp metadata: {error}")) + })?, + ); + Ok(Request { + jsonrpc: "2.0".into(), + // rmcp owns the actual JSON-RPC ID and wraps ErrorData with it. + id: Some(Value::Null), + method: "tools/call".into(), + params: Some(params), + meta: None, + }) + } + + /// Convert a core payment challenge/failure into the protocol error rmcp + /// expects from a tool handler. + pub fn error_data(response: ErrorResponse) -> ErrorData { + ErrorData::new( + ErrorCode(response.error.code as i32), + response.error.message, + serde_json::to_value(response.error.data).ok(), + ) + } + + fn internal_error(error: Error) -> ErrorData { + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + "Payment processing failed", + Some(serde_json::json!({ "detail": error.to_string() })), + ) + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::mpp::{Base64UrlJson, PaymentChallenge}; + use ::rmcp::{ + model::{CallToolResponse, ClientConfig, ContentBlock, ServerConfig}, + service::{serve_directly, RequestContext, RoleClient, RoleServer}, + ServerHandler, + }; + use std::sync::{Arc, Mutex}; + + #[derive(Debug, Default)] + struct RetryState { + digest: Option, + progress_tokens: Vec, + } + + #[derive(Debug, Clone, Default)] + struct RetryServer { + state: Arc>, + } + + impl ServerHandler for RetryServer { + async fn call_tool( + &self, + params: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let digest = request(¶ms, &context.meta) + .and_then(|request| request.operation_digest()) + .map_err(internal_error)?; + let mut state = self.state.lock().map_err(|_| { + ErrorData::new(ErrorCode::INTERNAL_ERROR, "retry state poisoned", None) + })?; + state.progress_tokens.push( + context + .meta + .get("progressToken") + .cloned() + .expect("rmcp should attach a progress token"), + ); + + if let Some(expected) = state.digest.as_ref() { + if expected != &digest { + return Err(ErrorData::new( + ErrorCode(super::super::PAYMENT_VERIFICATION_FAILED_CODE as i32), + "operation-mismatch", + None, + )); + } + Ok(CallToolResult::success(vec![ContentBlock::text("paid")]).into()) + } else { + state.digest = Some(digest); + Err(ErrorData::new( + ErrorCode(super::super::PAYMENT_REQUIRED_CODE as i32), + "Payment Required", + None, + )) + } + } + } + + #[test] + fn credential_and_receipt_use_rmcp_metadata() { + let challenge = PaymentChallenge::new( + "challenge-1", + "compute.example.com", + "solana", + "charge", + Base64UrlJson::from_value(&serde_json::json!({"amount": "1"})).unwrap(), + ); + let credential = PaymentCredential::new( + challenge.to_echo(), + serde_json::json!({"type": "signature", "signature": "sig"}), + ); + let mut params = CallToolRequestParams::new("compute"); + set_credential(&mut params, &credential).unwrap(); + assert!(params + .meta + .as_ref() + .unwrap() + .contains_key(CREDENTIAL_META_KEY)); + + let mut result = CallToolResult::success(Vec::new()); + let paid = Receipt::success("solana", "tx", "challenge-1"); + attach_receipt(&mut result, &paid).unwrap(); + assert_eq!( + receipt(&result).unwrap().unwrap().challenge_id, + "challenge-1" + ); + } + + #[test] + fn advertises_rmcp_payment_capability() { + let mut capabilities = ServerCapabilities::default(); + advertise( + &mut capabilities, + Capabilities::solana(["charge", "session"]), + ) + .unwrap(); + assert!(capabilities.experimental.unwrap().contains_key("payment")); + } + + #[test] + fn extracts_payment_challenges_from_rmcp_errors() { + let challenge = super::super::Challenge::try_from(&PaymentChallenge::new( + "challenge-1", + "compute.example.com", + "solana", + "session", + Base64UrlJson::from_value(&serde_json::json!({"amount": "1"})).unwrap(), + )) + .unwrap(); + let error = error_data(ErrorResponse::payment_required( + Value::Null, + vec![challenge], + )); + let challenges = challenges(&error).unwrap().unwrap(); + assert_eq!(challenges[0].intent.as_str(), "session"); + } + + #[test] + fn extracts_payment_challenges_without_http_status() { + let challenge = super::super::Challenge::try_from(&PaymentChallenge::new( + "challenge-1", + "compute.example.com", + "solana", + "charge", + Base64UrlJson::from_value(&serde_json::json!({"amount": "1"})).unwrap(), + )) + .unwrap(); + let error = ErrorData::new( + ErrorCode(super::super::PAYMENT_REQUIRED_CODE as i32), + "Payment Required", + Some(serde_json::json!({"challenges": [challenge]})), + ); + + let challenges = challenges(&error).unwrap().unwrap(); + assert_eq!(challenges[0].intent.as_str(), "charge"); + } + + #[tokio::test] + async fn paid_retry_ignores_rmcp_progress_token() { + let (server_transport, client_transport) = tokio::io::duplex(4096); + let server = RetryServer::default(); + let state = Arc::clone(&server.state); + let running_server = serve_directly::( + server, + server_transport, + Some(ClientConfig::default()), + ); + let server_task = tokio::spawn(async move { running_server.waiting().await }); + let client = serve_directly::( + (), + client_transport, + Some(ServerConfig::default().into()), + ); + + let mut params = + CallToolRequestParams::new("compute").with_arguments(serde_json::Map::from_iter([ + ("cpu".into(), serde_json::json!(1)), + ])); + let error = client.call_tool(params.clone()).await.unwrap_err(); + assert!(matches!( + error, + ::rmcp::ServiceError::McpError(ref data) + if data.code.0 == super::super::PAYMENT_REQUIRED_CODE as i32 + )); + + let payment = PaymentChallenge::new( + "challenge-1", + "compute.example.com", + "solana", + "charge", + Base64UrlJson::from_value(&serde_json::json!({"amount": "1"})).unwrap(), + ); + let credential = PaymentCredential::new( + payment.to_echo(), + serde_json::json!({"type": "signature", "signature": "sig"}), + ); + set_credential(&mut params, &credential).unwrap(); + + client.call_tool(params).await.unwrap(); + client.cancel().await.unwrap(); + server_task.await.unwrap().unwrap(); + + let state = state.lock().unwrap(); + assert_eq!(state.progress_tokens.len(), 2); + assert_ne!(state.progress_tokens[0], state.progress_tokens[1]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payment_challenge() -> PaymentChallenge { + PaymentChallenge::new( + "challenge-1", + "compute.example.com", + "solana", + "session", + Base64UrlJson::from_value(&serde_json::json!({ + "amount": "1000", + "currency": "USDC" + })) + .unwrap(), + ) + .with_expires("2030-01-01T00:00:00Z") + .with_description("Compute execution") + } + + #[test] + fn challenge_uses_native_json_and_round_trips() { + let payment = payment_challenge(); + let challenge = Challenge::try_from(&payment).unwrap(); + assert_eq!(challenge.request["amount"], "1000"); + assert_eq!(challenge.description.as_deref(), Some("Compute execution")); + + let round_trip = PaymentChallenge::try_from(challenge).unwrap(); + assert_eq!( + round_trip.request.decode_value().unwrap(), + payment.request.decode_value().unwrap() + ); + assert_eq!(round_trip.description, payment.description); + } + + #[test] + fn request_reads_nested_and_root_credentials_but_rejects_conflicts() { + let payment = payment_challenge(); + let credential = + PaymentCredential::new(payment.to_echo(), serde_json::json!({"proof": "ok"})); + let mut request = Request { + jsonrpc: "2.0".into(), + id: Some(serde_json::json!(1)), + method: "tools/call".into(), + params: Some(serde_json::json!({"name": "compute"})), + meta: None, + }; + request.set_credential(&credential).unwrap(); + assert_eq!( + request.credential().unwrap().unwrap().payload["proof"], + "ok" + ); + + request.meta = Some(Map::from_iter([( + CREDENTIAL_META_KEY.into(), + serde_json::json!({"different": true}), + )])); + assert!(request.credential().is_err()); + } + + #[test] + fn operation_digest_ignores_request_id_and_payment_credential() { + let payment = payment_challenge(); + let credential = + PaymentCredential::new(payment.to_echo(), serde_json::json!({"proof": "ok"})); + let mut request = Request { + jsonrpc: "2.0".into(), + id: Some(serde_json::json!(1)), + method: "tools/call".into(), + params: Some(serde_json::json!({"name": "compute", "arguments": {"cpu": 1}})), + meta: None, + }; + let expected = request.operation_digest().unwrap(); + request.id = Some(serde_json::json!(2)); + request.set_credential(&credential).unwrap(); + assert_eq!(request.operation_digest().unwrap(), expected); + + request.params.as_mut().unwrap()["arguments"]["cpu"] = serde_json::json!(2); + assert_ne!(request.operation_digest().unwrap(), expected); + } + + #[test] + fn operation_digest_preserves_requests_without_params_on_paid_retry() { + let payment = payment_challenge(); + let credential = + PaymentCredential::new(payment.to_echo(), serde_json::json!({"proof": "ok"})); + let mut request = Request { + jsonrpc: "2.0".into(), + id: Some(serde_json::json!(1)), + method: "ping".into(), + params: None, + meta: None, + }; + let expected = request.operation_digest().unwrap(); + + request.set_credential(&credential).unwrap(); + + assert_eq!(request.operation_digest().unwrap(), expected); + let decoded = request.credential().unwrap().unwrap(); + assert_eq!(decoded.challenge.id, credential.challenge.id); + assert_eq!(decoded.payload, credential.payload); + } + + #[test] + fn error_and_receipt_follow_payment_auth_mcp_shape() { + let challenge = Challenge::try_from(&payment_challenge()).unwrap(); + let response = ErrorResponse::payment_required(serde_json::json!(7), vec![challenge]); + let value = serde_json::to_value(response).unwrap(); + assert_eq!(value["error"]["code"], PAYMENT_REQUIRED_CODE); + assert_eq!(value["error"]["data"]["httpStatus"], 402); + + let mut result = serde_json::json!({"content": []}); + let receipt_value = Receipt::success("solana", "tx-1", "challenge-1"); + attach_receipt(&mut result, &receipt_value).unwrap(); + assert_eq!( + receipt(&result).unwrap().unwrap().challenge_id, + "challenge-1" + ); + } + + #[test] + fn advertises_payment_capabilities_without_clobbering_others() { + let mut capabilities = serde_json::json!({"tools": {}}); + advertise( + &mut capabilities, + Capabilities::solana(["charge", "session"]), + ) + .unwrap(); + assert!(capabilities.get("tools").is_some()); + assert_eq!( + capabilities["experimental"]["payment"]["methods"]["solana"]["intents"][1], + "session" + ); + } +} diff --git a/rust/crates/kit/src/mpp/mod.rs b/rust/crates/kit/src/mpp/mod.rs index c30adf3e7..37603f321 100644 --- a/rust/crates/kit/src/mpp/mod.rs +++ b/rust/crates/kit/src/mpp/mod.rs @@ -39,6 +39,7 @@ pub mod error; pub mod expires; +pub mod mcp; pub mod program; pub mod protocol; pub mod store; diff --git a/rust/crates/kit/src/mpp/protocol/core/challenge.rs b/rust/crates/kit/src/mpp/protocol/core/challenge.rs index af4f7f60e..0dd6b45f3 100644 --- a/rust/crates/kit/src/mpp/protocol/core/challenge.rs +++ b/rust/crates/kit/src/mpp/protocol/core/challenge.rs @@ -139,6 +139,7 @@ impl PaymentChallenge { intent: self.intent.clone(), request: self.request.clone(), expires: self.expires.clone(), + description: self.description.clone(), digest: self.digest.clone(), opaque: self.opaque.clone(), } @@ -251,6 +252,8 @@ pub struct ChallengeEcho { #[serde(skip_serializing_if = "Option::is_none")] pub expires: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub digest: Option, #[serde(skip_serializing_if = "Option::is_none")] pub opaque: Option, diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 14ac6df73..1f5b74eb7 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -21,6 +21,7 @@ overrides: picomatch@2.3.1: 2.3.2 postcss: 8.5.23 vite: 8.0.16 + '@modelcontextprotocol/sdk': '>=1.31.0' fast-uri: '>=4.1.5' hono: '>=4.13.5' ip-address: '>=10.7.2' @@ -29,6 +30,7 @@ overrides: qs: '>=6.16.0' yaml: '>=2.8.3' body-parser: '>=2.3.0' + proxy-addr: '>=2.0.8' '@toon-format/toon': 2.3.1 importers: @@ -104,7 +106,7 @@ importers: version: 25.5.0 mppx: specifier: ^0.8.15 - version: 0.8.15(@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) + version: 0.8.15(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) surfpool-sdk: specifier: ^1.2.0 version: 1.2.0 @@ -131,7 +133,7 @@ importers: version: 6.10.0(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6) mppx: specifier: '>=0.8.15' - version: 0.8.15(@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) + version: 0.8.15(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)) devDependencies: '@solana/mpp': specifier: workspace:^ @@ -532,12 +534,6 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} - '@hono/node-server@2.0.11': - resolution: {integrity: sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==} - engines: {node: '>=20'} - peerDependencies: - hono: '>=4.13.5' - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -671,16 +667,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@modelcontextprotocol/server@2.0.0-alpha.4': resolution: {integrity: sha512-/KEo3ZJ50HlagHp0lz2vPgfBZFtXHu6zTBXT9XqPc+4O9i4+IbBVWKq/a9yAfv/ifp0u3+mRo8SUk5kMKzhN8A==} engines: {node: '>=20'} @@ -2330,20 +2316,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -2586,10 +2561,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2819,10 +2790,6 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2839,12 +2806,6 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express-rate-limit@8.3.1: - resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2862,9 +2823,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@4.2.1: - resolution: {integrity: sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==} - fastestsmallesttextencoderdecoder@1.0.22: resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} @@ -3069,10 +3027,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.7.2: - resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3266,9 +3220,6 @@ packages: node-notifier: optional: true - jose@6.2.1: - resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} - joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3297,12 +3248,6 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -3514,7 +3459,7 @@ packages: resolution: {integrity: sha512-+4jQRYB3AbgATfsZZAen7SxDC4miAPhUokTmBgda5OORZKvPnbWKAKHMHK1oMKsxWTVMYBwfgxmiJYAEe6I47g==} hasBin: true peerDependencies: - '@modelcontextprotocol/sdk': '>=1.25.0' + '@modelcontextprotocol/sdk': '>=1.31.0' elysia: '>=1' express: '>=5' hono: '>=4.13.5' @@ -3685,10 +3630,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -3731,8 +3672,8 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} punycode.js@2.3.1: @@ -3772,10 +3713,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -4315,11 +4252,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -4677,11 +4609,6 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@hono/node-server@2.0.11(hono@4.13.7)': - dependencies: - hono: 4.13.7 - optional: true - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4915,31 +4842,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 - '@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.4.3)': - dependencies: - '@hono/node-server': 2.0.11(hono@4.13.7) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.13.7 - jose: 6.2.1 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.1(zod@4.4.3) - optionalDependencies: - '@cfworker/json-schema': 4.1.1 - transitivePeerDependencies: - - supports-color - optional: true - '@modelcontextprotocol/server@2.0.0-alpha.4': dependencies: zod: 4.4.3 @@ -6611,11 +6513,6 @@ snapshots: acorn@8.16.0: {} - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - optional: true - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -6623,14 +6520,6 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 4.2.1 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - optional: true - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -6869,12 +6758,6 @@ snapshots: cookie@0.7.2: optional: true - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - optional: true - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7120,11 +7003,6 @@ snapshots: eventsource-parser@3.1.0: {} - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.1.0 - optional: true - execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -7150,12 +7028,6 @@ snapshots: jest-mock: 30.3.0 jest-util: 30.3.0 - express-rate-limit@8.3.1(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.7.2 - optional: true - express@5.2.1: dependencies: accepts: 2.0.0 @@ -7177,7 +7049,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 @@ -7204,9 +7076,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@4.2.1: - optional: true - fastestsmallesttextencoderdecoder@1.0.22: optional: true @@ -7430,9 +7299,6 @@ snapshots: inherits@2.0.4: {} - ip-address@10.7.2: - optional: true - ipaddr.js@1.9.1: optional: true @@ -7808,9 +7674,6 @@ snapshots: - supports-color - ts-node - jose@6.2.1: - optional: true - joycon@3.1.1: {} js-tokens@10.0.0: {} @@ -7829,12 +7692,6 @@ snapshots: json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: - optional: true - - json-schema-typed@8.0.2: - optional: true - json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -8011,7 +7868,7 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - mppx@0.8.15(@modelcontextprotocol/sdk@1.27.1(@cfworker/json-schema@4.1.1)(zod@4.4.3))(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)): + mppx@0.8.15(express@5.2.1)(hono@4.13.7)(typescript@5.9.3)(viem@2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3)): dependencies: '@stripe/stripe-js': 9.9.0 eventsource-parser: 3.1.0 @@ -8020,7 +7877,6 @@ snapshots: viem: 2.55.10(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@modelcontextprotocol/sdk': 1.27.1(@cfworker/json-schema@4.1.1)(zod@4.4.3) express: 5.2.1 hono: 4.13.7 transitivePeerDependencies: @@ -8162,9 +8018,6 @@ snapshots: pirates@4.0.7: {} - pkce-challenge@5.0.1: - optional: true - pkg-dir@4.2.0: dependencies: find-up: 4.1.0 @@ -8199,7 +8052,7 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -8236,9 +8089,6 @@ snapshots: require-directory@2.1.1: {} - require-from-string@2.0.2: - optional: true - requireindex@1.2.0: {} resolve-cwd@3.0.0: @@ -8807,11 +8657,6 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.1(zod@4.4.3): - dependencies: - zod: 4.4.3 - optional: true - zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/typescript/pnpm-workspace.yaml b/typescript/pnpm-workspace.yaml index 5959a73b3..5699d4cb2 100644 --- a/typescript/pnpm-workspace.yaml +++ b/typescript/pnpm-workspace.yaml @@ -33,6 +33,7 @@ overrides: vite: '8.0.16' # fast-uri/hono/ip-address floors below track advisories reached via # @modelcontextprotocol/sdk (see `pnpm why `); bump as new CVEs land. + '@modelcontextprotocol/sdk': '>=1.31.0' fast-uri: '>=4.1.5' hono: '>=4.13.5' ip-address: '>=10.7.2' @@ -41,6 +42,9 @@ overrides: qs: '>=6.16.0' yaml: '>=2.8.3' body-parser: '>=2.3.0' + # GHSA-jqcg-44mw-7w3h: forwarded-header denial of service, patched in 2.0.8. + # Reaches us via mppx > express. + proxy-addr: '>=2.0.8' # GHSA-p95v-992w-h6c3: prototype pollution decoding untrusted TOON, patched in # 2.3.1. Reaches us via packages/pay-kit > mppx > incur. Pinned exactly rather # than floored: `incur` expects the 2.x line, and a `>=2.3.1` override would