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
84 changes: 69 additions & 15 deletions typescript/packages/mcp/src/client/x402MCPClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
PaymentRequiredHook,
PaymentRequiredContext,
} from "../types";
import { MCP_PAYMENT_REQUIRED_CODE, MCP_PAYMENT_META_KEY } from "../types";
import {
MCP_PAYMENT_REQUIRED_CODE,
MCP_PAYMENT_META_KEY,
isPaymentRequiredError,
} from "../types";
import { extractPaymentResponseFromMeta } from "../utils";

// ============================================================================
Expand Down Expand Up @@ -463,15 +467,33 @@ export class x402MCPClient {
options?: { timeout?: number; signal?: AbortSignal; resetTimeoutOnProgress?: boolean },
): Promise<x402MCPToolCallResult> {
// First attempt without payment
const result = await this.mcpClient.callTool({ name, arguments: args }, undefined, options);
let result: MCPCallToolResult;
let paymentRequired: PaymentRequired | null = null;

// Validate result structure
if (!isMCPCallToolResult(result)) {
throw new Error("Invalid MCP tool result: missing content array");
}
try {
const rawResult = await this.mcpClient.callTool(
{ name, arguments: args },
undefined,
options,
);

if (!isMCPCallToolResult(rawResult)) {
throw new Error("Invalid MCP tool result: missing content array");
}

// Check if this is a payment required response (isError with payment_required in content)
const paymentRequired = this.extractPaymentRequiredFromResult(result);
result = rawResult;
paymentRequired = this.extractPaymentRequiredFromResult(result);
} catch (error: unknown) {
// Handle MCP UrlElicitationRequired (-32042) used for payment flows (SEP-1036).
// The MCP SDK throws McpError for -32042 with error.data preserved.
const extracted = this.extractPaymentRequiredFromError(error);
if (extracted) {
paymentRequired = extracted;
result = { content: [], isError: true };
} else {
throw error;
}
}

if (!paymentRequired) {
// Not a payment required response, forward original MCP response as-is
Expand Down Expand Up @@ -642,15 +664,18 @@ export class x402MCPClient {
): Promise<PaymentRequired | null> {
// Note: This actually calls the tool to trigger 402 if paid.
// If the tool is free, it will execute as a side effect.
const result = await this.mcpClient.callTool({ name, arguments: args });
try {
const result = await this.mcpClient.callTool({ name, arguments: args });

// Validate result structure
if (!isMCPCallToolResult(result)) {
return null;
}
if (!isMCPCallToolResult(result)) {
return null;
}

// Check if this is a payment required response
return this.extractPaymentRequiredFromResult(result);
return this.extractPaymentRequiredFromResult(result);
} catch (error: unknown) {
// Handle McpError(-32042) payment challenges
return this.extractPaymentRequiredFromError(error);
}
}

// ============================================================================
Expand Down Expand Up @@ -722,6 +747,35 @@ export class x402MCPClient {
return null;
}

/**
* Extracts PaymentRequired from a thrown MCP error.
*
* Uses isPaymentRequiredError() to validate the error structure (supports
* both 402 and -32042 codes), then extracts PaymentRequired from the
* correct location (error.data directly or error.data.x402 for namespaced
* -32042 errors).
*
* @param error - The caught error
* @returns PaymentRequired if this is a payment error, null otherwise
*/
private extractPaymentRequiredFromError(error: unknown): PaymentRequired | null {
if (!isPaymentRequiredError(error)) {
return null;
}

// isPaymentRequiredError validated that PaymentRequired exists in error.data
// (directly or namespaced under .x402 for -32042 errors).
const data = error.data as unknown as Record<string, unknown>;
if ("x402Version" in data) {
return error.data;
}
if (typeof data.x402 === "object" && data.x402 !== null) {
return data.x402 as PaymentRequired;
}

return null;
}

}

/**
Expand Down
44 changes: 40 additions & 4 deletions typescript/packages/mcp/src/types/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ import { isObject } from "../utils/encoding";
*/
export const MCP_PAYMENT_REQUIRED_CODE = 402;

/**
* MCP's UrlElicitationRequired JSON-RPC error code (-32042) from SEP-1036.
*
* SEP-1036 defines this code for flows where the server needs the client to
* provide something before proceeding, explicitly including payment flows.
* This is the only custom error code the MCP TypeScript SDK propagates with
* error.data intact through McpServer's tool handler catch block.
*
* Using this code for payment challenges ensures error.data (containing
* PaymentRequired) survives the McpServer round-trip, working around the
* SDK limitation tracked in:
* https://github.com/modelcontextprotocol/typescript-sdk/issues/774
*/
export const JSONRPC_PAYMENT_REQUIRED_CODE = -32042;

/**
* MCP _meta key for payment payload (client → server)
*/
Expand Down Expand Up @@ -301,7 +316,14 @@ export interface MCPPaymentRequiredError {
}

/**
* Type guard to check if an error is a payment required error
* Type guard to check if an error is a payment required error.
*
* Supports both the legacy x402 error code (402) and the MCP standard
* UrlElicitationRequired code (-32042) which covers payment flows per SEP-1036.
*
* For -32042 errors, PaymentRequired may be directly in error.data or
* namespaced under error.data.x402 (for servers that include additional
* payment method data alongside x402 requirements).
*
* @param error - The error to check
* @returns True if the error is a payment required error
Expand All @@ -311,13 +333,27 @@ export function isPaymentRequiredError(error: unknown): error is MCPPaymentRequi
return false;
}

if (error.code !== MCP_PAYMENT_REQUIRED_CODE || typeof error.message !== "string") {
if (typeof error.message !== "string") {
return false;
}

if (!isObject(error.data)) {
// Legacy x402 error code (402) - PaymentRequired directly in error.data
if (error.code === MCP_PAYMENT_REQUIRED_CODE) {
if (!isObject(error.data)) return false;
return "x402Version" in error.data && "accepts" in error.data;
}

// MCP UrlElicitationRequired (-32042) - used for payment flows per SEP-1036
if (error.code === JSONRPC_PAYMENT_REQUIRED_CODE) {
if (!isObject(error.data)) return false;
// Direct PaymentRequired in error.data
if ("x402Version" in error.data && "accepts" in error.data) return true;
// Namespaced under error.data.x402
if (isObject(error.data.x402)) {
return "x402Version" in error.data.x402 && "accepts" in error.data.x402;
}
return false;
}

return "x402Version" in error.data && "accepts" in error.data;
return false;
}
Loading
Loading