From 43a93e090ddfb54ef2fc6e02300a74d8bb406fa8 Mon Sep 17 00:00:00 2001
From: Philippe d'Argent
Date: Fri, 8 May 2026 13:21:13 +0200
Subject: [PATCH] add missing mcp hook primitives
---
e2e/clients/mcp-typescript/index.ts | 202 +++++++++++++++---
e2e/clients/mcp-typescript/test.config.json | 9 +-
e2e/pnpm-lock.yaml | 7 +-
e2e/servers/mcp-typescript/index.ts | 117 +++++++++-
e2e/servers/mcp-typescript/package.json | 3 +-
e2e/servers/mcp-typescript/test.config.json | 27 ++-
examples/typescript/pnpm-lock.yaml | 2 +-
typescript/.changeset/late-meals-win.md | 5 +
typescript/.changeset/spotty-hounds-raise.md | 5 +
.../core/src/server/x402ResourceServer.ts | 60 +++++-
.../unit/server/x402ResourceServer.test.ts | 124 ++++++++++-
.../packages/mcp/src/client/x402MCPClient.ts | 63 ++++++
.../packages/mcp/src/server/paymentWrapper.ts | 182 +++++++++++++---
.../packages/mcp/test/unit/client.test.ts | 70 ++++++
.../packages/mcp/test/unit/server.test.ts | 127 ++++++++++-
15 files changed, 930 insertions(+), 73 deletions(-)
create mode 100644 typescript/.changeset/late-meals-win.md
create mode 100644 typescript/.changeset/spotty-hounds-raise.md
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,
);
});
});