UNPKG

ai-payments

Version:

A TypeScript library for AI payments functionality

239 lines 11.1 kB
// Polyfill fetch for Node.js import fetch, { Headers, Request, Response } from 'node-fetch'; if (!globalThis.fetch) { globalThis.fetch = fetch; globalThis.Headers = Headers; globalThis.Request = Request; globalThis.Response = Response; } import { config } from 'dotenv'; import axios from 'axios'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { withPaymentInterceptor } from 'x402-axios'; import { privateKeyToAccount } from 'viem/accounts'; import { createPublicClient, http, parseUnits, formatUnits } from 'viem'; import { baseSepolia } from 'viem/chains'; import { z } from 'zod'; config(); const DEFAULT_CONFIG = { baseURL: 'http://localhost:3001', network: baseSepolia, usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', }; // Lazy initialization variables let account = null; let publicClient = null; // Initialize account and client when needed function initializeClients(config = {}) { if (account && publicClient) { return; // Already initialized } // Environment validation (done when first needed) const privateKey = process.env.BUYER_PRIVATE_KEY; if (!privateKey) { throw new McpError(ErrorCode.InternalError, 'Missing BUYER_PRIVATE_KEY environment variable'); } account = privateKeyToAccount(privateKey); // Create a public client for blockchain operations publicClient = createPublicClient({ chain: config.network || DEFAULT_CONFIG.network, transport: http(), }); } // Storage for processed transactions to prevent double-spending const processedTransactions = new Set(); // Clear processed transactions (for testing/debugging) export function clearProcessedTransactions() { processedTransactions.clear(); console.error('[MCP DEBUG] Cleared processed transactions set'); } // Cleanup processed transactions older than 24 hours periodically setInterval(() => { // For now, we'll keep all transactions. In a production environment, // you might want to implement a more sophisticated cleanup mechanism // or use a persistent storage solution. }, 24 * 60 * 60 * 1000); // 24 hours /** * Create a paywall-protected MCP tool * * @param server - The MCP server instance to register the tool with * @param endpoint - The protected endpoint to access (e.g., '/secret') * @param priceUSD - Price in USD (e.g., 0.01 for $0.01) * @param payToAddress - Wallet address to receive payment * @param toolName - Tool name for the MCP server * @param authOptions - Optional authentication options for protected endpoints * @param config - Optional configuration overrides * * @example * ```typescript * const server = new McpServer({ name: "my-server", version: "1.0.0" }); * * // Simple endpoint * await createPaywallTool(server, '/secret', 0.01, process.env.PAY_TO_ADDRESS!, 'get_secret'); * * // With authentication * await createPaywallTool( * server, * '/auth/query', * 0.01, * process.env.PAY_TO_ADDRESS!, * 'get_url_query_secret', * { * queryParam: 'x-api-key', * apiKey: 'demo-api-key-12345', * } * ); * ``` */ export async function createPaywallTool(server, endpoint, priceUSD, payToAddress, toolName, authOptions, config = {}) { if (!server) { throw new McpError(ErrorCode.InternalError, 'MCP server instance is required'); } if (!payToAddress) { throw new McpError(ErrorCode.InternalError, 'Missing payToAddress parameter'); } if (!toolName) { throw new McpError(ErrorCode.InternalError, 'Tool name is required'); } const mergedConfig = { ...DEFAULT_CONFIG, ...config }; // Register the tool with the MCP server server.tool(toolName, `Access ${endpoint}. Call this tool whenever someone asks for this protected content. If no payment parameters are provided, returns payment instructions. If txHash is provided, verifies payment and reveals the protected data.`, { txHash: z .string() .optional() .describe('Transaction hash for payment verification (optional)'), txhash: z .string() .optional() .describe('Transaction hash for payment verification (optional) - lowercase variant'), }, async (args) => { // Debug: Log the arguments received directly (should be the actual tool arguments) console.error('[MCP DEBUG] Tool handler received args:', JSON.stringify(args, null, 2)); console.error('[MCP DEBUG] Args type:', typeof args); console.error('[MCP DEBUG] Args keys:', Object.keys(args || {})); // Handle case-insensitive parameter names const txHash = args.txHash || args.txhash; // Debug: Log extracted txHash console.error('[MCP DEBUG] Extracted txHash:', txHash); // If txHash is provided, verify payment if (txHash) { const result = await verifyPaymentAndGetContent(txHash, endpoint, authOptions, mergedConfig); return { content: [{ type: 'text', text: result }], }; } // If no txHash is provided, this is an initial payment request await throwPaymentRequiredError(endpoint, priceUSD, payToAddress); // This line will never be reached because throwPaymentRequiredError always throws return { content: [{ type: 'text', text: '' }] }; }); } /** * Throws a payment required error with payment instructions */ async function throwPaymentRequiredError(endpoint, priceUSD, payToAddress) { // Convert USD price to USDC amount (6 decimals for USDC) const usdcAmount = parseUnits(priceUSD.toString(), 6); const paymentMessage = `💰 Payment Required\n\nTo access this content, please send exactly $${priceUSD} USDC to:\n\n**Recipient Address:** ${payToAddress}\n**Amount:** ${formatUnits(usdcAmount, 6)} USDC\n**Network:** Base Sepolia\n\nAfter sending the transaction, call this tool again with:\n- txHash: your transaction hash\n\n**Note:** If you already have a transaction hash from a previous payment, provide it as: "txHash=[your_tx_hash]" to verify your payment.`; throw new McpError(ErrorCode.InvalidRequest, paymentMessage); } /** * Verifies payment on blockchain and returns protected content */ async function verifyPaymentAndGetContent(txHash, endpoint, authOptions, config = {}) { // Initialize clients if not already done initializeClients(config); const mergedConfig = { ...DEFAULT_CONFIG, ...config }; try { console.error('[MCP DEBUG] Starting payment verification for txHash:', txHash); console.error('[MCP DEBUG] Using USDC address:', mergedConfig.usdcAddress); console.error('[MCP DEBUG] Using network:', mergedConfig.network?.name); // Check if transaction has already been processed to prevent double-spending if (processedTransactions.has(txHash)) { console.error('[MCP DEBUG] Transaction already processed'); throw new McpError(ErrorCode.InvalidRequest, 'Transaction has already been used for payment'); } console.error('[MCP DEBUG] Getting transaction from blockchain...'); // Get transaction details from blockchain const transaction = await publicClient.getTransaction({ hash: txHash, }); console.error('[MCP DEBUG] Transaction found:', !!transaction); if (!transaction) { throw new McpError(ErrorCode.InvalidRequest, 'Transaction not found on blockchain'); } console.error('[MCP DEBUG] Transaction details:', { to: transaction.to, value: transaction.value?.toString(), input: transaction.input, }); console.error('[MCP DEBUG] Getting transaction receipt...'); // Verify transaction is confirmed const receipt = await publicClient.getTransactionReceipt({ hash: txHash, }); console.error('[MCP DEBUG] Receipt found:', !!receipt); console.error('[MCP DEBUG] Receipt status:', receipt?.status); if (!receipt || receipt.status !== 'success') { throw new McpError(ErrorCode.InvalidRequest, 'Transaction not confirmed or failed'); } console.error('[MCP DEBUG] Validating transaction target...'); console.error('[MCP DEBUG] Transaction.to:', transaction.to?.toLowerCase()); console.error('[MCP DEBUG] Expected USDC address:', mergedConfig.usdcAddress.toLowerCase()); // Basic validation of transaction if (transaction.to?.toLowerCase() !== mergedConfig.usdcAddress.toLowerCase()) { throw new McpError(ErrorCode.InvalidRequest, 'Transaction must be sent to USDC contract address'); } console.error(`[MCP] Payment verified: ${txHash}`); // Make the request to get the protected content const client = withPaymentInterceptor(axios.create({ baseURL: mergedConfig.baseURL }), account); // Prepare request options with authentication const requestOptions = {}; // Handle authentication based on authOptions if (authOptions) { // Query parameter authentication if (authOptions.queryParam && authOptions.apiKey) { requestOptions.params = { ...requestOptions.params, [authOptions.queryParam]: authOptions.apiKey, }; } // Header-based authentication if (authOptions.header && authOptions.apiKey) { requestOptions.headers = { ...requestOptions.headers, [authOptions.header]: authOptions.apiKey, }; } // Custom headers if (authOptions.headers) { requestOptions.headers = { ...requestOptions.headers, ...authOptions.headers, }; } } const response = await client.get(endpoint, requestOptions); console.error(`[MCP] Retrieved protected content: ${JSON.stringify(response.data)}`); // Mark transaction as processed to prevent reuse processedTransactions.add(txHash); // Return properly formatted response data if (typeof response.data === 'object') { return JSON.stringify(response.data, null, 2); } return response.data; } catch (error) { console.error('[MCP DEBUG] Payment verification error occurred:'); console.error('[MCP DEBUG] Error type:', error?.constructor?.name); console.error('[MCP DEBUG] Error message:', error instanceof Error ? error.message : error); console.error('[MCP DEBUG] Full error:', error); if (error instanceof McpError) { throw error; } throw new McpError(ErrorCode.InternalError, `Payment verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Export types for better developer experience export { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; //# sourceMappingURL=index.js.map