You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
withX402Client (agents/x402) only recognises a payment-required tool result when the server puts the PaymentRequired object in result._meta["x402/error"]:
// Check for x402 payment required error in response metadata
constmaybeX402Error=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:
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.mdconstPR={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"}}]};constSHAPES={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]ofObject.entries(SHAPES)){letretried=0;constserver=newMcpServer({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"}]};}returnmake402();});const[ct,st]=InMemoryTransport.createLinkedPair();awaitserver.connect(st);constclient=newClient({name: "c",version: "0.0.0"});awaitclient.connect(ct);letconfirmed=0,signed=0;constx402=withX402Client(client,{network: "base-sepolia",account: {address: "0x000000000000000000000000000000000000dEaD",signTypedData: async()=>{signed++;return"0x"+"11".repeat(65);}},confirmationCallback: async()=>{confirmed++;returntrue;}});constres=awaitx402.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 PaymentRequired with a small helper and keep _meta["x402/error"] as a fallback, so withX402 servers built with this package keep working:
functionextractPaymentRequired(res: CallToolResult): (PaymentRequired&Record<string,unknown>)|undefined{if(!res.isError)returnundefined;constok=(v: unknown): v is PaymentRequired&Record<string,unknown>=>!!v&&typeofv==="object"&&typeof(vasPaymentRequired).x402Version==="number"&&Array.isArray((vasPaymentRequired).accepts)&&(vasPaymentRequired).accepts.length>0;if(ok(res.structuredContent))returnres.structuredContent;constfirst=res.content?.[0];if(first?.type==="text"){try{constparsed=JSON.parse(first.text);if(ok(parsed))returnparsed;}catch{}}constlegacy=res._meta?.["x402/error"];returnok(legacy) ? legacy : undefined;}
and in callToolWithPayment replace the res._meta?.["x402/error"] lookup with const maybeX402Error = extractPaymentRequired(res);. I ran this helper against eight result shapes (the three above, content[0].text only, a non-error result, a plain error string, empty accepts, 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, the setup() helper mocks the first callTool result with _meta["x402/error"]. A parameterised variant that mocks { isError: true, structuredContent: PR, content: [{ type: "text", text: JSON.stringify(PR) }] } (and one with only content[0].text) and asserts signTypedData was called once and callTool twice would cover it. The existing _meta cases stay as the backward-compat test.
Related, not verified by execution. On retry, withX402Client sends the payment as a base64 string in _meta["x402/payment"] (x402.ts#L608-L618). The spec shows a PaymentPayload object there (mcp.md#L80-L85), and @x402/mcp's server only accepts an object with x402Version and payload (encoding.ts#L34-L40, #L83-L99). From reading the code, fixing detection alone would therefore lead to a second 402 from an @x402/mcp server. I have not run that combination. (The reverse direction looks similar: the withX402 server in this package reads only a string token, and emits no structuredContent.)
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 withX402Client as deprecated / feature-frozen and its new server wrapper emits structuredContent like @x402/mcp, so the current client would not detect that wrapper's 402 either. If the plan is to steer users to the upstream x402Client instead, a short note in the withX402Client docs 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