Repository navigation
withX402Client ignores spec-compliant x402 v2 payment-required results (structuredContent / content[0].text), only reads _meta["x402/error"] #2550
Description
Describe the bug
withX402Client (agents/x402) only recognises a payment-required tool result when the server puts the PaymentRequired object in result._meta["x402/error"]:
agents/packages/agents/src/mcp/client/x402.ts
Lines 531 to 541 in 48d9c36
| // Check for x402 payment required error in response metadata | |
| const maybeX402Error = res._meta?.["x402/error"] as | |
| | (PaymentRequired & Record<string, unknown>) | |
| | undefined; | |
| if ( | |
| res.isError && | |
| maybeX402Error && | |
| maybeX402Error.accepts && | |
| Array.isArray(maybeX402Error.accepts) && | |
| maybeX402Error.accepts.length > 0 |
The x402 v2 MCP transport spec does not define _meta["x402/error"]. It says servers MUST return isError: true with the PaymentRequired in structuredContent and as JSON in content[0].text, and that clients should read structuredContent first and fall back to content[0].text:
- Server requirements: https://github.com/x402-foundation/x402/blob/f8f83309706b14cc696962f0a002eeeb66c3dc26/specs/transports-v2/mcp.md#L16-L30
- Client requirements: https://github.com/x402-foundation/x402/blob/f8f83309706b14cc696962f0a002eeeb66c3dc26/specs/transports-v2/mcp.md#L73-L78
The reference server wrapper in @x402/mcp emits exactly that shape and no _meta["x402/error"]:
So a withX402Client-wrapped client talking to a spec-compliant x402 MCP server (e.g. one built with @x402/mcp) treats the 402 as an ordinary tool error: the confirmation callback is never called, nothing is signed, and the caller gets the raw error back. It fails closed (no unintended payment), but paid tools on those servers are unusable.
To Reproduce
Self-contained script, in-process only: McpServer + Client over InMemoryTransport, a mock signer that returns a placeholder signature. No network, no keys, no funds.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { withX402Client } from "agents/x402";
// PaymentRequired from the example in specs/transports-v2/mcp.md
const PR = {
x402Version: 2,
error: "Payment required to access this resource",
resource: { url: "mcp://tool/paid_echo", description: "Paid echo tool", mimeType: "application/json" },
accepts: [{
scheme: "exact", network: "eip155:84532", amount: "10000",
asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
maxTimeoutSeconds: 60, extra: { name: "USDC", version: "2" }
}]
};
const SHAPES = {
specCompliant: () => ({ isError: true, structuredContent: { ...PR }, content: [{ type: "text", text: JSON.stringify(PR) }] }),
metaOnlyControl: () => ({ isError: true, _meta: { "x402/error": PR }, content: [{ type: "text", text: JSON.stringify(PR) }] })
};
for (const [name, make402] of Object.entries(SHAPES)) {
let retried = 0;
const server = new McpServer({ name: "s", version: "0.0.0" });
server.registerTool("paid_echo", { description: "402 until paid" }, async (extra) => {
if (extra?._meta?.["x402/payment"] !== undefined) { retried++; return { content: [{ type: "text", text: "paid" }] }; }
return make402();
});
const [ct, st] = InMemoryTransport.createLinkedPair();
await server.connect(st);
const client = new Client({ name: "c", version: "0.0.0" });
await client.connect(ct);
let confirmed = 0, signed = 0;
const x402 = withX402Client(client, {
network: "base-sepolia",
account: { address: "0x000000000000000000000000000000000000dEaD", signTypedData: async () => { signed++; return "0x" + "11".repeat(65); } },
confirmationCallback: async () => { confirmed++; return true; }
});
const res = await x402.callTool(null, { name: "paid_echo", arguments: {} });
console.log(name, { confirmed, signed, retried, isError: res.isError ?? false });
}Output with agents@0.28.0, @modelcontextprotocol/sdk@1.30.0, @x402/core@2.28.0, @x402/evm@2.28.0, Node 24.15.0:
| server result shape | confirmationCallback | signTypedData | retried with payment | final isError |
|---|---|---|---|---|
spec v2: structuredContent + content[0].text |
0 | 0 | 0 | true (original 402 returned) |
structuredContent only |
0 | 0 | 0 | true |
control: _meta["x402/error"] + content[0].text |
1 | 1 | 1 | false |
I also checked that the unwrapped client receives structuredContent.accepts intact in the first two cases, so the payment requirement does reach the client.
Expected behavior
withX402Client detects the payment requirement from structuredContent (preferred) or content[0].text, as the spec describes, and goes through the same confirmation / cap / signing path it uses today for _meta["x402/error"].
Version:
agents 0.28.0 (also the version on main at 48d9c36).
Additional context
-
Suggested fix. Extract the
PaymentRequiredwith a small helper and keep_meta["x402/error"]as a fallback, sowithX402servers built with this package keep working:function extractPaymentRequired(res: CallToolResult): (PaymentRequired & Record<string, unknown>) | undefined { if (!res.isError) return undefined; const ok = (v: unknown): v is PaymentRequired & Record<string, unknown> => !!v && typeof v === "object" && typeof (v as PaymentRequired).x402Version === "number" && Array.isArray((v as PaymentRequired).accepts) && (v as PaymentRequired).accepts.length > 0; if (ok(res.structuredContent)) return res.structuredContent; const first = res.content?.[0]; if (first?.type === "text") { try { const parsed = JSON.parse(first.text); if (ok(parsed)) return parsed; } catch {} } const legacy = res._meta?.["x402/error"]; return ok(legacy) ? legacy : undefined; }
and in
callToolWithPaymentreplace theres._meta?.["x402/error"]lookup withconst maybeX402Error = extractPaymentRequired(res);. I ran this helper against eight result shapes (the three above,content[0].textonly, a non-error result, a plain error string, emptyaccepts, and a normal structured tool output); it matches only the four payment-required shapes. -
Regression test idea. In
packages/agents/src/x402-tests/client-payment.test.ts, thesetup()helper mocks the firstcallToolresult with_meta["x402/error"]. A parameterised variant that mocks{ isError: true, structuredContent: PR, content: [{ type: "text", text: JSON.stringify(PR) }] }(and one with onlycontent[0].text) and assertssignTypedDatawas called once andcallTooltwice would cover it. The existing_metacases stay as the backward-compat test. -
Related, not verified by execution. On retry,
withX402Clientsends the payment as a base64 string in_meta["x402/payment"](x402.ts#L608-L618). The spec shows aPaymentPayloadobject there (mcp.md#L80-L85), and@x402/mcp's server only accepts an object withx402Versionandpayload(encoding.ts#L34-L40, #L83-L99). From reading the code, fixing detection alone would therefore lead to a second 402 from an@x402/mcpserver. I have not run that combination. (The reverse direction looks similar: thewithX402server in this package reads only a string token, and emits nostructuredContent.) -
Relation to feat(payments): wrap functions and MCP tools with x402 #2273. The open PR feat(payments): wrap functions and MCP tools with x402 #2273 marks
withX402Clientas deprecated / feature-frozen and its new server wrapper emitsstructuredContentlike@x402/mcp, so the current client would not detect that wrapper's 402 either. If the plan is to steer users to the upstreamx402Clientinstead, a short note in thewithX402Clientdocs that it only understands the_meta["x402/error"]shape would already help people who hit this on 0.28.x. Happy to send a PR for either the fix or the doc note, whichever you prefer.
Related issues I checked: #2415 / #2494 (server side reading _meta under MCP SDK v2, fixed), #2272 (cap on the selected requirement), #1973 (propagating upstream HTTP 402). None covers the client-side detection.
Activity
Echolonius commentedon Oct 21, 2026
Since this repo isn't taking external PRs, here's a tested patch on a fork in case it saves you time: compare (
.patchforgit am)._meta["x402/error"]check, sowithX402servers behave as before, it reads the spec form:structuredContent, then JSON incontent[0].text. It needsisError: true, a numericx402Versionand a non-emptyaccepts.PaymentPayloadobject in_meta["x402/payment"]. Detection alone isn't enough: the reference@x402/mcpserver ignores anything that isn't an object withx402Versionandpayload(extractPaymentFromMeta), so a base64 token gets a second 402.withX402servers still get the token.client-payment.test.tsand a patch changeset.I ran
pnpm install --frozen-lockfile,pnpm run build,pnpm run checkandpnpm --filter agents run test:x402on GitHub Actions against the patch. The check passes ("All 133 projects typecheck successfully") and all 73 x402 tests pass. The same new test file againstmain(f404547) fails only the three spec payment cases, withexpected true to be undefined, meaning the 402 came back unpaid.One more data point: draft #2273 marks
withX402Clientfeature-frozen, and its new server adapter also sendsstructuredContentchallenges and reads a payment object, so today's client can't pay that adapter either.Written with Claude Code (AI). The CI runs are the verification.
ponpokoyama1972 commentedon Oct 22, 2026
Thanks for testing this and sharing the patch. Your result (the three spec payment cases fail on main and pass with the patch) matches what I saw.