diff --git a/typescript/.changeset/mcp-402-snapshot-accepts.md b/typescript/.changeset/mcp-402-snapshot-accepts.md new file mode 100644 index 0000000000..74774f5bd6 --- /dev/null +++ b/typescript/.changeset/mcp-402-snapshot-accepts.md @@ -0,0 +1,5 @@ +--- +"@x402/mcp": patch +--- + +MCP 402 responses snapshot payment accepts before scheme enrichers so batch-settlement recovery retries still match. diff --git a/typescript/packages/mcp/src/server/paymentWrapper.ts b/typescript/packages/mcp/src/server/paymentWrapper.ts index a7957b5ea8..7166bd984d 100644 --- a/typescript/packages/mcp/src/server/paymentWrapper.ts +++ b/typescript/packages/mcp/src/server/paymentWrapper.ts @@ -11,6 +11,7 @@ import { resolveFailurePathSettlement, resolvePaymentFlow, resolvePaymentFlowPhases, + snapshotPaymentRequirementsList, x402ResourceServer, } from "@x402/core/server"; @@ -672,9 +673,12 @@ async function createPaymentRequiredResult( paymentPayload?: PaymentPayload, ): Promise { const resourceInfo = buildToolResourceInfo(toolName, config); + // Enrichers may mutate Extra in place (e.g. batch-settlement channelState). + // Snapshot so wrapper config stays a stable match baseline across tool calls. + const accepts = snapshotPaymentRequirementsList(config.accepts); const paymentRequired = await resourceServer.createPaymentRequiredResponse( - config.accepts, + accepts, resourceInfo, errorMessage, config.extensions, diff --git a/typescript/packages/mcp/test/unit/server.test.ts b/typescript/packages/mcp/test/unit/server.test.ts index c9e58f5e39..d2dcda2fe8 100644 --- a/typescript/packages/mcp/test/unit/server.test.ts +++ b/typescript/packages/mcp/test/unit/server.test.ts @@ -2,14 +2,16 @@ * Unit tests for createPaymentWrapper */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createPaymentWrapper } from "../../src/server"; -import { MCP_PAYMENT_RESPONSE_META_KEY } from "../../src/types"; +import { x402ResourceServer, type FacilitatorClient } from "@x402/core/server"; import type { PaymentPayload, PaymentRequirements, + SchemeNetworkServer, SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { createPaymentWrapper } from "../../src/server"; +import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_RESPONSE_META_KEY } from "../../src/types"; // ============================================================================ // Mock Types @@ -1316,4 +1318,118 @@ describe("createPaymentWrapper", () => { consoleError.mockRestore(); }); }); + + describe("corrective 402 accept snapshot", () => { + it("does not mutate config accepts when a 402 enricher writes extra in place", async () => { + mockResourceServer.createPaymentRequiredResponse.mockImplementation(async accepts => { + for (const requirement of accepts) { + requirement.extra.channelState = { chargedCumulativeAmount: "2000" }; + } + return mockPaymentRequired; + }); + const accepts: PaymentRequirements[] = [{ ...mockPaymentRequirements, extra: {} }]; + const paid = createPaymentWrapper( + mockResourceServer as unknown as Parameters[0], + { accepts }, + ); + const handler = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "success" }], + }); + + await paid(handler)({ test: "arg" }, {}); + + expect(accepts[0].extra).toEqual({}); + }); + + it("does not mutate wrapper config accepts when building a corrective 402", async () => { + const reason = "invalid_batch_settlement_evm_cumulative_amount_mismatch"; + let verifyCalls = 0; + let abortOnce = true; + const mockFacilitator: FacilitatorClient = { + verify: async () => { + verifyCalls += 1; + return { isValid: true, payer: "test-payer" }; + }, + settle: async () => ({ + success: true, + transaction: "0xtx", + network: "x402:cash", + payer: "test-payer", + }), + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "cash", network: "x402:cash" }], + extensions: [], + signers: {}, + }), + }; + + let enricherCalls = 0; + const scheme: SchemeNetworkServer = { + scheme: "cash", + defaultAssetTransferMethod: "default", + paymentFlows: { + default: { supported: ["authorization"], default: "authorization" }, + }, + parsePrice: async () => ({ amount: "1000", asset: "USD", extra: {} }), + enhancePaymentRequirements: async requirements => requirements, + enrichPaymentRequiredResponse: async ctx => { + if (ctx.error !== reason || !ctx.paymentPayload) { + return; + } + enricherCalls += 1; + for (const requirement of ctx.requirements) { + if (!requirement.extra) { + requirement.extra = {}; + } + requirement.extra.channelState = { chargedCumulativeAmount: "2000" }; + } + }, + }; + + const server = new x402ResourceServer(mockFacilitator); + server.register("x402:cash", scheme); + await server.initialize(); + server.onBeforeVerify(async () => { + if (!abortOnce) { + return; + } + abortOnce = false; + return { + abort: true, + reason, + message: "Client voucher base does not match server state", + }; + }); + + const cashRequirements = (): PaymentRequirements => ({ + scheme: "cash", + network: "x402:cash", + amount: "1000", + payTo: "test-recipient", + asset: "", + maxTimeoutSeconds: 0, + extra: {}, + }); + const config = { accepts: [cashRequirements()] }; + const paid = createPaymentWrapper(server, config); + const wrappedHandler = paid(async () => ({ + content: [{ type: "text", text: "ok" }], + })); + const payload: PaymentPayload = { + x402Version: 2, + accepted: cashRequirements(), + payload: { signature: "~test-payer" }, + }; + const extra = { _meta: { [MCP_PAYMENT_META_KEY]: payload } }; + + const first = await wrappedHandler({}, extra); + expect(first.isError).toBe(true); + expect(enricherCalls).toBe(1); + expect(config.accepts[0].extra).toEqual({}); + + const second = await wrappedHandler({}, extra); + expect(second.isError).toBeFalsy(); + expect(verifyCalls).toBe(1); + }); + }); });