withX402Client ignores spec-compliant x402 v2 payment-required results (`structuredContent` / `content[0].text`), only reads `_meta["x402/error"]` · Issue #2550 · cloudflare/agents · GitHub
Skip to content

withX402Client ignores spec-compliant x402 v2 payment-required results (structuredContent / content[0].text), only reads _meta["x402/error"] #2550

Open
0x5t4l1n/agents
#1

Description

@ponpokoyama1972

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"]:

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:

The reference server wrapper in @x402/mcp emits exactly that shape and no _meta["x402/error"]:

https://github.com/x402-foundation/x402/blob/f8f83309706b14cc696962f0a002eeeb66c3dc26/typescript/packages/mcp/src/server/paymentWrapper.ts#L667-L695

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

  1. 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:

    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 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.

  2. 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.

  3. 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.)

  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions