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/greptile.json b/greptile.json
new file mode 100644
index 000000000..1b8315f96
--- /dev/null
+++ b/greptile.json
@@ -0,0 +1,184 @@
+{
+ "autoReview": [],
+ "strictness": 1,
+ "customContext": {
+ "rules": [
+ {
+ "rule": "pay-kit#317 `solana-foundation/pay-kit@a58c78e:go/protocols/mpp/wire/challenge_test.go:56`: `now` was fixed at 1900, so the leap-second cases proved only that parsing succeeded, not the mapping to the last nanosecond of `:59` (https://github.com/solana-foundation/pay-kit/pull/317#discussion_r3982265139).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@efff41c:harness/protocol-runners/kotlin.json:3`: the manifest execs an installed Gradle distribution that no CI job builds, so on a clean checkout the runner cannot start (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253848).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@8c59ab9:harness/test/protocol-conformance.test.ts:195`: a spawn failure, a non-zero exit or empty stdout counted as the expected divergence, so a runner that never ran looked green.",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@2291822:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`: decoding with unknown keys ignored silently dropped the `hash` payload of `credential_with_source` (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253870).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@c4341ee:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:57-66`: the same lenient decoding dropped the challenge `description` (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253852).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#320 `solana-foundation/pay-kit@a55ee48:php/tests/PayCore/Rfc3339Test.php:48`: long-fraction vectors were checked with `format('c')`, which drops microseconds, so a parser that discards the fraction still passed (https://github.com/solana-foundation/pay-kit/pull/320#discussion_r3992887690).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#320 `solana-foundation/pay-kit@a55ee48:php/tests/PayCore/Rfc3339Test.php:88`: no case exercised the last-day-of-month rule for `23:59:60Z`, so deleting the check kept the suite green (https://github.com/solana-foundation/pay-kit/pull/320#discussion_r3992887696).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:271`: an empty `PAY_KIT_RPC_URL` overrode the network default with `\"\"`, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737040).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:277`: a zero or negative `PAY_KIT_MPP_EXPIRES_IN` was accepted, issuing already-expired challenges, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737053).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:308`: `yes`/`no`/`on`/`off` for `PAY_KIT_PREFLIGHT` raised at boot, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737067).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#340 `solana-foundation/pay-kit@b2638e1:ruby/lib/pay_kit/config.rb:268`: lowercase `pay_kit_*` variables were silently ignored, unlike the Python SDK it mirrors (https://github.com/solana-foundation/pay-kit/pull/340#discussion_r4095737071).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#15 `caliperforge/pay-kit@1f02e55:swift/Sources/mpp-protocol-runner/main.swift:74`: `challenge.parse` dropped `description` (https://github.com/caliperforge/pay-kit/pull/15#discussion_r4173474978).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#15 `caliperforge/pay-kit@1f02e55:swift/Sources/mpp-protocol-runner/main.swift:97`: `credential.format` required `transaction` for `type: \"transaction\"`, so the canonical `basic_credential` vector could not be formatted (https://github.com/caliperforge/pay-kit/pull/15#discussion_r4173474983).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#322 `solana-foundation/pay-kit@c35533e:swift/Sources/SolanaPayKit/Protocols/Mpp/Core/Models.swift:125`: a review asked to allow leap seconds only in June and December; RFC 3339 §5.7 and Appendix D do not restrict them, the UTC month-end check matches the Rust reference, and the finding was withdrawn (https://github.com/solana-foundation/pay-kit/pull/322#discussion_r4035505691).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#282 `solana-foundation/pay-kit@e2f2b1d:harness/src/protocol/vectors.ts:436`: `collectExpiresCases()` had no callers, though comments said the per-language tests used it (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3751682861).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#282 `solana-foundation/pay-kit@faf3250:typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts:449`: the test imported `../shared/rfc3339.js`, which did not exist at that SHA, so the whole file failed to load (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3960191945).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#282 `solana-foundation/pay-kit@faf3250:typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts:478`: the loop tested only `parseRfc3339` while the production guards still used `new Date(...)` and `Date.parse`, so the tests could pass without exercising the shipped code (https://github.com/solana-foundation/pay-kit/pull/282#discussion_r3960191963).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#313 `solana-foundation/pay-kit@a752d51:typescript/packages/mpp/src/shared/rfc3339.ts:38`: rejected `second = 60`, which RFC 3339 allows for a positive leap second (https://github.com/solana-foundation/pay-kit/pull/313#discussion_r3962113765).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#14 `caliperforge/pay-kit@208fbd7:harness/test/protocol-conformance.test.ts:179`: an expectation checked only that `description` was absent, so `result: {}` would also pass (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234527).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@66449a5:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:66`:
**Challenge description is dropped** When a valid header contains a top-level `description`, such as the canonical `full_challenge`, this response omits it. The parsed challenge no longer matches the expected object, and the basic-only runner test does not catch the difference. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253852).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@66449a5:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:65`:
**Plain opaque values fail parsing** The SDK treats `opaque` as a pass-through string. When a valid challenge contains a plain value such as `trace-123`, decoding it as base64url JSON throws, so the runner returns `parse_error` instead of the parsed challenge. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253859).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#13 `caliperforge/pay-kit@8c59ab9:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`:
**Hash payload is lost** The canonical `credential_with_source` case contains a valid `hash` payload. This code deserializes it while ignoring unknown fields, but the Kotlin payload type has no `hash` property. Formatting then emits an Authorization header without the hash value. (https://github.com/caliperforge/pay-kit/pull/13#discussion_r4166253870).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:78`:
**Opaque values are misparsed** When a valid Payment challenge includes `opaque`, the runner tries to decode it as base64url JSON even though the SDK and protocol adapter treat it as an opaque string. A value that is not JSON becomes a parse error; a JSON-bearing value is returned with the wrong type. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902439).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:85`:
**Missing request becomes valid** If a `credential.format` input omits the required `challenge.request`, the runner inserts an empty request and can return a successful Authorization header. This hides an invalid input and produces a header that does not represent the supplied challenge. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902463).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:77`:
**Empty optional values disappear** When a parsed challenge contains an explicitly empty `expires` or `digest`, these checks omit the field from the result. The adapter distinguishes a present empty value from an absent field, so the runner returns the wrong object for those headers. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902477).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#16 `caliperforge/pay-kit@00614e3:harness/kotlin-protocol-runner/src/main/kotlin/com/solana/paykit/protocolrunner/Main.kt:33`:
**Conformance skips recognized operations** Seven recognized operations always return fixed errors, and the Kotlin divergence tests assert those responses instead of exercising protocol behavior. A green Kotlin protocol job therefore does not verify encoding, challenge generation, or those header operations. Implement them or separate these gap checks from conformance coverage. (https://github.com/caliperforge/pay-kit/pull/16#discussion_r4186902484).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#14 `caliperforge/pay-kit@17dd9a2:harness/test/protocol-conformance.test.ts:212`:
**Formatter cases are skipped** This filter selects no successful `credential.format` vector and no `receipt.format` vector: neither operation is in `smokeOps`, and the Kotlin map has only a failing credential-format case. The credential formatter could fail every valid request without failing this suite, while the declared receipt-format gap gets no conformance check. Add a successful formatter case and track the receipt-format gap. (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234531).",
+ "scope": [
+ "**"
+ ]
+ },
+ {
+ "rule": "pay-kit#14 `caliperforge/pay-kit@b976cea:harness/test/protocol-conformance.test.ts:179`:
**Challenge fields go unchecked** For challenges with a description, this expectation checks only that `description` is absent. A response with `result: {}` would also pass, so the test would miss regressions in required challenge fields or the decoded request. Check those fields against the vector while allowing the known omission. (https://github.com/caliperforge/pay-kit/pull/14#discussion_r4173234527).",
+ "scope": [
+ "**"
+ ]
+ }
+ ],
+ "files": [
+ {
+ "path": "docs/paykit-interface.md",
+ "scope": [
+ "**"
+ ]
+ }
+ ]
+ }
+}
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..46efc64f4 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,80 @@ 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).
-//
-// 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> = {};
+// 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).
+
+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/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