diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb075ec..0e470d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `mcpc help tools/list` and other MCP method names now show the command's help instead of failing with "Unknown command" — they already worked as aliases everywhere else. +- x402 payments now work against servers that only reveal a tool's price when it is called: the payment signed for such a challenge is attached to the retried call, which used to go out unpaid and return the payment-required result again. The signature stays scoped to the tools the server charges for, so calls to free tools never carry a payment. ## [0.6.0] - 2026-08-02 diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 92ab2c33..141d83df 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -1309,13 +1309,17 @@ class BridgeProcess { * Returns { handled: false } if the result is not a payment-required response. */ private async handlePaymentRequiredRetry( + toolName: string, toolResult: unknown, retryFn: () => Promise ): Promise<{ handled: true; result: unknown } | { handled: false }> { if (!this.x402Wallet) return { handled: false }; - const { extractPaymentRequiredFromResult, extractAcceptFromPaymentRequired } = - await import('../lib/x402/fetch-middleware.js'); + const { + extractPaymentRequiredFromResult, + extractAcceptFromPaymentRequired, + recordPaymentRequiredTool, + } = await import('../lib/x402/fetch-middleware.js'); const paymentRequired = extractPaymentRequiredFromResult(toolResult); if (!paymentRequired) return { handled: false }; @@ -1327,6 +1331,10 @@ class BridgeProcess { logger.debug('Payment-required tool result received, signing fresh payment and retrying...'); + // The challenge is the only proof that this tool is paid on servers that advertise no + // _meta.x402 — without it the middleware would leave the retry unpaid (#365) + recordPaymentRequiredTool(this.x402PaymentCache, toolName); + // Invalidate cache and sign fresh this.x402PaymentCache.signature = null; try { @@ -1499,7 +1507,7 @@ class BridgeProcess { // Execute with automatic x402 payment retry on payment-required tool results result = await executeToolCall(); - const retry = await this.handlePaymentRequiredRetry(result, executeToolCall); + const retry = await this.handlePaymentRequiredRetry(params.name, result, executeToolCall); if (retry.handled) { result = retry.result; } diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index 442eaa93..e0f1bf9a 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -2,7 +2,7 @@ * x402 fetch middleware for MCP transport * * Wraps the fetch function used by StreamableHTTPClientTransport to: - * 1. Reuse a cached payment signature across tool calls within a session + * 1. Reuse a cached payment signature across calls to paid tools within a session * 2. Sign a fresh payment on the first call (or after cache invalidation) * 3. Handle HTTP 402 responses by parsing PAYMENT-REQUIRED, signing, and retrying once * @@ -73,6 +73,23 @@ interface JsonRpcRequest { export interface X402PaymentCache { /** Base64-encoded payment signature, or null if not yet signed / invalidated */ signature: string | null; + + /** + * Names of tools the server has charged for at runtime, via a payment-required tool + * result or an HTTP 402. Challenge-first servers omit `_meta.x402` from `tools/list`, + * so this is the only record that such a tool is paid at all — without it the cached + * signature would have to be attached to every tools/call in the session (including + * free ones) for the retry after a challenge to carry payment. + */ + paymentRequiredTools?: Set; +} + +/** + * Remember that the server charges for a tool, so later calls to it reuse the session's + * payment signature even when the tool advertises no `_meta.x402`. + */ +export function recordPaymentRequiredTool(cache: X402PaymentCache, toolName: string): void { + (cache.paymentRequiredTools ??= new Set()).add(toolName); } /** @@ -189,28 +206,34 @@ async function getOrSignPayment( return undefined; } - // The bridge can populate this cache after receiving a payment-required - // CallToolResult. That retry must not depend on proactive tools/list metadata: - // challenge-first servers may omit _meta.x402 entirely. - if (paymentCache.signature) { - logger.debug(`Using cached payment signature for tool "${toolName}"`); - return paymentCache.signature; + // Look up tool metadata (absent on challenge-first servers, which advertise no price + // until the tool is called) + const tool = getToolByName?.(toolName); + if (getToolByName && !tool) { + logger.debug(`Tool "${toolName}" not found in cache, relying on runtime payment challenges`); } - - if (!getToolByName) { + const x402 = (tool as { _meta?: { x402?: ToolPaymentMeta } } | undefined)?._meta?.x402; + + // Only tools the server charges for get a payment. The signature is deliberately reused + // across calls (servers such as mcp.apify.com treat it as a prepaid token, see #247), so + // this check is what keeps it off free tools — the alternative, attaching it to every + // tools/call, would hand a live authorization to calls that never asked for one. + const advertisesPayment = !!x402?.paymentRequired; + const chargedBefore = paymentCache.paymentRequiredTools?.has(toolName) ?? false; + if (!advertisesPayment && !chargedBefore) { return undefined; } - // Look up tool metadata - const tool = getToolByName(toolName); - if (!tool) { - logger.debug(`Tool "${toolName}" not found in cache, skipping payment`); - return undefined; + // Reuse the session's signature. The bridge stores one here after a payment-required + // CallToolResult, and the retry must find it even though the tool has no _meta.x402. + if (paymentCache.signature) { + logger.debug(`Using cached payment signature for tool "${toolName}"`); + return paymentCache.signature; } - // Check _meta.x402 - const meta = (tool as { _meta?: { x402?: ToolPaymentMeta } })._meta; - const x402 = meta?.x402; + // Charged before, but the tool advertises no terms to sign from — defer to the challenge, + // which carries the authoritative ones (a payment-required result handled by the bridge, + // or an HTTP 402). Proactive signing still requires _meta.x402. if (!x402 || !x402.paymentRequired) { return undefined; } @@ -284,6 +307,13 @@ async function handle402Fallback( // Cache the freshly signed payment for subsequent calls paymentCache.signature = result.paymentSignatureBase64; + // A 402 on a tools/call proves the server charges for that tool, even if it advertises + // no _meta.x402, so later calls to it can reuse this signature + const toolName = extractToolCallName(originalInit?.body); + if (toolName) { + recordPaymentRequiredTool(paymentCache, toolName); + } + // Retry with payment signature (once only) const retryInit = injectPayment(originalInit, result.paymentSignatureBase64); return await baseFetch(url, retryInit); diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts index b4cd7639..23b48139 100644 --- a/test/unit/lib/x402/fetch-middleware.test.ts +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -103,7 +103,11 @@ describe('createX402FetchMiddleware proactive sign', () => { payload: { signature: '0xsig', authorization: { from: WALLET.address } }, }; const cachedSignature = Buffer.from(JSON.stringify(cachedPayload)).toString('base64'); - const cache: X402PaymentCache = { signature: cachedSignature }; + // What the bridge leaves behind after a payment-required CallToolResult + const cache: X402PaymentCache = { + signature: cachedSignature, + paymentRequiredTools: new Set(['paid-tool']), + }; const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); const fetchFn = createX402FetchMiddleware(baseFetch as never, { wallet: WALLET, @@ -122,6 +126,52 @@ describe('createX402FetchMiddleware proactive sign', () => { expect(body.params._meta['x402/payment']).toEqual(cachedPayload); }); + it('does not attach the cached signature to a tool the server never charged for', async () => { + const cache: X402PaymentCache = { + signature: 'session-signature-base64', + paymentRequiredTools: new Set(['paid-tool']), + }; + const freeTool = { + name: 'free-tool', + description: 'Free tool', + inputSchema: { type: 'object' }, + } as unknown as Tool; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => freeTool, + paymentCache: cache, + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('free-tool') }); + + expect(mockSignPayment).not.toHaveBeenCalled(); + const init = baseFetch.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBeNull(); + expect(JSON.parse(String(init.body)).params._meta).toBeUndefined(); + }); + + it('does not attach the cached signature to a tool missing from the tools cache', async () => { + const cache: X402PaymentCache = { + signature: 'session-signature-base64', + paymentRequiredTools: new Set(['paid-tool']), + }; + const baseFetch = vi.fn().mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + getToolByName: () => undefined, + paymentCache: cache, + }); + + await fetchFn('https://example.test/mcp', { + method: 'POST', + body: toolsCallBody('unknown-tool'), + }); + + const init = baseFetch.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBeNull(); + }); + it('with schemePreference=exact and accepts=[exact, upto], signs exact', async () => { const tool = makePaidTool({ accepts: [EXACT_ACCEPT, UPTO_ACCEPT], ...UPTO_ACCEPT }); const cache: X402PaymentCache = { signature: null }; @@ -217,6 +267,44 @@ describe('createX402FetchMiddleware proactive sign', () => { }); }); +// --------------------------------------------------------------------------- +// HTTP 402 fallback path +// --------------------------------------------------------------------------- + +describe('createX402FetchMiddleware HTTP 402 fallback', () => { + const paymentRequiredHeader = Buffer.from( + JSON.stringify({ x402Version: 2, accepts: [EXACT_ACCEPT] }) + ).toString('base64'); + + it('remembers the charged tool, so the next call to it reuses the signature', async () => { + const cache: X402PaymentCache = { signature: null }; + const baseFetch = vi + .fn() + // First call: unpaid, server demands payment + .mockResolvedValueOnce( + new Response('', { status: 402, headers: { 'PAYMENT-REQUIRED': paymentRequiredHeader } }) + ) + .mockResolvedValue(new Response('', { status: 200 })); + const fetchFn = createX402FetchMiddleware(baseFetch as never, { + wallet: WALLET, + // Challenge-first server: nothing advertised in tools/list + getToolByName: () => undefined, + paymentCache: cache, + }); + + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + expect(cache.paymentRequiredTools?.has('paid-tool')).toBe(true); + + // Second call to the same tool goes out paid without signing again + await fetchFn('https://example.test/mcp', { method: 'POST', body: toolsCallBody('paid-tool') }); + + expect(mockSignPayment).toHaveBeenCalledTimes(1); + expect(baseFetch).toHaveBeenCalledTimes(3); + const init = baseFetch.mock.calls[2]?.[1] as RequestInit; + expect(new Headers(init.headers).get('PAYMENT-SIGNATURE')).toBe('mock-signature-base64'); + }); +}); + // --------------------------------------------------------------------------- // tool-result retry path — extractAcceptFromPaymentRequired // ---------------------------------------------------------------------------