Repository navigation
agents/x402: withX402 never sees the payment with MCP SDK v2 (reads extra._meta, v2 moved it to ctx.mcpReq._meta) #2415
Description
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
fix(x402): read paidTool payments from MCP SDK v2 handler context
fix(x402): read paidTool payments from MCP SDK v2 handler context (cl…