diff --git a/e2e/clients/mcp-typescript/index.ts b/e2e/clients/mcp-typescript/index.ts index 9029b796a5..eb832cf0c4 100644 --- a/e2e/clients/mcp-typescript/index.ts +++ b/e2e/clients/mcp-typescript/index.ts @@ -9,8 +9,17 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { ExactEvmScheme, type ExactEvmSchemeOptions } from "@x402/evm/exact/client"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; +import { toClientEvmSigner } from "@x402/evm"; +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from "@x402/core/http"; import { createx402MCPClient } from "@x402/mcp"; +import { createPublicClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; +import { base, baseSepolia } from "viem/chains"; interface E2EResult { success: boolean; @@ -20,9 +29,24 @@ interface E2EResult { error?: string; } +interface RequestResult { + success: boolean; + data: any; + status_code: number; + payment_response?: any; +} + const serverUrl = process.env.RESOURCE_SERVER_URL as string; const endpointPath = process.env.ENDPOINT_PATH as string; // tool name, e.g. "get_weather" const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const evmNetwork = process.env.EVM_NETWORK || "eip155:84532"; +const evmChain = evmNetwork === "eip155:8453" ? base : baseSepolia; +const channelSalt = process.env.CHANNEL_SALT as `0x${string}` | undefined; +const batchSettlementPhase = process.env.BATCH_SETTLEMENT_PHASE as + | "initial" + | "recovery-refund" + | "full" + | undefined; if (!serverUrl || !endpointPath || !evmPrivateKey) { const result: E2EResult = { @@ -34,17 +58,36 @@ if (!serverUrl || !endpointPath || !evmPrivateKey) { } async function main(): Promise { - const evmSigner = privateKeyToAccount(evmPrivateKey); + const evmAccount = privateKeyToAccount(evmPrivateKey); + const publicClient = createPublicClient({ + chain: evmChain, + transport: http(process.env.EVM_RPC_URL), + }); + const evmSigner = toClientEvmSigner(evmAccount, publicClient); const evmSchemeOptions: ExactEvmSchemeOptions | undefined = process.env.EVM_RPC_URL ? { rpcUrl: process.env.EVM_RPC_URL } : undefined; + const voucherSignerKey = process.env.EVM_VOUCHER_SIGNER_PRIVATE_KEY as + | `0x${string}` + | undefined; + const voucherSigner = voucherSignerKey + ? toClientEvmSigner(privateKeyToAccount(voucherSignerKey), publicClient) + : undefined; + const batchSettlementOptions = + channelSalt || voucherSigner + ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } + : undefined; + const batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); const x402Mcp = createx402MCPClient({ name: "x402-mcp-e2e-client", version: "1.0.0", - schemes: [{ network: "eip155:84532", client: new ExactEvmScheme(evmSigner, evmSchemeOptions) }], + schemes: [ + { network: "eip155:*", client: new ExactEvmScheme(evmAccount, evmSchemeOptions) }, + { network: "eip155:*", client: batchSettlementScheme }, + ], autoPayment: true, - onPaymentRequested: async () => true, // Auto-approve all payments for e2e + onPaymentRequested: async () => true, }); try { @@ -54,40 +97,143 @@ async function main(): Promise { // Call the tool specified by ENDPOINT_PATH with test arguments const toolArgs = { city: "San Francisco" }; - const result = await x402Mcp.callTool(endpointPath, toolArgs); - // Extract text content from the result - let data: any = null; - if (result.content && result.content.length > 0) { - const firstContent = result.content[0]; + function parseToolData(result: Awaited>): any { + const firstContent = result.content?.[0]; + if (!firstContent) { + return null; + } if (firstContent.type === "text" && typeof firstContent.text === "string") { try { - data = JSON.parse(firstContent.text as string); + return JSON.parse(firstContent.text); } catch { - data = { text: firstContent.text }; + return { text: firstContent.text }; } - } else { - data = firstContent; } + return firstContent; } - // Build e2e result - const e2eResult: E2EResult = { - success: true, - data: data, - status_code: 200, - payment_response: result.paymentResponse - ? { - success: result.paymentResponse.success, - transaction: result.paymentResponse.transaction, - network: result.paymentResponse.network, - } - : undefined, - }; + async function issueRequest(): Promise { + const result = await x402Mcp.callTool(endpointPath, toolArgs); + return { + success: result.paymentResponse?.success ?? !result.isError, + data: parseToolData(result), + status_code: result.isError ? 402 : 200, + payment_response: result.paymentResponse, + }; + } - console.log(JSON.stringify(e2eResult)); - await x402Mcp.close(); - process.exit(0); + function aggregateBatchResult( + phase: "initial" | "recovery-refund" | "full", + results: RequestResult[], + details: Record, + ): E2EResult { + const last = results[results.length - 1]!; + return { + success: results.every(result => result.success), + data: { + batchSettlement: { + phase, + requests: results, + ...details, + }, + }, + status_code: last.status_code, + payment_response: last.payment_response, + }; + } + + async function mcpRefundFetch(_input: RequestInfo | URL, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + const paymentHeader = headers.get("PAYMENT-SIGNATURE") ?? headers.get("X-PAYMENT"); + if (!paymentHeader) { + const paymentRequired = await x402Mcp.getToolPaymentRequirements(endpointPath, toolArgs); + if (!paymentRequired) { + return new Response("", { status: 200 }); + } + return new Response("", { + status: 402, + headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(paymentRequired) }, + }); + } + + const paymentPayload = decodePaymentSignatureHeader(paymentHeader); + const result = await x402Mcp.callToolWithPayment(endpointPath, toolArgs, paymentPayload); + if (result.paymentResponse) { + return new Response(JSON.stringify(parseToolData(result)), { + status: 200, + headers: { "PAYMENT-RESPONSE": encodePaymentResponseHeader(result.paymentResponse) }, + }); + } + + const firstContent = result.content?.[0]; + if (result.isError && firstContent?.type === "text" && typeof firstContent.text === "string") { + const paymentRequired = JSON.parse(firstContent.text); + return new Response("", { + status: 402, + headers: { "PAYMENT-REQUIRED": encodePaymentRequiredHeader(paymentRequired) }, + }); + } + + return new Response(JSON.stringify(parseToolData(result)), { status: result.isError ? 500 : 200 }); + } + + if (!batchSettlementPhase) { + const result = await issueRequest(); + console.log(JSON.stringify(result)); + await x402Mcp.close(); + process.exit(0); + } + + if (batchSettlementPhase === "initial") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + console.log(JSON.stringify(aggregateBatchResult("initial", [deposit, voucher], { deposit, voucher }))); + await x402Mcp.close(); + process.exit(0); + } + + if (batchSettlementPhase === "recovery-refund") { + const recoveryVoucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(`mcp://tool/${endpointPath}`, { + fetch: mcpRefundFetch, + }); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log( + JSON.stringify( + aggregateBatchResult("recovery-refund", [recoveryVoucher, refund], { + recoveryVoucher, + refund, + }), + ), + ); + await x402Mcp.close(); + process.exit(0); + } + + if (batchSettlementPhase === "full") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(`mcp://tool/${endpointPath}`, { + fetch: mcpRefundFetch, + }); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log(JSON.stringify(aggregateBatchResult("full", [deposit, voucher, refund], { deposit, voucher, refund }))); + await x402Mcp.close(); + process.exit(0); + } + + throw new Error(`Unknown BATCH_SETTLEMENT_PHASE: ${batchSettlementPhase}`); } catch (error: any) { const e2eResult: E2EResult = { success: false, diff --git a/e2e/clients/mcp-typescript/test.config.json b/e2e/clients/mcp-typescript/test.config.json index 66ff57dd80..fa9136f6fd 100644 --- a/e2e/clients/mcp-typescript/test.config.json +++ b/e2e/clients/mcp-typescript/test.config.json @@ -10,7 +10,8 @@ 2 ], "schemes": [ - "exact" + "exact", + "batch-settlement" ], "evm": { "assetTransferMethods": [ @@ -24,6 +25,10 @@ "RESOURCE_SERVER_URL", "ENDPOINT_PATH" ], - "optional": [] + "optional": [ + "CHANNEL_SALT", + "BATCH_SETTLEMENT_PHASE", + "EVM_VOUCHER_SIGNER_PRIVATE_KEY" + ] } } diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index 8111735b0b..4455af1e17 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -2305,6 +2305,9 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) zod: specifier: ^3.24.4 version: 3.25.71 @@ -6903,7 +6906,7 @@ packages: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} bufferutil@4.0.9: - resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==, tarball: https://artifactory.cbhq.net:8081/artifactory/api/npm/cb-npm-master/bufferutil/-/bufferutil-4.0.9.tgz} + resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} engines: {node: '>=6.14.2'} bundle-require@5.1.0: @@ -10363,7 +10366,7 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==, tarball: https://artifactory.cbhq.net:8081/artifactory/api/npm/cb-npm-master/utf-8-validate/-/utf-8-validate-5.0.10.tgz} + resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} engines: {node: '>=6.14.2'} utf8@3.0.0: diff --git a/e2e/servers/mcp-typescript/index.ts b/e2e/servers/mcp-typescript/index.ts index 25b7b080b3..d09dad88ba 100644 --- a/e2e/servers/mcp-typescript/index.ts +++ b/e2e/servers/mcp-typescript/index.ts @@ -8,15 +8,18 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; import { createPaymentWrapper, x402ResourceServer } from "@x402/mcp"; import { HTTPFacilitatorClient } from "@x402/core/server"; import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; import express from "express"; +import { privateKeyToAccount } from "viem/accounts"; import { z } from "zod"; const PORT = process.env.PORT || "4022"; const EVM_NETWORK = (process.env.EVM_NETWORK || "eip155:84532") as `${string}:${string}`; const EVM_PAYEE_ADDRESS = process.env.EVM_PAYEE_ADDRESS as `0x${string}`; +const EVM_PERMIT2_ASSET = process.env.EVM_PERMIT2_ASSET as `0x${string}`; const facilitatorUrl = process.env.FACILITATOR_URL; if (!EVM_PAYEE_ADDRESS) { @@ -39,6 +42,14 @@ function getWeatherData(city: string): { city: string; weather: string; temperat return { city, weather, temperature }; } +function getBatchSettlementData(method: string): { message: string; timestamp: string; method: string } { + return { + message: "Batch-settlement MCP tool accessed successfully", + timestamp: new Date().toISOString(), + method, + }; +} + async function main(): Promise { // Step 1: Create standard MCP server const mcpServer = new McpServer({ @@ -49,7 +60,19 @@ async function main(): Promise { // Step 2: Set up x402 resource server for payment handling const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); const resourceServer = new x402ResourceServer(facilitatorClient); - resourceServer.register("eip155:84532", new ExactEvmScheme()); + resourceServer.register("eip155:*", new ExactEvmScheme()); + const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; + const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; + resourceServer.register( + "eip155:*", + new BatchSettlementEvmScheme(EVM_PAYEE_ADDRESS, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + }), + ); await resourceServer.initialize(); // Step 3: Build payment requirements @@ -60,6 +83,26 @@ async function main(): Promise { price: "$0.001", extra: { name: "USDC", version: "2" }, }); + const batchEip3009Accepts = await resourceServer.buildPaymentRequirements({ + scheme: "batch-settlement", + network: EVM_NETWORK, + payTo: EVM_PAYEE_ADDRESS, + price: "$0.001", + }); + const batchPermit2Accepts = await resourceServer.buildPaymentRequirements({ + scheme: "batch-settlement", + network: EVM_NETWORK, + payTo: EVM_PAYEE_ADDRESS, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + name: EVM_NETWORK === "eip155:84532" ? "USDC" : "USD Coin", + version: "2", + }, + }, + }); // Step 4: Declare bazaar discovery extension for the weather tool const weatherExtensions = declareDiscoveryExtension({ @@ -74,6 +117,24 @@ async function main(): Promise { required: ["city"], }, }); + const batchEip3009Extensions = declareDiscoveryExtension({ + toolName: "batch_settlement_eip3009", + description: "Batch-settlement EIP-3009 MCP tool. Requires payment of $0.001.", + transport: "sse", + inputSchema: { + type: "object", + properties: {}, + }, + }); + const batchPermit2Extensions = declareDiscoveryExtension({ + toolName: "batch_settlement_permit2", + description: "Batch-settlement Permit2 MCP tool. Requires payment of $0.001.", + transport: "sse", + inputSchema: { + type: "object", + properties: {}, + }, + }); // Step 5: Create payment wrapper with extensions const paidWeather = createPaymentWrapper(resourceServer, { @@ -81,6 +142,22 @@ async function main(): Promise { resource: { url: "mcp://tool/get_weather", description: "Get current weather for a city" }, extensions: weatherExtensions, }); + const paidBatchEip3009 = createPaymentWrapper(resourceServer, { + accepts: batchEip3009Accepts, + resource: { + url: "mcp://tool/batch_settlement_eip3009", + description: "Batch-settlement EIP-3009 MCP tool", + }, + extensions: batchEip3009Extensions, + }); + const paidBatchPermit2 = createPaymentWrapper(resourceServer, { + accepts: batchPermit2Accepts, + resource: { + url: "mcp://tool/batch_settlement_permit2", + description: "Batch-settlement Permit2 MCP tool", + }, + extensions: batchPermit2Extensions, + }); // Step 6: Register tools mcpServer.tool( @@ -97,6 +174,34 @@ async function main(): Promise { })), ); + mcpServer.tool( + "batch_settlement_eip3009", + "Batch-settlement EIP-3009 tool. Requires payment of $0.001.", + {}, + paidBatchEip3009(async () => ({ + content: [ + { + type: "text" as const, + text: JSON.stringify(getBatchSettlementData("batch-settlement-eip3009"), null, 2), + }, + ], + })), + ); + + mcpServer.tool( + "batch_settlement_permit2", + "Batch-settlement Permit2 tool. Requires payment of $0.001.", + {}, + paidBatchPermit2(async () => ({ + content: [ + { + type: "text" as const, + text: JSON.stringify(getBatchSettlementData("batch-settlement-permit2"), null, 2), + }, + ], + })), + ); + // Free tool for basic connectivity check mcpServer.tool("ping", "A free health check tool", {}, async () => ({ content: [{ type: "text", text: "pong" }], @@ -126,7 +231,15 @@ async function main(): Promise { }); app.get("/health", (_, res) => { - res.json({ status: "ok", tools: ["get_weather (paid: $0.001)", "ping (free)"] }); + res.json({ + status: "ok", + tools: [ + "get_weather (paid: $0.001)", + "batch_settlement_eip3009 (paid: $0.001)", + "batch_settlement_permit2 (paid: $0.001)", + "ping (free)", + ], + }); }); app.post("/close", (_, res) => { diff --git a/e2e/servers/mcp-typescript/package.json b/e2e/servers/mcp-typescript/package.json index 60c6f1014e..d23d5b042e 100644 --- a/e2e/servers/mcp-typescript/package.json +++ b/e2e/servers/mcp-typescript/package.json @@ -16,6 +16,7 @@ "@x402/extensions": "workspace:*", "@x402/mcp": "workspace:*", "express": "^4.18.2", + "viem": "^2.48.11", "zod": "^3.24.4" }, "devDependencies": { @@ -33,4 +34,4 @@ "tsx": "^4.21.0", "typescript": "^5.7.3" } -} +} \ No newline at end of file diff --git a/e2e/servers/mcp-typescript/test.config.json b/e2e/servers/mcp-typescript/test.config.json index 5b83d8b598..bae1edfd01 100644 --- a/e2e/servers/mcp-typescript/test.config.json +++ b/e2e/servers/mcp-typescript/test.config.json @@ -19,6 +19,28 @@ "scheme": "exact", "assetTransferMethod": "eip3009" }, + { + "path": "batch_settlement_eip3009", + "method": "tool", + "toolName": "batch_settlement_eip3009", + "mcpTransport": "sse", + "description": "Batch-settlement EIP-3009 tool via MCP transport", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" + }, + { + "path": "batch_settlement_permit2", + "method": "tool", + "toolName": "batch_settlement_permit2", + "mcpTransport": "sse", + "description": "Batch-settlement Permit2 tool via MCP transport", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2" + }, { "path": "/health", "method": "GET", @@ -38,6 +60,9 @@ "EVM_PAYEE_ADDRESS", "FACILITATOR_URL" ], - "optional": [] + "optional": [ + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", + "EVM_PERMIT2_ASSET" + ] } } diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 71583637e8..7134ab376d 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1146,7 +1146,7 @@ importers: version: link:../../../../typescript/packages/mechanisms/svm axios: specifier: ^1.13.2 - version: 1.13.4 + version: 1.13.2 dotenv: specifier: ^16.4.7 version: 16.6.1 diff --git a/typescript/.changeset/late-meals-win.md b/typescript/.changeset/late-meals-win.md new file mode 100644 index 0000000000..1f4882aa55 --- /dev/null +++ b/typescript/.changeset/late-meals-win.md @@ -0,0 +1,5 @@ +--- +'@x402/mcp': minor +--- + +Implemented missing hook primitives needed for batch-settlement aligning with http transport diff --git a/typescript/.changeset/spotty-hounds-raise.md b/typescript/.changeset/spotty-hounds-raise.md new file mode 100644 index 0000000000..72ebee918b --- /dev/null +++ b/typescript/.changeset/spotty-hounds-raise.md @@ -0,0 +1,5 @@ +--- +'@x402/core': patch +--- + +Allow paymentPayload.accepted.extra to include additive client fields, while all server-declared fields still have to match diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index 01965382a1..6913d08876 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -1256,9 +1256,10 @@ export class x402ResourceServer { ): PaymentRequirements | undefined { switch (paymentPayload.x402Version) { case 2: - // For v2, match by accepted requirements + // For v2, all server-declared requirements must match. + // The client may include additive scheme-specific metadata under `accepted.extra`. return availableRequirements.find(paymentRequirements => - deepEqual(paymentRequirements, paymentPayload.accepted), + paymentRequirementsMatchAccepted(paymentRequirements, paymentPayload.accepted), ); case 1: // For v1, match by scheme and network @@ -1516,4 +1517,59 @@ export class x402ResourceServer { } } +/** + * Returns whether a client-selected requirement satisfies a server-advertised requirement. + * + * Core payment terms and all server-declared `extra` fields must match exactly, + * but clients may include additive scheme-specific metadata under `accepted.extra`. + * + * @param required - Server-advertised payment requirement. + * @param accepted - Client-selected payment requirement from the payment payload. + * @returns True when `accepted` preserves every server-declared requirement. + */ +function paymentRequirementsMatchAccepted( + required: PaymentRequirements, + accepted: PaymentRequirements, +): boolean { + const { extra: requiredExtra, ...requiredCore } = required; + const { extra: acceptedExtra, ...acceptedCore } = accepted; + + if (!deepEqual(requiredCore, acceptedCore)) { + return false; + } + + if (requiredExtra === undefined) { + return true; + } + + return objectContainsSubset(requiredExtra, acceptedExtra); +} + +/** + * Recursively checks that `actual` contains every field and value from `expected`. + * Object values may contain additional fields; arrays and primitives must match exactly. + * + * @param expected - Required subset. + * @param actual - Candidate object. + * @returns True when `actual` contains `expected`. + */ +function objectContainsSubset(expected: unknown, actual: unknown): boolean { + if (expected === null || typeof expected !== "object" || Array.isArray(expected)) { + return deepEqual(expected, actual); + } + + if (actual === null || typeof actual !== "object" || Array.isArray(actual)) { + return false; + } + + const actualRecord = actual as Record; + return Object.entries(expected as Record).every(([key, value]) => { + const hasActualKey = Object.prototype.hasOwnProperty.call(actualRecord, key); + if (!hasActualKey) { + return value === undefined; + } + return objectContainsSubset(value, actualRecord[key]); + }); +} + export default x402ResourceServer; diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index 24a1e9c60e..8dbb3b591f 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -1535,7 +1535,7 @@ describe("x402ResourceServer", () => { }); describe("findMatchingRequirements", () => { - it("should match v2 requirements by deep equality", () => { + it("should match v2 requirements when server-declared terms are unchanged", () => { const server = new x402ResourceServer(); const req1 = buildPaymentRequirements({ @@ -1562,6 +1562,128 @@ describe("x402ResourceServer", () => { expect(result).toEqual(req1); }); + it("should match v2 requirements with additive accepted.extra fields", () => { + const server = new x402ResourceServer(); + + const req = buildPaymentRequirements({ + scheme: "batch-settlement", + network: "eip155:8453" as Network, + amount: "1000000", + asset: "USDC", + extra: { + name: "USDC", + version: "2", + nested: { required: true }, + }, + }); + + const payload = buildPaymentPayload({ + x402Version: 2, + accepted: { + ...req, + extra: { + ...req.extra, + nested: { required: true, clientOnly: "ok" }, + channelState: { chargedCumulativeAmount: "2000" }, + }, + }, + }); + + const result = server.findMatchingRequirements([req], payload); + + expect(result).toEqual(req); + }); + + it("should match v2 requirements when server extra has undefined fields omitted by transport", () => { + const server = new x402ResourceServer(); + + const req = buildPaymentRequirements({ + scheme: "batch-settlement", + network: "eip155:8453" as Network, + amount: "1000000", + asset: "USDC", + extra: { + name: "USDC", + version: "2", + assetTransferMethod: undefined, + }, + }); + + const payload = buildPaymentPayload({ + x402Version: 2, + accepted: { + ...req, + extra: { + name: "USDC", + version: "2", + }, + }, + }); + + const result = server.findMatchingRequirements([req], payload); + + expect(result).toEqual(req); + }); + + it("should not match v2 requirements when accepted.extra overwrites server fields", () => { + const server = new x402ResourceServer(); + + const req = buildPaymentRequirements({ + scheme: "batch-settlement", + network: "eip155:8453" as Network, + amount: "1000000", + asset: "USDC", + extra: { + name: "USDC", + version: "2", + }, + }); + + const payload = buildPaymentPayload({ + x402Version: 2, + accepted: { + ...req, + extra: { + ...req.extra, + version: "3", + }, + }, + }); + + const result = server.findMatchingRequirements([req], payload); + + expect(result).toBeUndefined(); + }); + + it("should not match v2 requirements when accepted.extra omits server fields", () => { + const server = new x402ResourceServer(); + + const req = buildPaymentRequirements({ + scheme: "batch-settlement", + network: "eip155:8453" as Network, + amount: "1000000", + asset: "USDC", + extra: { + name: "USDC", + version: "2", + }, + }); + + const payload = buildPaymentPayload({ + x402Version: 2, + accepted: { + ...req, + extra: { + name: "USDC", + }, + }, + }); + + const result = server.findMatchingRequirements([req], payload); + + expect(result).toBeUndefined(); + }); + it("should match v1 requirements by scheme and network", () => { const server = new x402ResourceServer(); diff --git a/typescript/packages/mcp/src/client/x402MCPClient.ts b/typescript/packages/mcp/src/client/x402MCPClient.ts index 2b0a8def77..09b5690210 100644 --- a/typescript/packages/mcp/src/client/x402MCPClient.ts +++ b/typescript/packages/mcp/src/client/x402MCPClient.ts @@ -599,6 +599,69 @@ export class x402MCPClient { }); } + const paymentRequired = this.extractPaymentRequiredFromResult(result); + const recoveryResult = paymentPayload.accepted + ? await this._paymentClient.handlePaymentResponse({ + paymentPayload, + requirements: paymentPayload.accepted, + ...(paymentResponse ? { settleResponse: paymentResponse } : {}), + ...(paymentRequired ? { paymentRequired } : {}), + }) + : undefined; + + // A paid attempt can return a corrective 402. Scheme hooks recover local + // state from it, then we retry once with a fresh payload from that response. + if (recoveryResult?.recovered && paymentRequired) { + const freshPayload = await this._paymentClient.createPaymentPayload(paymentRequired); + const retryCallParams = { + name, + arguments: args, + _meta: { + [MCP_PAYMENT_META_KEY]: freshPayload, + }, + }; + const retryResult = await this.mcpClient.callTool(retryCallParams, undefined, options); + + if (!isMCPCallToolResult(retryResult)) { + throw new Error("Invalid MCP tool result: missing content array"); + } + + const retryResultWithMeta: MCPResultWithMeta = { + content: retryResult.content, + isError: retryResult.isError, + _meta: retryResult._meta, + }; + const retryPaymentResponse = extractPaymentResponseFromMeta(retryResultWithMeta); + + for (const hook of this.afterPaymentHooks) { + await hook({ + toolName: name, + paymentPayload: freshPayload, + result: retryResultWithMeta, + settleResponse: retryPaymentResponse, + }); + } + + const retryCorrectivePaymentRequired = this.extractPaymentRequiredFromResult(retryResult); + if (freshPayload.accepted) { + await this._paymentClient.handlePaymentResponse({ + paymentPayload: freshPayload, + requirements: freshPayload.accepted, + ...(retryPaymentResponse ? { settleResponse: retryPaymentResponse } : {}), + ...(retryCorrectivePaymentRequired + ? { paymentRequired: retryCorrectivePaymentRequired } + : {}), + }); + } + + return { + content: retryResult.content, + isError: retryResult.isError, + paymentResponse: retryPaymentResponse ?? undefined, + paymentMade: true, + }; + } + // Forward original MCP response content as-is return { content: result.content, diff --git a/typescript/packages/mcp/src/server/paymentWrapper.ts b/typescript/packages/mcp/src/server/paymentWrapper.ts index 2070efe525..14ff1a4d93 100644 --- a/typescript/packages/mcp/src/server/paymentWrapper.ts +++ b/typescript/packages/mcp/src/server/paymentWrapper.ts @@ -5,7 +5,7 @@ * Use createPaymentWrapper to wrap tool handlers with payment verification and settlement. */ -import type { PaymentRequirements } from "@x402/core/types"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; import { x402ResourceServer } from "@x402/core/server"; import type { @@ -102,6 +102,13 @@ export interface ToolResult { isError?: boolean; } +interface MCPPaymentTransportContext { + toolName: string; + arguments: Record; + meta?: Record; + result?: ToolResult | WrappedToolResult; +} + /** * Handler function type for tools to be wrapped with payment. */ @@ -187,6 +194,11 @@ export function createPaymentWrapper( arguments: args, meta: _meta, }; + const transportContext: MCPPaymentTransportContext = { + toolName, + arguments: args, + meta: _meta, + }; // Extract payment from _meta if present const paymentPayload = extractPaymentFromMeta({ @@ -202,6 +214,7 @@ export function createPaymentWrapper( toolName, config, "Payment required to access this tool", + transportContext, ); } @@ -216,6 +229,7 @@ export function createPaymentWrapper( resourceInfoForMatch, undefined, config.extensions, + transportContext, ); const paymentRequirements = resourceServer.findMatchingRequirements( paymentRequiredForMatch.accepts, @@ -228,6 +242,7 @@ export function createPaymentWrapper( toolName, config, "No matching payment requirements found", + transportContext, ); } @@ -236,6 +251,7 @@ export function createPaymentWrapper( paymentPayload, paymentRequirements, extMap, + transportContext, ); if (!verifyResult.isValid) { @@ -244,6 +260,8 @@ export function createPaymentWrapper( toolName, config, verifyResult.invalidReason || "Payment verification failed", + transportContext, + paymentPayload, ); } @@ -254,6 +272,26 @@ export function createPaymentWrapper( paymentRequirements, paymentPayload, }; + const cancellationDispatcher = resourceServer.createPaymentCancellationDispatcher( + paymentPayload, + paymentRequirements, + extMap, + transportContext, + ); + + if (verifyResult.skipHandler) { + return settlePaymentResult( + resourceServer, + toolName, + config, + hookContext, + paymentPayload, + paymentRequirements, + extMap, + transportContext, + createSkipHandlerResult(verifyResult.skipHandler.body), + ); + } // Run onBeforeExecution hook if present if (config.hooks?.onBeforeExecution) { @@ -264,12 +302,23 @@ export function createPaymentWrapper( toolName, config, "Execution blocked by hook", + transportContext, ); } } // Execute the tool handler - const result = await handler(args, context); + let result: ToolResult; + try { + result = await handler(args, context); + } catch (error) { + await cancellationDispatcher.cancel({ + reason: "handler_threw", + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + transportContext.result = result; // Build after execution context const afterExecContext: AfterExecutionContext = { @@ -284,42 +333,102 @@ export function createPaymentWrapper( // If the tool handler returned an error, don't proceed to settlement if (result.isError) { + await cancellationDispatcher.cancel({ reason: "handler_failed" }); return result; } - // Settle the payment - try { - const settleResult = await resourceServer.settlePayment( - paymentPayload, - paymentRequirements, - extMap, - ); + return settlePaymentResult( + resourceServer, + toolName, + config, + hookContext, + paymentPayload, + paymentRequirements, + extMap, + transportContext, + result, + ); + }; + }; +} - // Run onAfterSettlement hook if present - if (config.hooks?.onAfterSettlement) { - const settlementContext: SettlementContext = { - ...hookContext, - settlement: settleResult, - }; - await config.hooks.onAfterSettlement(settlementContext); - } +/** + * Builds a tool result from the verifier's `skipHandler` body when the handler is skipped but settlement still runs. + * + * @param body - Verifier-supplied body to expose as text; objects become JSON text and optional structured content. + * @returns MCP-compatible wrapped result with text content and optional structured content. + */ +function createSkipHandlerResult(body: unknown): WrappedToolResult { + const result: WrappedToolResult = { + content: [ + { + type: "text", + text: typeof body === "string" ? body : JSON.stringify(body ?? {}), + }, + ], + }; - // Return full result (preserving structuredContent, etc.) with payment response in _meta - return { - ...result, - _meta: { [MCP_PAYMENT_RESPONSE_META_KEY]: settleResult }, - }; - } catch (settleError) { - // Settlement failed after execution - return 402 error - return createSettlementFailedResult( - resourceServer, - toolName, - config, - settleError instanceof Error ? settleError.message : "Settlement failed", - ); - } + if (typeof body === "object" && body !== null && !Array.isArray(body)) { + result.structuredContent = body as Record; + } + + return result; +} + +/** + * Settles payment after tool execution and attaches settlement metadata to the tool result. + * + * @param resourceServer - x402 resource server used to perform settlement. + * @param toolName - Name of the MCP tool that produced the result. + * @param config - Payment wrapper configuration (e.g. settlement hooks). + * @param hookContext - Hook context for the current server invocation. + * @param paymentPayload - Verified payment payload from the client. + * @param paymentRequirements - Payment requirements satisfied for this call. + * @param extMap - Extension map forwarded to the settlement call. + * @param transportContext - MCP payment transport context for this invocation. + * @param result - Successful tool result to merge settlement metadata into. + * @returns Tool result including `_meta` with settlement details, or a settlement-failure error result. + */ +async function settlePaymentResult( + resourceServer: x402ResourceServer, + toolName: string, + config: PaymentWrapperConfig, + hookContext: ServerHookContext, + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + extMap: Record, + transportContext: MCPPaymentTransportContext, + result: WrappedToolResult | ToolResult, +): Promise { + try { + const settleResult = await resourceServer.settlePayment( + paymentPayload, + paymentRequirements, + extMap, + transportContext, + ); + + if (config.hooks?.onAfterSettlement) { + const settlementContext: SettlementContext = { + ...hookContext, + settlement: settleResult, + }; + await config.hooks.onAfterSettlement(settlementContext); + } + + return { + ...result, + _meta: { [MCP_PAYMENT_RESPONSE_META_KEY]: settleResult }, }; - }; + } catch (settleError) { + return createSettlementFailedResult( + resourceServer, + toolName, + config, + settleError instanceof Error ? settleError.message : "Settlement failed", + transportContext, + ); + } } /** @@ -329,6 +438,8 @@ export function createPaymentWrapper( * @param toolName - Name of the tool for resource URL * @param config - Payment wrapper configuration * @param errorMessage - Error message describing why payment is required + * @param transportContext - Optional MCP payment transport context for the current tool call. + * @param paymentPayload - Optional client payment payload to include when building the 402 response. * @returns Promise resolving to structured 402 error result with payment requirements */ async function createPaymentRequiredResult( @@ -336,6 +447,8 @@ async function createPaymentRequiredResult( toolName: string, config: PaymentWrapperConfig, errorMessage: string, + transportContext?: MCPPaymentTransportContext, + paymentPayload?: PaymentPayload, ): Promise { const resourceInfo = { url: createToolResourceUrl(toolName, config.resource?.url), @@ -348,6 +461,8 @@ async function createPaymentRequiredResult( resourceInfo, errorMessage, config.extensions, + transportContext, + paymentPayload, ); return { @@ -369,6 +484,7 @@ async function createPaymentRequiredResult( * @param toolName - Name of the tool for resource URL * @param config - Payment wrapper configuration * @param errorMessage - Error message describing the settlement failure + * @param transportContext - Optional MCP payment transport context forwarded into the error result. * @returns Promise resolving to structured 402 error result with settlement failure info */ async function createSettlementFailedResult( @@ -376,6 +492,7 @@ async function createSettlementFailedResult( toolName: string, config: PaymentWrapperConfig, errorMessage: string, + transportContext?: MCPPaymentTransportContext, ): Promise { // Per spec R5, settlement failure follows the same format as payment required // (structuredContent + content[0].text + isError: true) with the error message @@ -387,5 +504,6 @@ async function createSettlementFailedResult( toolName, config, `Payment settlement failed: ${errorMessage}`, + transportContext, ); } diff --git a/typescript/packages/mcp/test/unit/client.test.ts b/typescript/packages/mcp/test/unit/client.test.ts index 337d7577ae..36e9908139 100644 --- a/typescript/packages/mcp/test/unit/client.test.ts +++ b/typescript/packages/mcp/test/unit/client.test.ts @@ -21,6 +21,7 @@ interface MockMCPClient { interface MockPaymentClient { createPaymentPayload: ReturnType; + handlePaymentResponse: ReturnType; register: ReturnType; registerV1: ReturnType; } @@ -52,6 +53,7 @@ const mockPaymentRequired: PaymentRequired = { const mockPaymentPayload: PaymentPayload = { x402Version: 2, + accepted: mockPaymentRequired.accepts[0], payload: { signature: "0x123", authorization: { @@ -180,6 +182,7 @@ function createMockMCPClient(): MockMCPClient { function createMockPaymentClient(): MockPaymentClient { return { createPaymentPayload: vi.fn().mockResolvedValue(mockPaymentPayload), + handlePaymentResponse: vi.fn().mockResolvedValue(undefined), register: vi.fn().mockReturnThis(), registerV1: vi.fn().mockReturnThis(), }; @@ -403,6 +406,73 @@ describe("x402MCPClient", () => { ); }); + it("should call core payment response hooks with settlement metadata", async () => { + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(mockPaymentRequired)) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "result" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + + await client.callTool("paid_tool"); + + expect(mockPaymentClient.handlePaymentResponse).toHaveBeenCalledWith({ + paymentPayload: mockPaymentPayload, + requirements: mockPaymentPayload.accepted, + settleResponse: mockSettleResponse, + }); + }); + + it("should retry once with a fresh payload when core hook recovers", async () => { + const correctivePaymentRequired: PaymentRequired = { + ...mockPaymentRequired, + accepts: [ + { + ...mockPaymentRequired.accepts[0], + extra: { + ...mockPaymentRequired.accepts[0].extra, + channelState: { chargedCumulativeAmount: "2000" }, + }, + }, + ], + }; + const freshPayload: PaymentPayload = { + ...mockPaymentPayload, + payload: { ...mockPaymentPayload.payload, signature: "0xfresh" }, + }; + mockPaymentClient.createPaymentPayload + .mockResolvedValueOnce(mockPaymentPayload) + .mockResolvedValueOnce(freshPayload); + mockPaymentClient.handlePaymentResponse + .mockResolvedValueOnce({ recovered: true }) + .mockResolvedValueOnce(undefined); + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(mockPaymentRequired)) + .mockResolvedValueOnce(createEmbeddedPaymentError(correctivePaymentRequired)) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "recovered result" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + + const result = await client.callTool("paid_tool"); + + expect(result.content[0]?.text).toBe("recovered result"); + expect(mockMcpClient.callTool).toHaveBeenCalledTimes(3); + expect(mockPaymentClient.createPaymentPayload).toHaveBeenCalledTimes(2); + expect(mockPaymentClient.createPaymentPayload).toHaveBeenNthCalledWith( + 2, + correctivePaymentRequired, + ); + expect(mockPaymentClient.handlePaymentResponse).toHaveBeenCalledTimes(2); + expect(mockPaymentClient.handlePaymentResponse).toHaveBeenNthCalledWith(1, { + paymentPayload: mockPaymentPayload, + requirements: mockPaymentPayload.accepted, + paymentRequired: correctivePaymentRequired, + }); + const retryCall = mockMcpClient.callTool.mock.calls[2][0]; + expect(retryCall._meta?.[MCP_PAYMENT_META_KEY]).toEqual(freshPayload); + }); + it("should support chaining hooks", () => { const result = client.onBeforePayment(() => {}).onAfterPayment(() => {}); expect(result).toBe(client); diff --git a/typescript/packages/mcp/test/unit/server.test.ts b/typescript/packages/mcp/test/unit/server.test.ts index 8fd479b7d9..25ee708038 100644 --- a/typescript/packages/mcp/test/unit/server.test.ts +++ b/typescript/packages/mcp/test/unit/server.test.ts @@ -20,6 +20,7 @@ interface MockResourceServer { verifyPayment: ReturnType; settlePayment: ReturnType; createPaymentRequiredResponse: ReturnType; + createPaymentCancellationDispatcher: ReturnType; } // ============================================================================ @@ -38,6 +39,7 @@ const mockPaymentRequirements: PaymentRequirements = { const mockPaymentPayload: PaymentPayload = { x402Version: 2, + accepted: mockPaymentRequirements, payload: { signature: "0x123", authorization: { @@ -82,11 +84,13 @@ const mockPaymentRequired = { * @returns Mock resource server instance */ function createMockResourceServer(): MockResourceServer { + const cancel = vi.fn().mockResolvedValue(undefined); return { findMatchingRequirements: vi.fn().mockReturnValue(mockPaymentRequirements), verifyPayment: vi.fn().mockResolvedValue(mockVerifyResponse), settlePayment: vi.fn().mockResolvedValue(mockSettleResponse), createPaymentRequiredResponse: vi.fn().mockResolvedValue(mockPaymentRequired), + createPaymentCancellationDispatcher: vi.fn().mockReturnValue({ cancel }), }; } @@ -144,6 +148,10 @@ describe("createPaymentWrapper", () => { mockPaymentPayload, mockPaymentRequirements, {}, + expect.objectContaining({ + toolName: "paid_tool", + arguments: { test: "arg" }, + }), ); expect(handler).toHaveBeenCalled(); expect(result.content).toEqual([{ type: "text", text: "success" }]); @@ -169,6 +177,10 @@ describe("createPaymentWrapper", () => { mockPaymentPayload, mockPaymentRequirements, {}, + expect.objectContaining({ + toolName: "paid_tool", + arguments: { test: "arg" }, + }), ); }); @@ -218,6 +230,114 @@ describe("createPaymentWrapper", () => { expect(result.isError).toBe(true); expect(mockResourceServer.settlePayment).not.toHaveBeenCalled(); + const dispatcher = mockResourceServer.createPaymentCancellationDispatcher.mock.results[0] + .value as { cancel: ReturnType }; + expect(dispatcher.cancel).toHaveBeenCalledWith({ reason: "handler_failed" }); + }); + + it("should cancel verified payment if tool handler throws", async () => { + const paid = createPaymentWrapper( + mockResourceServer as unknown as Parameters[0], + { + accepts: [mockPaymentRequirements], + }, + ); + const error = new Error("handler failed"); + const handler = vi.fn().mockRejectedValue(error); + const wrappedHandler = paid(handler); + + await expect( + wrappedHandler({ test: "arg" }, { _meta: { "x402/payment": mockPaymentPayload } }), + ).rejects.toThrow("handler failed"); + + const dispatcher = mockResourceServer.createPaymentCancellationDispatcher.mock.results[0] + .value as { cancel: ReturnType }; + expect(dispatcher.cancel).toHaveBeenCalledWith({ + reason: "handler_threw", + error, + }); + expect(mockResourceServer.settlePayment).not.toHaveBeenCalled(); + }); + + it("should settle skipHandler responses without executing the tool", async () => { + mockResourceServer.verifyPayment.mockResolvedValueOnce({ + isValid: true, + skipHandler: { body: { refunded: true } }, + }); + const paid = createPaymentWrapper( + mockResourceServer as unknown as Parameters[0], + { + accepts: [mockPaymentRequirements], + }, + ); + const handler = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "should not run" }], + }); + const wrappedHandler = paid(handler); + + const result = await wrappedHandler( + { test: "arg" }, + { _meta: { "x402/payment": mockPaymentPayload } }, + ); + + expect(handler).not.toHaveBeenCalled(); + expect(mockResourceServer.settlePayment).toHaveBeenCalled(); + expect(result.content).toEqual([{ type: "text", text: JSON.stringify({ refunded: true }) }]); + expect(result.structuredContent).toEqual({ refunded: true }); + expect(result._meta?.[MCP_PAYMENT_RESPONSE_META_KEY]).toEqual(mockSettleResponse); + }); + + it("should pass MCP transport context through core lifecycle calls", async () => { + const paid = createPaymentWrapper( + mockResourceServer as unknown as Parameters[0], + { + accepts: [mockPaymentRequirements], + resource: { url: "mcp://tool/context_tool" }, + }, + ); + const handler = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "success" }], + }); + const wrappedHandler = paid(handler); + const extra = { _meta: { "x402/payment": mockPaymentPayload, traceId: "trace-1" } }; + + await wrappedHandler({ test: "arg" }, extra); + + const expectedContext = expect.objectContaining({ + toolName: "context_tool", + arguments: { test: "arg" }, + meta: extra._meta, + }); + expect(mockResourceServer.createPaymentRequiredResponse).toHaveBeenCalledWith( + [mockPaymentRequirements], + expect.any(Object), + undefined, + undefined, + expectedContext, + ); + expect(mockResourceServer.verifyPayment).toHaveBeenCalledWith( + mockPaymentPayload, + mockPaymentRequirements, + {}, + expectedContext, + ); + expect(mockResourceServer.createPaymentCancellationDispatcher).toHaveBeenCalledWith( + mockPaymentPayload, + mockPaymentRequirements, + {}, + expectedContext, + ); + expect(mockResourceServer.settlePayment).toHaveBeenCalledWith( + mockPaymentPayload, + mockPaymentRequirements, + {}, + expect.objectContaining({ + toolName: "context_tool", + result: expect.objectContaining({ + content: [{ type: "text", text: "success" }], + }), + }), + ); }); it("should return 402 if payment verification fails", async () => { @@ -416,7 +536,7 @@ describe("createPaymentWrapper", () => { const handler = vi.fn(async () => { callOrder.push("handler"); - return { content: [{ type: "text", text: "success" }] }; + return { content: [{ type: "text" as const, text: "success" }] }; }); const wrappedHandler = paid(handler); @@ -457,6 +577,7 @@ describe("createPaymentWrapper", () => { mockPaymentPayload, mockPaymentRequirements, {}, + expect.any(Object), ); }); }); @@ -504,6 +625,8 @@ describe("createPaymentWrapper", () => { expect.any(Object), "Payment required to access this tool", extensions, + expect.any(Object), + undefined, ); expect((result.structuredContent as Record)?.extensions).toEqual(extensions); }); @@ -528,6 +651,8 @@ describe("createPaymentWrapper", () => { expect.any(Object), "Payment required to access this tool", undefined, + expect.any(Object), + undefined, ); }); });