diff --git a/src/bridge/index.ts b/src/bridge/index.ts index 50018828..5758bb3c 100644 --- a/src/bridge/index.ts +++ b/src/bridge/index.ts @@ -60,6 +60,7 @@ import type { ProxyConfig } from '../lib/types.js'; import type { X402PaymentCache } from '../lib/x402/fetch-middleware.js'; import type { SignerWallet } from '../lib/x402/signer.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { buildX402RetryMeta } from './x402-retry.js'; // HTTP proxy and TLS settings are configured in main() after parsing --insecure flag @@ -1165,12 +1166,15 @@ class BridgeProcess { */ private async handlePaymentRequiredRetry( toolResult: unknown, - retryFn: () => Promise + retryFn: (paymentPayload: Record) => 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 { + decodePaymentPayload, + extractPaymentRequiredFromResult, + extractAcceptFromPaymentRequired, + } = await import('../lib/x402/fetch-middleware.js'); const paymentRequired = extractPaymentRequiredFromResult(toolResult); if (!paymentRequired) return { handled: false }; @@ -1184,6 +1188,7 @@ class BridgeProcess { // Invalidate cache and sign fresh this.x402PaymentCache.signature = null; + let paymentPayload: Record; try { const { signPayment } = await import('../lib/x402/signer.js'); const signed = await signPayment({ @@ -1191,6 +1196,7 @@ class BridgeProcess { accept: parsed.accept, resource: parsed.resource, }); + paymentPayload = decodePaymentPayload(signed.paymentSignatureBase64); this.x402PaymentCache.signature = signed.paymentSignatureBase64; logger.debug( `Fresh payment signed for retry: $${signed.amountUsd.toFixed(6)} to ${signed.to} on ${signed.networkLabel}` @@ -1200,9 +1206,15 @@ class BridgeProcess { return { handled: false }; } - // Retry once with the new cached payment - const result = await retryFn(); - return { handled: true, result }; + // Retry once with payment attached to the MCP request metadata. The SDK + // transport can use a non-string request body, so fetch-level body + // injection is not reliable for this path. + try { + const result = await retryFn(paymentPayload); + return { handled: true, result }; + } finally { + this.x402PaymentCache.signature = null; + } } /** @@ -1266,14 +1278,20 @@ class BridgeProcess { // Helper to execute the tool call (used for initial attempt and 402 retry) // Capture client ref — guaranteed non-null by check at top of handleMcpRequest const client = this.client; - const executeToolCall = async (): Promise => { + const executeToolCall = async ( + paymentPayload?: Record + ): Promise => { + const requestMeta = paymentPayload + ? buildX402RetryMeta(params._meta, paymentPayload) + : params._meta; + if (params.useTask && client.supportsTasksForToolCall()) { if (params.detach) { // Detached execution: start task and return task ID immediately const taskUpdate = await client.callToolDetached( params.name, params.arguments, - params._meta + requestMeta ); this.activeTasks.set(taskUpdate.taskId, { taskId: taskUpdate.taskId, @@ -1313,7 +1331,7 @@ class BridgeProcess { params.name, params.arguments, wrappedOnUpdate, - params._meta + requestMeta ); } finally { for (const [tid, task] of this.activeTasks) { @@ -1332,7 +1350,7 @@ class BridgeProcess { } } - return client.callTool(params.name, params.arguments, params._meta); + return client.callTool(params.name, params.arguments, requestMeta); }; // Execute with automatic x402 payment retry on payment-required tool results diff --git a/src/bridge/x402-retry.ts b/src/bridge/x402-retry.ts new file mode 100644 index 00000000..4550a9ea --- /dev/null +++ b/src/bridge/x402-retry.ts @@ -0,0 +1,15 @@ +const MCP_PAYMENT_META_KEY = 'x402/payment'; + +/** + * Preserve caller metadata and attach the decoded payment payload for the + * immediate MCP retry. + */ +export function buildX402RetryMeta( + requestMeta: Record | undefined, + paymentPayload: Record +): Record { + return { + ...requestMeta, + [MCP_PAYMENT_META_KEY]: paymentPayload, + }; +} diff --git a/src/lib/x402/fetch-middleware.ts b/src/lib/x402/fetch-middleware.ts index bc4e515d..69274302 100644 --- a/src/lib/x402/fetch-middleware.ts +++ b/src/lib/x402/fetch-middleware.ts @@ -35,6 +35,20 @@ const logger = createLogger('x402-middleware'); /** MCP _meta key for x402 payment (per x402 MCP spec) */ const MCP_PAYMENT_META_KEY = 'x402/payment'; +/** + * Decode a base64-encoded x402 payment signature into the payload expected by + * the MCP transport metadata field. + */ +export function decodePaymentPayload(paymentSignatureBase64: string): Record { + const parsed: unknown = JSON.parse( + Buffer.from(paymentSignatureBase64, 'base64').toString('utf-8') + ); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Decoded x402 payment payload must be an object'); + } + return parsed as Record; +} + /** * Payment information from tool's `_meta.x402`. * @@ -473,9 +487,7 @@ function injectPayment(init: RequestInit | undefined, paymentSignatureBase64: st // 2. JSON-RPC body _meta (x402 MCP spec mechanism) if (init?.body && typeof init.body === 'string') { try { - const paymentPayload = JSON.parse( - Buffer.from(paymentSignatureBase64, 'base64').toString('utf-8') - ) as Record; + const paymentPayload = decodePaymentPayload(paymentSignatureBase64); result.body = injectPaymentMeta(init.body, paymentPayload); } catch (error) { logger.debug('Failed to inject payment into body _meta:', error); diff --git a/test/unit/bridge/x402-retry.test.ts b/test/unit/bridge/x402-retry.test.ts new file mode 100644 index 00000000..558fce64 --- /dev/null +++ b/test/unit/bridge/x402-retry.test.ts @@ -0,0 +1,34 @@ +import { buildX402RetryMeta } from '../../../src/bridge/x402-retry.js'; + +describe('buildX402RetryMeta', () => { + it('attaches the decoded payment payload to the immediate retry', () => { + const paymentPayload = { + x402Version: 2, + payload: { authorization: { nonce: '0x1234' } }, + }; + + expect(buildX402RetryMeta(undefined, paymentPayload)).toEqual({ + 'x402/payment': paymentPayload, + }); + }); + + it('preserves caller metadata while replacing any stale payment', () => { + const paymentPayload = { + x402Version: 2, + payload: { authorization: { nonce: '0xfresh' } }, + }; + + expect( + buildX402RetryMeta( + { + progressToken: 'progress-1', + 'x402/payment': { payload: { authorization: { nonce: '0xstale' } } }, + }, + paymentPayload + ) + ).toEqual({ + progressToken: 'progress-1', + 'x402/payment': paymentPayload, + }); + }); +}); diff --git a/test/unit/lib/x402/fetch-middleware.test.ts b/test/unit/lib/x402/fetch-middleware.test.ts index 49f925ec..538f8622 100644 --- a/test/unit/lib/x402/fetch-middleware.test.ts +++ b/test/unit/lib/x402/fetch-middleware.test.ts @@ -11,6 +11,7 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { createX402FetchMiddleware, + decodePaymentPayload, extractAcceptFromPaymentRequired, type X402PaymentCache, } from '../../../../src/lib/x402/fetch-middleware.js'; @@ -224,3 +225,24 @@ describe('extractAcceptFromPaymentRequired', () => { expect(result).toBeUndefined(); }); }); + +describe('decodePaymentPayload', () => { + it('decodes an object payload for MCP request metadata', () => { + const payload = { + x402Version: 2, + accepted: EXACT_ACCEPT, + payload: { authorization: { nonce: '0x1234' } }, + }; + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64'); + + expect(decodePaymentPayload(encoded)).toEqual(payload); + }); + + it('rejects decoded values that are not objects', () => { + const encoded = Buffer.from(JSON.stringify(['not-an-object'])).toString('base64'); + + expect(() => decodePaymentPayload(encoded)).toThrow( + 'Decoded x402 payment payload must be an object' + ); + }); +});