Skip to main content
Glama

Crossmint HR Airdrop MCP

by monostate
server.ts37.4 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import { Connection, Keypair, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; import { crossmint } from '@goat-sdk/crossmint'; import dotenv from 'dotenv'; // Load environment variables dotenv.config(); import { parse as csvParse } from 'csv-parse/sync'; // import nodemailer from 'nodemailer'; import fs from 'fs'; import { z } from 'zod'; import { createToken, addLiquidity, sendCompressedAirdrop, calculateAirdropFees } from './utils/tokenUtils.js'; import { createCustodialWallets, sendWalletEmails, generateSimpleEmailContent } from './utils/crossmintUtils.js'; // State interface for the MCP server interface ServerState { connectedWallet: { publicKey: string; solBalance: number; } | null; createdToken: { name: string; symbol: string; mintAddress: string; supply: number; decimals: number; } | null; employees: { name?: string; email: string; role?: string; walletAddress: string; tokenAmount?: number; }[]; airdropStatus: { started: boolean; completed: boolean; successful: number; failed: number; }; emailsStatus: { sent: boolean; successful: number; failed: number; }; } interface RoleAmounts { operational?: number; developer?: number; manager?: number; VP?: number; VIP?: number; } class HRAirdropServer { private server: Server; private state: ServerState; constructor() { this.server = new Server( { name: 'hr-airdrop-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // Initialize state this.state = { connectedWallet: null, createdToken: null, employees: [], airdropStatus: { started: false, completed: false, successful: 0, failed: 0, }, emailsStatus: { sent: false, successful: 0, failed: 0, }, }; // Set up tool handlers this.setupToolHandlers(); // Error handling this.server.onerror = (error: any) => console.error('[MCP Server Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'connect_wallet', description: 'Connect a Solana wallet to the airdrop server', inputSchema: { type: 'object', properties: { privateKey: { type: 'string', description: 'The private key of the Solana wallet (base58 encoded)', }, rpcUrl: { type: 'string', description: 'The Solana RPC URL to use (optional)', }, }, required: ['privateKey'], }, }, { name: 'connect_crossmint_wallet', description: 'Connect a Crossmint wallet to the airdrop server', inputSchema: { type: 'object', properties: { email: { type: 'string', description: 'Email address associated with the Crossmint wallet', }, apiKey: { type: 'string', description: 'Crossmint API key', }, }, required: ['email', 'apiKey'], }, }, { name: 'check_balance', description: 'Check the SOL balance of the connected wallet', inputSchema: { type: 'object', properties: {}, }, }, { name: 'create_token', description: 'Create a new Solana token', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Token name', }, symbol: { type: 'string', description: 'Token symbol', }, supply: { type: 'number', description: 'Total token supply', }, decimals: { type: 'number', description: 'Token decimals (default: 9)', }, }, required: ['name', 'symbol', 'supply'], }, }, { name: 'add_liquidity', description: 'Add liquidity to the created token', inputSchema: { type: 'object', properties: { tokenAmount: { type: 'number', description: 'Amount of tokens to add to liquidity pool', }, solAmount: { type: 'number', description: 'Amount of SOL to add to liquidity pool', }, }, required: ['tokenAmount', 'solAmount'], }, }, { name: 'generate_wallets', description: 'Generate custodial wallets for employees using Crossmint', inputSchema: { type: 'object', properties: { employees: { type: 'string', description: 'List of employees in format "name,email" (one per line)', }, apiKey: { type: 'string', description: 'Crossmint API key (optional, defaults to test key for demo)', }, }, required: ['employees'], }, }, { name: 'upload_csv', description: 'Process employee data from a CSV file', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the CSV file', }, }, required: ['filePath'], }, }, { name: 'calculate_amounts', description: 'Calculate token amounts for each employee', inputSchema: { type: 'object', properties: { uniformAmount: { type: 'number', description: 'Uniform amount for all employees (if no CSV role-mapping is used)', }, roleAmounts: { type: 'object', properties: { operational: { type: 'number' }, developer: { type: 'number' }, manager: { type: 'number' }, VP: { type: 'number' }, VIP: { type: 'number' }, }, description: 'Token amounts by role (if CSV with roles is used)', }, }, }, }, { name: 'calculate_fees', description: 'Calculate gas fees for the airdrop', inputSchema: { type: 'object', properties: {}, }, }, { name: 'start_airdrop', description: 'Perform the token airdrop to employee wallets', inputSchema: { type: 'object', properties: {}, }, }, { name: 'send_emails', description: 'Send emails to employees with wallet access instructions', inputSchema: { type: 'object', properties: { fromEmail: { type: 'string', description: 'Sender email address (e.g., hr@company.com)', }, subject: { type: 'string', description: 'Email subject', }, resendApiKey: { type: 'string', description: 'Resend API key (optional, will use default if not provided)', }, }, required: ['fromEmail'], }, }, { name: 'get_state', description: 'Get the current state of the airdrop process', inputSchema: { type: 'object', properties: {}, }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => { try { const { name, arguments: args } = request.params; switch (name) { case 'connect_wallet': return await this.handleConnectWallet(args); case 'connect_crossmint_wallet': return await this.handleConnectCrossmintWallet(args); case 'check_balance': return await this.handleCheckBalance(); case 'create_token': return await this.handleCreateToken(args); case 'add_liquidity': return await this.handleAddLiquidity(args); case 'generate_wallets': return await this.handleGenerateWallets(args); case 'upload_csv': return await this.handleUploadCsv(args); case 'calculate_amounts': return await this.handleCalculateAmounts(args); case 'calculate_fees': return await this.handleCalculateFees(); case 'start_airdrop': return await this.handleStartAirdrop(); case 'send_emails': return await this.handleSendEmails(args); case 'get_state': return await this.handleGetState(); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { console.error(`Error in tool call ${request.params.name}:`, error); if (error instanceof McpError) { throw error; } return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); } private async handleConnectCrossmintWallet(args: any) { try { // Validate input const schema = z.object({ email: z.string().email(), apiKey: z.string().optional(), }); const { email, apiKey: providedApiKey } = schema.parse(args); // Check if API key is provided, if not, prompt the user if (!providedApiKey) { return { content: [ { type: 'text', text: `To connect a Crossmint wallet, I need your Crossmint API key. You can get this from the Crossmint developer dashboard at https://www.crossmint.com/ Please provide your Crossmint API key to continue.`, }, ], }; } const apiKey = providedApiKey; console.error(`Connecting Crossmint wallet for ${email}`); try { // In production mode, attempt to connect to the real Crossmint wallet console.error('PRODUCTION MODE: Attempting to connect to real Crossmint wallet'); // Initialize Crossmint client const crossmintClient = crossmint(apiKey); // Get the wallet for this email const wallet = await crossmintClient.custodial({ chain: "solana", connection: new Connection('https://api.mainnet-beta.solana.com', 'confirmed'), email }); // Get the wallet address const publicKey = wallet.getAddress(); // Get the SOL balance (in a real implementation, we would query the blockchain) // For now, we'll use a default value const solBalance = 1.0; console.error(`Successfully connected to real Crossmint wallet: ${publicKey}`); // Update state this.state.connectedWallet = { publicKey, solBalance, }; return { content: [ { type: 'text', text: `Crossmint wallet connected successfully for ${email}.\nPublic Key: ${publicKey}\nSOL Balance: ${solBalance} SOL`, }, ], }; } catch (error) { console.error('Error connecting to real Crossmint wallet, falling back to simulation:', error); // Generate a pseudo-random wallet address const publicKey = `crossmint_${Math.random().toString(36).substring(2, 10)}`; const solBalance = 1.0; // Default SOL balance for demo // Update state this.state.connectedWallet = { publicKey, solBalance, }; return { content: [ { type: 'text', text: `Crossmint wallet connected successfully for ${email}.\nPublic Key: ${publicKey}\nSOL Balance: ${solBalance} SOL\n\n(Note: Using simulation mode due to connection error)`, }, ], }; } } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to connect Crossmint wallet: ${error instanceof Error ? error.message : String(error)}` ); } } private async handleConnectWallet(args: any) { try { // Validate input const schema = z.object({ privateKey: z.string(), rpcUrl: z.string().optional(), }); const { privateKey, rpcUrl } = schema.parse(args); // Connect to Solana const connection = new Connection( rpcUrl || 'https://api.mainnet-beta.solana.com', 'confirmed' ); let keypair: Keypair; try { keypair = Keypair.fromSecretKey(Buffer.from(privateKey, 'base64')); } catch (e: any) { throw new McpError(ErrorCode.InvalidParams, 'Invalid private key format. Please provide a base64 encoded private key.'); } const publicKey = keypair.publicKey.toString(); // Get SOL balance const balance = await connection.getBalance(keypair.publicKey); const solBalance = balance / LAMPORTS_PER_SOL; // Update state this.state.connectedWallet = { publicKey, solBalance, }; return { content: [ { type: 'text', text: `Wallet connected successfully. Public Key: ${publicKey}\nSOL Balance: ${solBalance} SOL`, }, ], }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to connect wallet: ${error instanceof Error ? error.message : String(error)}` ); } } private async handleCheckBalance() { if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } // In a real implementation, we would refresh the balance from the blockchain // For this demo, we'll just return the cached balance const { publicKey, solBalance } = this.state.connectedWallet; // Calculate required SOL for employees const employeeCount = this.state.employees.length || 1; // Default to 1 if no employees yet const requiredSol = 0.1 * employeeCount; // 0.1 SOL per employee const isSufficient = solBalance >= requiredSol; return { content: [ { type: 'text', text: ` Wallet Public Key: ${publicKey} Current SOL Balance: ${solBalance.toFixed(5)} SOL Required for Airdrop: ~${requiredSol.toFixed(5)} SOL (0.1 SOL per employee) Status: ${isSufficient ? 'Sufficient balance' : 'Insufficient balance'} ${!isSufficient ? 'Insufficient tokens. Create a new token? (yes/no)' : ''} `.trim(), }, ], }; } private async handleCreateToken(args: any) { if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } // Validate input const schema = z.object({ name: z.string(), symbol: z.string(), supply: z.number().positive(), decimals: z.number().min(0).max(9).default(9), }); const { name, symbol, supply, decimals } = schema.parse(args); try { // Create a dummy connection and keypair for simulation const connection = new Connection('https://api.mainnet-beta.solana.com', 'confirmed'); const keypair = Keypair.generate(); // In a real implementation, this would be the user's wallet // Call the token creation utility const result = await createToken( connection, keypair, name, symbol, supply, decimals ); if (!result.mintAddress) { throw new Error('Token creation failed: No mint address returned'); } // Update state this.state.createdToken = { name, symbol, mintAddress: result.mintAddress, supply, decimals, }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Token creation failed: ${error instanceof Error ? error.message : String(error)}` ); } return { content: [ { type: 'text', text: ` Token created successfully: Name: ${name} Symbol: ${symbol} Supply: ${supply.toLocaleString()} Decimals: ${decimals} Mint Address: ${this.state.createdToken?.mintAddress} Next step: Add liquidity to give the token value. `.trim(), }, ], }; } private async handleAddLiquidity(args: any) { if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } if (!this.state.createdToken) { throw new McpError( ErrorCode.InvalidRequest, 'No token created. Please create a token first.' ); } // Validate input const schema = z.object({ tokenAmount: z.number().positive(), solAmount: z.number().positive(), }); const { tokenAmount, solAmount } = schema.parse(args); // Check if we have enough balance if (this.state.connectedWallet.solBalance < solAmount) { throw new McpError( ErrorCode.InvalidRequest, `Insufficient SOL balance. You have ${this.state.connectedWallet.solBalance} SOL, but ${solAmount} SOL is required.` ); } // In a real implementation, we would call the Raydium liquidity addition function // For this demo, we'll simulate liquidity addition // Update SOL balance this.state.connectedWallet.solBalance -= solAmount; return { content: [ { type: 'text', text: ` Liquidity added successfully: Token: ${this.state.createdToken.symbol} Token Amount: ${tokenAmount.toLocaleString()} ${this.state.createdToken.symbol} SOL Amount: ${solAmount} SOL Remaining SOL Balance: ${this.state.connectedWallet.solBalance} SOL Next step: Generate wallets for employees. `.trim(), }, ], }; } private async handleGenerateWallets(args: any) { if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } // Validate input const schema = z.object({ employees: z.string(), apiKey: z.string().optional(), }); const { employees, apiKey: providedApiKey } = schema.parse(args); // Check if API key is provided, if not, prompt the user if (!providedApiKey) { return { content: [ { type: 'text', text: `To generate custodial wallets for employees, I need your Crossmint API key. You can get this from the Crossmint developer dashboard at https://www.crossmint.com/ Please provide your Crossmint API key to continue.`, }, ], }; } const apiKey = providedApiKey; // Parse employee data from string // Format: "name,email" (one per line) const employeeList = employees.split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .map(line => { const parts = line.split(','); if (parts.length >= 2) { return { name: parts[0].trim(), email: parts[1].trim() }; } else { // If only one part, assume it's an email return { email: parts[0].trim() }; } }); // Extract emails for wallet creation const emails = employeeList.map(emp => emp.email); if (emails.length === 0) { throw new McpError( ErrorCode.InvalidParams, 'No valid employee emails found. Please provide at least one employee.' ); } // Log the parsed data for debugging console.error('Parsed employee data:', JSON.stringify(employeeList, null, 2)); try { // Create a Solana connection const connection = new Connection( 'https://api.mainnet-beta.solana.com', 'confirmed' ); // Use the Crossmint SDK to create custodial wallets const wallets = await createCustodialWallets(connection, apiKey, emails); // Update state with the created wallets this.state.employees = wallets; } catch (error) { throw new McpError( ErrorCode.InternalError, `Failed to create wallets: ${error instanceof Error ? error.message : String(error)}` ); } return { content: [ { type: 'text', text: ` Generated ${emails.length} custodial wallets successfully: ${this.state.employees .map( (employee, index) => `${index + 1}. Email: ${employee.email}\n Wallet: ${employee.walletAddress}` ) .join('\n')} Next step: Upload a CSV file with detailed employee information (optional) or calculate token amounts. `.trim(), }, ], }; } private async handleUploadCsv(args: any) { // Validate input const schema = z.object({ filePath: z.string(), }); const { filePath } = schema.parse(args); try { // Read and parse CSV file const fileContent = fs.readFileSync(filePath, 'utf8'); const records = csvParse(fileContent, { columns: true, skip_empty_lines: true, }); // Validate records const validRoles = ['operational', 'developer', 'manager', 'VP', 'VIP']; if (!records.length) { throw new McpError(ErrorCode.InvalidParams, 'CSV file is empty.'); } // Check required columns const firstRecord = records[0]; if (!('email' in firstRecord)) { throw new McpError(ErrorCode.InvalidParams, 'CSV file must have an "email" column.'); } // Map existing emails to records const employeesByEmail = new Map( this.state.employees.map((e) => [e.email, e]) ); // Update or create employee records const updatedEmployees = records.map((record: any) => { if (!record.email) { throw new McpError(ErrorCode.InvalidParams, 'Every row must have an email.'); } const existingEmployee = employeesByEmail.get(record.email); if (!existingEmployee) { throw new McpError( ErrorCode.InvalidParams, `Email ${record.email} does not match any generated wallet. Please generate wallets first.` ); } if (record.role && !validRoles.includes(record.role.toLowerCase())) { throw new McpError( ErrorCode.InvalidParams, `Invalid role "${record.role}" for ${record.email}. Valid roles are: ${validRoles.join(', ')}.` ); } return { ...existingEmployee, name: record.name || undefined, role: record.role?.toLowerCase() || undefined, }; }); // Update state this.state.employees = updatedEmployees; return { content: [ { type: 'text', text: ` CSV data processed successfully. Updated ${updatedEmployees.length} employee records. Role distribution: - Operational: ${updatedEmployees.filter((e: any) => e.role === 'operational').length} - Developer: ${updatedEmployees.filter((e: any) => e.role === 'developer').length} - Manager: ${updatedEmployees.filter((e: any) => e.role === 'manager').length} - VP: ${updatedEmployees.filter((e: any) => e.role === 'vp').length} - VIP: ${updatedEmployees.filter((e: any) => e.role === 'vip').length} - No role: ${updatedEmployees.filter((e: any) => !e.role).length} Next step: Calculate token amounts for each employee. `.trim(), }, ], }; } catch (error) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Failed to process CSV: ${error instanceof Error ? error.message : String(error)}` ); } } private async handleCalculateAmounts(args: any) { if (this.state.employees.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No employees added. Please generate wallets first.' ); } // Validate input const schema = z.object({ uniformAmount: z.number().positive().optional(), roleAmounts: z .object({ operational: z.number().positive().optional(), developer: z.number().positive().optional(), manager: z.number().positive().optional(), VP: z.number().positive().optional(), VIP: z.number().positive().optional(), }) .optional(), }); const { uniformAmount, roleAmounts } = schema.parse(args); // Set default role amounts if not provided const defaultRoleTokens = { operational: 100, developer: 200, manager: 300, vp: 400, vip: 500, }; // Combine with provided role amounts const roleTokens: Record<string, number> = { ...defaultRoleTokens, ...(roleAmounts || {}), }; let totalAmount = 0; // Update employee records with token amounts this.state.employees = this.state.employees.map((employee) => { let tokenAmount: number; if (uniformAmount) { // Use uniform amount for all employees tokenAmount = uniformAmount; } else if (employee.role) { // Use role-based amount if role is available const role = employee.role.toLowerCase(); if (role === 'operational') tokenAmount = roleTokens.operational || 100; else if (role === 'developer') tokenAmount = roleTokens.developer || 200; else if (role === 'manager') tokenAmount = roleTokens.manager || 300; else if (role === 'vp') tokenAmount = roleTokens.vp || 400; else if (role === 'vip') tokenAmount = roleTokens.vip || 500; else tokenAmount = 100; // Default fallback } else { // Default amount if no role specified tokenAmount = 100; } totalAmount += tokenAmount; return { ...employee, tokenAmount, }; }); return { content: [ { type: 'text', text: ` Token amounts calculated successfully: ${this.state.employees .map( (employee) => `- ${employee.name || employee.email}: ${employee.tokenAmount} tokens (${ employee.role || 'No role' })` ) .join('\n')} Total tokens to be distributed: ${totalAmount} ${ this.state.createdToken ? `Token supply: ${this.state.createdToken.supply}` : 'No token created yet. Please create a token with sufficient supply.' } Next step: Calculate gas fees for the airdrop. `.trim(), }, ], }; } private async handleCalculateFees() { if (this.state.employees.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No employees added. Please generate wallets first.' ); } if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } const employeeCount = this.state.employees.length; // Calculate fees based on the requirements const accountCreationFee = 0.00001 * employeeCount; // ~0.00001 SOL per account const transactionFee = 0.000005 * employeeCount; // ~0.000005 SOL per signature const totalFees = accountCreationFee + transactionFee; // Check if we have enough SOL const hasSufficientFunds = this.state.connectedWallet.solBalance >= totalFees; return { content: [ { type: 'text', text: ` Gas fee calculation for ${employeeCount} employees: Account Creation: ${accountCreationFee.toFixed(6)} SOL (~0.00001 SOL per account) Transaction Fees: ${transactionFee.toFixed(6)} SOL (~0.000005 SOL per signature) Total Fees: ${totalFees.toFixed(6)} SOL Your wallet balance: ${this.state.connectedWallet.solBalance.toFixed(6)} SOL Status: ${hasSufficientFunds ? 'Sufficient funds for fees' : 'Insufficient funds for fees'} ${ !hasSufficientFunds ? 'WARNING: Your wallet does not have enough SOL to cover the gas fees. Please add more SOL to your wallet before proceeding.' : 'Next step: Start the airdrop process.' } `.trim(), }, ], }; } private async handleStartAirdrop() { if (this.state.employees.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No employees added. Please generate wallets first.' ); } if (!this.state.connectedWallet) { throw new McpError( ErrorCode.InvalidRequest, 'No wallet connected. Please connect a wallet first.' ); } if (!this.state.createdToken) { throw new McpError( ErrorCode.InvalidRequest, 'No token created. Please create a token first.' ); } // Check if token amounts are calculated const missingAmounts = this.state.employees.some((e) => e.tokenAmount === undefined); if (missingAmounts) { throw new McpError( ErrorCode.InvalidRequest, 'Token amounts not calculated for all employees. Please calculate amounts first.' ); } // Check if Helius API key is available in environment variables if (!process.env.HELIUS_API_KEY) { return { content: [ { type: 'text', text: `For optimal performance with compressed airdrops, I recommend using a Helius API key. You can get one from https://www.helius.dev/ Please provide your Helius API key to continue, or type "skip" to proceed without one.`, }, ], }; } try { // Create a Solana connection const connection = new Connection( 'https://api.mainnet-beta.solana.com', 'confirmed' ); // Create a keypair from the connected wallet (in a real implementation) // For this demo, we'll use a generated keypair const keypair = Keypair.generate(); // Prepare recipients for the airdrop const recipients = this.state.employees.map(employee => ({ address: employee.walletAddress, email: employee.email })); // Use the ZK light protocol to perform the airdrop // In a real implementation, this would call the actual sendCompressedAirdrop function // For this demo, we'll simulate the airdrop // Use each employee's calculated token amount const result = await sendCompressedAirdrop( connection, keypair, this.state.createdToken.mintAddress, 0, // Not used - individual amounts are in the employees array this.state.createdToken.decimals, this.state.employees.map(employee => ({ address: employee.walletAddress, email: employee.email, amount: employee.tokenAmount || 100 // Use calculated amount or default to 100 })), process.env.HELIUS_API_KEY // Use Helius API key for compressed transactions if available ); // Update airdrop status this.state.airdropStatus = { started: true, completed: true, successful: this.state.employees.length, failed: 0, }; } catch (error) { throw new McpError( ErrorCode.InternalError, `Airdrop failed: ${error instanceof Error ? error.message : String(error)}` ); } return { content: [ { type: 'text', text: ` Airdrop completed successfully: - Total employees: ${this.state.employees.length} - Successful transfers: ${this.state.airdropStatus.successful} - Failed transfers: ${this.state.airdropStatus.failed} Each employee has received their allocated tokens: ${this.state.employees .map( (employee) => `- ${employee.name || employee.email}: ${employee.tokenAmount} ${ this.state.createdToken?.symbol } tokens` ) .join('\n')} Next step: Send emails to employees with wallet access instructions. `.trim(), }, ], }; } private async handleSendEmails(args: any) { if (this.state.employees.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No employees added. Please generate wallets first.' ); } if (!this.state.airdropStatus.completed) { throw new McpError( ErrorCode.InvalidRequest, 'Airdrop not completed. Please start the airdrop first.' ); } // Validate input const schema = z.object({ fromEmail: z.string(), subject: z.string().default('Your token airdrop is ready!'), resendApiKey: z.string().optional(), }); const { fromEmail, subject, resendApiKey: providedApiKey } = schema.parse(args); // Check if Resend API key is provided, if not, prompt the user if (!providedApiKey) { return { content: [ { type: 'text', text: `To send emails to employees, I need your Resend API key. You can get this from the Resend dashboard at https://resend.com/ Please provide your Resend API key to continue.`, }, ], }; } // In a real implementation, we would send actual emails // For this demo, we'll simulate sending emails // Prepare employee data for emails const employeesWithTokenInfo = this.state.employees.map(employee => ({ ...employee, tokenSymbol: this.state.createdToken?.symbol })); // Use Resend email service via crossmintUtils const emailResult = await sendWalletEmails( employeesWithTokenInfo, { fromEmail, subject, apiKey: providedApiKey, // Use provided API key if available } ); const { successful, failed } = emailResult; // Update state this.state.emailsStatus = { sent: true, successful, failed, }; return { content: [ { type: 'text', text: ` Emails sent to employees: - Total emails: ${this.state.employees.length} - Successfully sent: ${successful} - Failed: ${failed} The emails contain instructions for employees to access their Crossmint custodial wallets and view their tokens. Process completed successfully! `.trim(), }, ], }; } private async handleGetState() { return { content: [ { type: 'text', text: ` Current Airdrop State: - Connected Wallet: ${this.state.connectedWallet ? 'Yes' : 'No'}${ this.state.connectedWallet ? `\n Public Key: ${this.state.connectedWallet.publicKey}\n SOL Balance: ${this.state.connectedWallet.solBalance} SOL` : '' } - Created Token: ${this.state.createdToken ? 'Yes' : 'No'}${ this.state.createdToken ? `\n Name: ${this.state.createdToken.name}\n Symbol: ${this.state.createdToken.symbol}\n Supply: ${this.state.createdToken.supply.toLocaleString()}\n Mint Address: ${this.state.createdToken.mintAddress}` : '' } - Employees: ${this.state.employees.length} - Airdrop Status: ${this.state.airdropStatus.completed ? 'Completed' : this.state.airdropStatus.started ? 'In Progress' : 'Not Started'} - Emails Status: ${this.state.emailsStatus.sent ? 'Sent' : 'Not Sent'} `.trim(), }, ], }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('HR Airdrop MCP server running on stdio'); } } // Start the server const server = new HRAirdropServer(); server.run().catch(console.error);

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/monostate/Employees-Airdrop-Rewards-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server