Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>
): 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 };

Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
64 changes: 47 additions & 17 deletions src/lib/x402/fetch-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string>;
}

/**
* 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<string>()).add(toolName);
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
90 changes: 89 additions & 1 deletion test/unit/lib/x402/fetch-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading