Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4,725 changes: 39 additions & 4,686 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"type": "module",
"workspaces": [
"packages/atxp-common",
"packages/atxp-mpp",
"packages/atxp-sqlite",
"packages/atxp-redis",
"packages/atxp-server",
Expand All @@ -16,7 +17,6 @@
"packages/atxp-polygon",
"packages/atxp-express",
"packages/atxp-cloudflare",
"packages/atxp-mpp",
"packages/atxp-tempo",
"packages/atxp-x402"
],
Expand Down
75 changes: 68 additions & 7 deletions packages/atxp-client/src/atxpFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OAuthAuthenticationRequiredError, OAuthClient } from './oAuth.js';
import {
PAYMENT_REQUIRED_ERROR_CODE,
OMNI_PAYMENT_ERROR_CODE,
paymentRequiredError,
AccessToken,
AuthorizationServerUrl,
Expand All @@ -21,6 +22,17 @@ import {
} from '@atxp/common';
import type { PaymentMaker, ProspectivePayment, ClientConfig, PaymentFailureContext } from './types.js';
import type { ProtocolHandler, ProtocolConfig } from './protocolHandler.js';
import { X402ProtocolHandler } from './x402ProtocolHandler.js';
import { MPPProtocolHandler } from './mppProtocolHandler.js';

/** Default protocol handlers — all supported protocols enabled out of the box.
* TODO: Extract ATXP-MCP into an ATXPProtocolHandler so all three protocols use
* the same ProtocolHandler interface. Currently ATXP-MCP is special-cased in
* handlePaymentRequestError and runs as the fallback when no handler matches. */
const DEFAULT_PROTOCOL_HANDLERS: ProtocolHandler[] = [
new X402ProtocolHandler(),
new MPPProtocolHandler(),
];
import { InsufficientFundsError, ATXPPaymentError } from './errors.js';
import { getIsReactNative, createReactNativeSafeFetch, Destination } from '@atxp/common';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -109,7 +121,7 @@ export class ATXPFetcher {
onPayment = async () => {},
onPaymentFailure,
onPaymentAttemptFailed,
protocolHandlers = [],
protocolHandlers,
protocolFlag
} = config;
// Use React Native safe fetch if in React Native environment
Expand All @@ -131,7 +143,7 @@ export class ATXPFetcher {
this.onPayment = onPayment;
this.onPaymentFailure = onPaymentFailure || this.defaultPaymentFailureHandler;
this.onPaymentAttemptFailed = onPaymentAttemptFailed;
this.protocolHandlers = protocolHandlers;
this.protocolHandlers = protocolHandlers ?? DEFAULT_PROTOCOL_HANDLERS;
this.protocolFlag = protocolFlag;
}

Expand Down Expand Up @@ -357,8 +369,9 @@ export class ATXPFetcher {
}

protected handlePaymentRequestError = async (paymentRequestError: McpError): Promise<boolean> => {
if (paymentRequestError.code !== PAYMENT_REQUIRED_ERROR_CODE) {
throw new Error(`ATXP: expected payment required error (code ${PAYMENT_REQUIRED_ERROR_CODE}); got code ${paymentRequestError.code}`);
// Accept both legacy -30402 (PAYMENT_REQUIRED_ERROR_CODE) and new -32042 (OMNI_PAYMENT_ERROR_CODE)
if (paymentRequestError.code !== PAYMENT_REQUIRED_ERROR_CODE && paymentRequestError.code !== OMNI_PAYMENT_ERROR_CODE) {
throw new Error(`ATXP: expected payment required error (code ${PAYMENT_REQUIRED_ERROR_CODE} or ${OMNI_PAYMENT_ERROR_CODE}); got code ${paymentRequestError.code}`);
}
const paymentRequestUrl = (paymentRequestError.data as {paymentRequestUrl: string}|undefined)?.paymentRequestUrl;
if (!paymentRequestUrl) {
Expand Down Expand Up @@ -605,6 +618,36 @@ export class ATXPFetcher {
};
}

/**
* Build a synthetic HTTP 402 Response from MCP error data so protocol handlers
* can detect and handle X402/MPP challenges that arrived via MCP JSON-RPC.
*
* The response mimics what an HTTP omni-challenge would look like:
* - Status: 402
* - Body: X402 payment requirements JSON (if data.x402 present)
* - WWW-Authenticate header: MPP challenge (if data.mpp present)
*/
protected buildSyntheticResponseFromMcpError(errorData: Record<string, unknown>): Response | null {
const x402Data = errorData.x402;
const mppData = errorData.mpp;

if (!x402Data && !mppData) return null;

const headers = new Headers({ 'Content-Type': 'application/json' });

// Add MPP challenge as WWW-Authenticate header (standard MPP detection)
if (mppData && typeof mppData === 'object') {
const mpp = mppData as Record<string, string>;
const parts = Object.entries(mpp).map(([k, v]) => `${k}="${v}"`).join(', ');
headers.set('WWW-Authenticate', `Payment ${parts}`);
}

// Body is X402 requirements (standard X402 detection looks for x402Version in body)
const body = x402Data ? JSON.stringify(x402Data) : '{}';

return new Response(body, { status: 402, headers });
}

/**
* Try to handle an HTTP-level payment challenge using registered protocol handlers.
* For omni-challenges, uses protocolFlag to select the preferred handler.
Expand Down Expand Up @@ -691,11 +734,29 @@ export class ATXPFetcher {
}
}

// Check for MCP error with payment required code - use duck typing since instanceof may fail with bundling
const mcpError = (fetchError as Error & { code?: number })?.code === PAYMENT_REQUIRED_ERROR_CODE ? fetchError as McpError : null;
// Check for MCP error with payment required code - accept both legacy -30402 and new omni -32042
const errorCode = (fetchError as Error & { code?: number })?.code;
const mcpError = (errorCode === PAYMENT_REQUIRED_ERROR_CODE || errorCode === OMNI_PAYMENT_ERROR_CODE) ? fetchError as McpError : null;

if (mcpError) {
this.logger.info(`Payment required - ATXP client starting payment flow ${(mcpError?.data as {paymentRequestUrl: string}|undefined)?.paymentRequestUrl}`);
const errorData = mcpError.data as Record<string, unknown> | undefined;

// Check if protocol handlers can handle the omni-challenge data.
// For X402: error.data.x402 contains payment requirements.
// For MPP: error.data.mpp contains the MPP challenge.
// If a protocol handler matches, use it instead of the ATXP-MCP flow.
if (this.protocolHandlers.length > 0 && errorData) {
const syntheticResponse = this.buildSyntheticResponseFromMcpError(errorData);
if (syntheticResponse) {
const protocolResult = await this.tryProtocolHandlers(syntheticResponse, url, init);
if (protocolResult) {
return protocolResult;
}
}
}

// Fall back to ATXP-MCP flow (payment request URL)
this.logger.info(`Payment required - ATXP client starting payment flow ${(errorData as {paymentRequestUrl: string}|undefined)?.paymentRequestUrl}`);
if(await this.handlePaymentRequestError(mcpError)) {
// Retry the request once - we should be auth'd now
response = await oauthClient.fetch(url, init);
Expand Down
1 change: 1 addition & 0 deletions packages/atxp-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
// Payment error handling
export {
PAYMENT_REQUIRED_ERROR_CODE,
OMNI_PAYMENT_ERROR_CODE,
PAYMENT_REQUIRED_PREAMBLE,
paymentRequiredError
} from './paymentRequiredError.js';
Expand Down
4 changes: 2 additions & 2 deletions packages/atxp-common/src/mcpJson.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PAYMENT_REQUIRED_ERROR_CODE, PAYMENT_REQUIRED_PREAMBLE } from './paymentRequiredError.js';
import { PAYMENT_REQUIRED_ERROR_CODE, PAYMENT_REQUIRED_PREAMBLE, OMNI_PAYMENT_ERROR_CODE } from './paymentRequiredError.js';
import { AuthorizationServerUrl } from './types.js';
import { CallToolResult, isJSONRPCError, isJSONRPCResponse, JSONRPCError, JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
import { Logger } from './types.js';
Expand All @@ -11,7 +11,7 @@ export function parsePaymentRequests(message: JSONRPCMessage): {url: Authorizati
if (isJSONRPCError(message)){
// Explicitly throw payment required errors that result in MCP protocol-level errors
const rpcError = message as JSONRPCError;
if (rpcError.error.code === PAYMENT_REQUIRED_ERROR_CODE) {
if (rpcError.error.code === PAYMENT_REQUIRED_ERROR_CODE || rpcError.error.code === OMNI_PAYMENT_ERROR_CODE) {
const paymentRequestUrl = (rpcError.error.data as {paymentRequestUrl: string})?.paymentRequestUrl;
const dataPr = _parsePaymentRequestFromString(paymentRequestUrl);
if(dataPr) {
Expand Down
6 changes: 5 additions & 1 deletion packages/atxp-common/src/paymentRequiredError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { McpError } from "@modelcontextprotocol/sdk/types.js";
import { AuthorizationServerUrl } from "./types.js";
import { BigNumber } from 'bignumber.js';

export const PAYMENT_REQUIRED_ERROR_CODE = -30402; // Payment required
// Legacy ATXP-MCP error code. Kept for backwards compatibility with old servers.
export const PAYMENT_REQUIRED_ERROR_CODE = -30402;
// New unified omni-challenge error code. Same as MPP's -32042.
// Going forward, all payment challenges use this code with both ATXP-MCP and MPP data in error.data.
export const OMNI_PAYMENT_ERROR_CODE = -32042;
// Do NOT modify this message. It is used by clients to identify an ATXP payment required error
// in an MCP response. Changing it will break back-compatability.
export const PAYMENT_REQUIRED_PREAMBLE = 'Payment via ATXP is required. ';
Expand Down
121 changes: 114 additions & 7 deletions packages/atxp-express/src/atxpExpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ import {
ProtocolSettlement,
type PaymentProtocol,
type ATXPConfig,
type SettlementContext,
} from "@atxp/server";

export function atxpExpress(args: ATXPArgs): Router {
const config = buildServerConfig(args);
const router = Router();
// Single ProtocolSettlement instance shared across all requests (stateless, just holds config)
const settlement = new ProtocolSettlement(config.server, config.logger);

// Regular middleware
const atxpMiddleware = async (req: Request, res: Response, next: NextFunction) => {
Expand All @@ -44,15 +47,15 @@ export function atxpExpress(args: ATXPArgs): Router {
logger.debug(`${mcpRequests.length} MCP requests found in request`);

if(mcpRequests.length === 0) {
// For non-MCP requests: check for payment credentials (X402 or ATXP)
// For non-MCP requests: check for payment credentials (X402 or MPP)
const detected = detectProtocol({
'x-payment': req.headers['x-payment'] as string | undefined,
'authorization': req.headers['authorization'] as string | undefined,
});

if (detected) {
// This is a retry with payment credentials — verify and settle
await handleProtocolCredential(config, req, res, next, detected.protocol, detected.credential);
await handleProtocolCredential(config, settlement, req, res, next, detected.protocol, detected.credential);
return;
}

Expand Down Expand Up @@ -88,25 +91,126 @@ export function atxpExpress(args: ATXPArgs): Router {
return router;
}

/**
* Resolve the user's identity from the request.
*
* Priority:
* 1. OAuth Bearer token → extract `sub` claim (preferred — works for all requests)
* 2. Wallet address from payment credential (fallback for non-OAuth clients)
*
* For X402: Authorization: Bearer coexists with X-PAYMENT, so OAuth is available.
* For MPP: Authorization: Payment replaces Authorization: Bearer. In MCP transport,
* identity is maintained via the session. In HTTP transport, the server embeds a
* session reference in the MPP challenge `id` field (opaque to the client).
*/
async function resolveIdentity(
config: ATXPConfig,
req: Request,
protocol: PaymentProtocol,
credential: string,
): Promise<string | undefined> {
const logger = config.logger;

// Try OAuth Bearer token first (works when Authorization header isn't used by the payment protocol)
const authHeader = req.headers['authorization'];
if (authHeader?.startsWith('Bearer ')) {
try {
const resource = getResource(config, new URL(req.url, req.protocol + '://' + req.host), req.headers);
const tokenCheck = await checkTokenNode(config, resource, req);
if (tokenCheck.data?.sub) {
logger.debug(`Resolved identity from OAuth token: ${tokenCheck.data.sub}`);
return tokenCheck.data.sub;
}
} catch (error) {
// Bearer token present but check failed — likely a config problem (wrong issuer, JWKS
// unreachable, etc.), not just a missing token. Log at warn to surface it.
logger.warn(`Failed to resolve identity from OAuth token, falling back to credential: ${error instanceof Error ? error.message : String(error)}`);
}
}

// Fallback: extract identity from the MPP credential's source field.
// Standard MPP uses a DID string: "did:pkh:eip155:<chainId>:<address>"
if (protocol === 'mpp') {
try {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(Buffer.from(credential, 'base64').toString());
} catch {
parsed = JSON.parse(credential);
}
const source = parsed.source;
if (typeof source === 'string' && source.startsWith('did:pkh:eip155:')) {
// Extract chain ID and address from DID: did:pkh:eip155:<chainId>:<address>
const parts = source.split(':');
const chainId = parts[3];
const address = parts[4];
if (chainId && address) {
// Map chainId to network name for our AccountId format
const network = chainId === '4217' ? 'tempo' : chainId === '42431' ? 'tempo_moderato' : `eip155:${chainId}`;
const identity = `${network}:${address}`;
logger.debug(`Resolved identity from MPP credential source DID: ${identity}`);
return identity;
}
}
} catch {
// Not parseable — no identity
}
}

// X402: payer address is only available after settlement (facilitator returns it).
// We can't extract it from the credential pre-settlement. Identity will be
// resolved by auth from the Permit2 signature if no sourceAccountId is provided.

return undefined;
}

/**
* Handle a request that includes payment credentials (retry after challenge).
* Verifies the credential at request start, serves the request, then settles at request end.
*
* Identity resolution: extracts user identity from OAuth token (preferred) or
* wallet address in payment credential (fallback), and passes it to auth as
* sourceAccountId for payment recording/reconciliation.
*/
async function handleProtocolCredential(
config: ATXPConfig,
settlement: ProtocolSettlement,
req: Request,
res: Response,
next: NextFunction,
protocol: PaymentProtocol,
credential: string,
): Promise<void> {
const logger = config.logger;
const settlement = new ProtocolSettlement(config.server, logger);

logger.info(`Detected ${protocol} credential on retry request`);

// Verify at request START
const verifyResult = await settlement.verify(protocol, credential);
// Resolve user identity before verification
const sourceAccountId = await resolveIdentity(config, req, protocol, credential);
if (sourceAccountId) {
logger.debug(`Resolved identity for ${protocol} payment: ${sourceAccountId}`);
}

// Build context with identity — passed to both verify and settle so auth
// can use sourceAccountId for account-level checks (rate limiting, spend limits)
// during verification, and for payment recording during settlement.
const context: SettlementContext = {
...(sourceAccountId && { sourceAccountId }),
};

// Verify at request START.
// Note: for X402, context.paymentRequirements is not available here because the
// middleware doesn't have the original 402 challenge data from the previous request.
// Auth handles undefined paymentRequirements gracefully (Coinbase facilitator can
// verify Permit2 signatures without them).
let verifyResult;
try {
verifyResult = await settlement.verify(protocol, credential, context);
} catch (error) {
logger.warn(`${protocol} credential parsing/verification error: ${error instanceof Error ? error.message : String(error)}`);
res.status(400).json({ error: 'invalid_payment', error_description: `Malformed ${protocol} credential` });
return;
}
if (!verifyResult.valid) {
logger.warn(`${protocol} credential verification failed`);
res.status(402).json({ error: 'invalid_payment', error_description: `${protocol} credential verification failed` });
Expand All @@ -115,12 +219,15 @@ async function handleProtocolCredential(

logger.info(`${protocol} credential verified successfully`);

// Listen for response finish to settle at request END (only on success)
// Listen for response finish to settle at request END (only on success).
// TODO: If settlement fails after a 200, the client received the resource for free.
// This needs a retry queue or dead-letter mechanism for production reliability.
// For now, failures are logged and the payment is lost.
res.on('finish', async () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
logger.debug(`Request finished successfully (${res.statusCode}), settling ${protocol} payment`);
await settlement.settle(protocol, credential);
await settlement.settle(protocol, credential, context);
} catch (error) {
logger.error(`Failed to settle ${protocol} payment: ${error instanceof Error ? error.message : String(error)}`);
}
Expand Down
Loading
Loading