Skip to content

agents/x402: withX402 never sees the payment with MCP SDK v2 (reads extra._meta, v2 moved it to ctx.mcpReq._meta) #2415

Closed

Description

@mszyma

What happens

With MCP TypeScript SDK v2 (@modelcontextprotocol/server 2.x), withX402(...).paidTool never sees the payment a client sends. Every call is answered with PAYMENT_REQUIRED, even when the request carries _meta["x402/payment"], so a paid tool can't be paid for.

Why

paidTool reads the payment token from the handler's second argument like this (dist/mcp/client/x402.js):

const headers = extra?.requestInfo?.headers ?? {};
const token = extra?._meta?.["x402/payment"] ?? headers["PAYMENT-SIGNATURE"] ?? headers["X-PAYMENT"];

In SDK v1 the request's _meta was on extra._meta. In SDK v2 the handler context is shaped differently: it has the keys sessionId, mcpReq and http, and the request _meta lives at ctx.mcpReq._meta. extra._meta is undefined, so token is always undefined.

What the handler receives for a call with _meta: { "x402/payment": "…" } on SDK v2:

{ "keys": ["sessionId", "mcpReq", "http"], "legacyMeta": null, "v2Meta": { "x402/payment": "…" } }

The header fallback also reads extra.requestInfo.headers, which isn't there in v2 either.

Reproduce

Versions: agents 0.24.0, @modelcontextprotocol/server 2.0.0, @modelcontextprotocol/client 2.0.0, @x402/core 2.28.0, @x402/evm 2.28.0 (vitest, Node 24).

import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { McpServer } from '@modelcontextprotocol/server';
import { withX402 } from 'agents/x402';
import { expect, it } from 'vitest';

it('withX402 sees the payment sent in _meta', async () => {
  const server = withX402(new McpServer({ name: 'repro', version: '1.0.0' }) as never, {
    network: 'base-sepolia',
    recipient: '0x000000000000000000000000000000000000dEaD',
  }) as any;
  server.paidTool('paid', 'A paid tool', 0.01, {}, {}, () => ({ content: [{ type: 'text', text: 'ran' }] }));

  const client = new Client({ name: 'c', version: '1' });
  const [a, b] = InMemoryTransport.createLinkedPair();
  await Promise.all([server.connect(a), client.connect(b)]);

  // Any non-empty token: withX402 should try to decode/verify it (INVALID_PAYMENT),
  // not answer as if no payment was sent (PAYMENT_REQUIRED).
  const token = btoa(JSON.stringify({ x402Version: 2, payload: {} }));
  const res = await client.callTool({ name: 'paid', arguments: {}, _meta: { 'x402/payment': token } });

  expect((res._meta as any)?.['x402/error']?.error).not.toBe('PAYMENT_REQUIRED'); // fails: it is PAYMENT_REQUIRED
});

Suggested fix

Read both shapes:

const meta = extra?.mcpReq?._meta ?? extra?._meta;
const token = meta?.["x402/payment"] ?? headers["PAYMENT-SIGNATURE"] ?? headers["X-PAYMENT"];

The header fallback likely needs the same treatment: the v2 context has an http key, but I have not checked where the headers are inside it.

Also, x402.d.ts still types paidTool against @modelcontextprotocol/sdk (v1) while createMcpHandler in the same package works with the v2 McpServer, so on v2 withX402 needs a cast.

Related, smaller

paidTool takes a fixed priceUSD. We sell painting by the number of colours and the quality in the call, so the price depends on the arguments. Accepting priceUSD: number | ((args) => number | Promise<number>) would cover that. (Happy to open this as a separate issue.)

We worked around both in our own server (a thin wrapper on @x402/core + @x402/evm, same _meta keys) and would switch back to agents/x402 once this is fixed.

Activity

  1. added a commit that references this issue on Oct 5, 2026
    010da63
  2. added a commit that references this issue on Oct 6, 2026
    04922ec
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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