ai-payments
Version:
A TypeScript library for AI payments functionality
239 lines • 11.1 kB
JavaScript
// 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