Skip to content
Open
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
38 changes: 28 additions & 10 deletions src/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1165,12 +1166,15 @@ class BridgeProcess {
*/
private async handlePaymentRequiredRetry(
toolResult: unknown,
retryFn: () => Promise<unknown>
retryFn: (paymentPayload: Record<string, unknown>) => 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 {
decodePaymentPayload,
extractPaymentRequiredFromResult,
extractAcceptFromPaymentRequired,
} = await import('../lib/x402/fetch-middleware.js');
const paymentRequired = extractPaymentRequiredFromResult(toolResult);
if (!paymentRequired) return { handled: false };

Expand All @@ -1184,13 +1188,15 @@ class BridgeProcess {

// Invalidate cache and sign fresh
this.x402PaymentCache.signature = null;
let paymentPayload: Record<string, unknown>;
try {
const { signPayment } = await import('../lib/x402/signer.js');
const signed = await signPayment({
wallet: this.x402Wallet,
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}`
Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -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<unknown> => {
const executeToolCall = async (
paymentPayload?: Record<string, unknown>
): Promise<unknown> => {
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,
Expand Down Expand Up @@ -1313,7 +1331,7 @@ class BridgeProcess {
params.name,
params.arguments,
wrappedOnUpdate,
params._meta
requestMeta
);
} finally {
for (const [tid, task] of this.activeTasks) {
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/bridge/x402-retry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined,
paymentPayload: Record<string, unknown>
): Record<string, unknown> {
return {
...requestMeta,
[MCP_PAYMENT_META_KEY]: paymentPayload,
};
}
18 changes: 15 additions & 3 deletions src/lib/x402/fetch-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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<string, unknown>;
}

/**
* Payment information from tool's `_meta.x402`.
*
Expand Down Expand Up @@ -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<string, unknown>;
const paymentPayload = decodePaymentPayload(paymentSignatureBase64);
result.body = injectPaymentMeta(init.body, paymentPayload);
} catch (error) {
logger.debug('Failed to inject payment into body _meta:', error);
Expand Down
34 changes: 34 additions & 0 deletions test/unit/bridge/x402-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
22 changes: 22 additions & 0 deletions test/unit/lib/x402/fetch-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
);
});
});