Skip to main content
Glama
server.ts.backup-20250930-08134775.9 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; import { UserSessionManager } from './user-session-manager.js'; import { QueryParamsSchema, BudgetParamsSchema, UMBRELLA_ENDPOINTS } from './types.js'; import { readFileSync } from 'fs'; import { resolve } from 'path'; export class UmbrellaMcpServer { private server: Server; private sessionManager: UserSessionManager; private baseURL: string; private initPromptGuidelines: string; private noAuthTools: boolean = false; private noAuthMode: boolean = false; constructor(baseURL: string) { this.baseURL = baseURL; this.sessionManager = new UserSessionManager(baseURL); this.initPromptGuidelines = this.loadInitPromptGuidelines(); this.server = new Server( { name: process.env.MCP_SERVER_NAME || 'Umbrella MCP', version: process.env.MCP_SERVER_VERSION || '1.0.0', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); } private loadInitPromptGuidelines(): string { try { const initPromptPath = resolve(process.cwd(), 'init_prompt.txt'); return readFileSync(initPromptPath, 'utf-8'); } catch (error) { console.error('⚠️ Warning: Could not load init_prompt.txt, using default guidance'); return 'Default Umbrella Cost API guidelines apply.'; } } private setupToolHandlers(): void { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { const authTool: Tool = { name: 'authenticate_user', description: 'Authenticate with Umbrella Cost API using username and password (multi-tenant)', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Your Umbrella Cost username (email address)', }, password: { type: 'string', description: 'Your Umbrella Cost password', }, sessionId: { type: 'string', description: 'Optional: Custom session identifier (defaults to username-based ID)', }, }, required: ['username', 'password'], }, }; const logoutTool: Tool = { name: 'logout', description: 'Log out and remove user session', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to logout (optional - will logout current session if not provided)', }, }, required: [], }, }; const sessionStatusTool: Tool = { name: 'session_status', description: 'Check authentication status and session information', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to check status for (optional)', }, }, required: [], }, }; const apiTools = UMBRELLA_ENDPOINTS.map((endpoint) => { // Strip leading slash to avoid triple underscores const path = endpoint.path.startsWith('/') ? endpoint.path.substring(1) : endpoint.path; return { name: `api__${path.replace(/\//g, '_').replace(/[-]/g, '_')}`, description: `${endpoint.description} (${endpoint.category})`, inputSchema: { type: 'object', properties: { ...(endpoint.parameters && Object.keys(endpoint.parameters).length > 0 ? Object.entries(endpoint.parameters).reduce((acc: any, [key, desc]) => { acc[key] = { type: 'string', description: desc as string, }; return acc; }, {}) : {}), }, required: [], }, }; }); const listEndpointsTool: Tool = { name: 'list_endpoints', description: 'List all available Umbrella Cost API endpoints with their descriptions', inputSchema: { type: 'object', properties: {}, required: [], }, }; const helpTool: Tool = { name: 'help', description: 'Get help and usage information for the Umbrella MCP server', inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Specific help topic (authentication, endpoints, parameters)', }, }, required: [], }, }; // In no-auth mode (HTTPS with OAuth), exclude authentication tools const tools = this.noAuthTools ? [listEndpointsTool, helpTool, ...apiTools] : [authTool, logoutTool, sessionStatusTool, listEndpointsTool, helpTool, ...apiTools]; return { tools, }; }); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; console.error(`[DEBUG-HANDLER] CallTool received: ${name} with args:`, JSON.stringify(args)); try { // Authentication tools (skip if in no-auth mode) if (!this.noAuthTools) { if (name === 'authenticate_user') { return await this.handleAuthenticateUser(args as any); } if (name === 'logout') { return this.handleLogout(args as any); } if (name === 'session_status') { return this.handleSessionStatus(args as any); } } // List endpoints tool if (name === 'list_endpoints') { return this.handleListEndpoints(); } // Help tool if (name === 'help') { return this.handleHelp(args?.topic as string); } // API endpoint tools if (name.startsWith('api__')) { const toolNamePart = name.replace('api__', ''); const endpoint = UMBRELLA_ENDPOINTS.find(ep => { // Strip leading slash to match registration const path = ep.path.startsWith('/') ? ep.path.substring(1) : ep.path; const expectedToolName = `api__${path.replace(/\//g, '_').replace(/[-]/g, '_')}`; return expectedToolName === name; }); if (!endpoint) { return { content: [ { type: 'text', text: `Error: Unknown tool "${name}". Use "list_endpoints" to see available endpoints.`, }, ], }; } return await this.handleApiCall(endpoint.path, args as any); } return { content: [ { type: 'text', text: `Error: Unknown tool "${name}". Use "help" to see available tools.`, }, ], }; } catch (error: any) { return { content: [ { type: 'text', text: `Error: ${error.message}`, }, ], }; } }); } private async handleAuthenticateUser(args: any) { try { // CRITICAL FIX: Check for existing OAuth session first! const currentSession = this.sessionManager.getCurrentSession(); if (currentSession && currentSession.isAuthenticated) { // User is already authenticated via OAuth - return success immediately const stats = this.sessionManager.getStats(); return { content: [ { type: 'text', text: `✅ Already authenticated via OAuth as ${currentSession.username}\n\n**Session ID:** ${currentSession.id}\n\nYour OAuth session is active and ready.\n\nYou can now use the API tools to query your Umbrella Cost data. Use "list_endpoints" to see available endpoints.\n\n📊 **Server Status:** ${stats.activeSessions} active session(s)\n\n## 📋 Umbrella Cost API Guidelines\n\n${this.initPromptGuidelines}`, }, ], }; } // No OAuth session, check if credentials were provided if (!args.username || !args.password) { return { content: [ { type: 'text', text: `❌ Authentication failed: Both username and password are required.\n\nPlease provide your Umbrella Cost credentials:\n- Username: Your email address\n- Password: Your account password`, }, ], }; } const credentials = { username: args.username, password: args.password }; const result = await this.sessionManager.authenticateUser(credentials, args.sessionId); if (result.success) { const stats = this.sessionManager.getStats(); return { content: [ { type: 'text', text: `✅ Successfully authenticated as ${args.username}\n\n**Session ID:** ${result.sessionId}\n\nYour credentials have been securely processed and a temporary access token has been obtained.\n\nYou can now use the API tools to query your Umbrella Cost data. Use "list_endpoints" to see available endpoints.\n\n📊 **Server Status:** ${stats.activeSessions} active session(s)\n\n## 📋 Umbrella Cost API Guidelines\n\nFor this session, please follow these guidelines for all future responses:\n\n${this.initPromptGuidelines}`, }, ], }; } else { return { content: [ { type: 'text', text: `❌ Authentication failed: ${result.error}\n\nPlease verify your credentials and try again:\n- Ensure your username is your full email address\n- Check your password is correct\n- Confirm your account has API access permissions\n\n**Note:** Your credentials are only used to obtain a secure token and are not stored.`, }, ], }; } } catch (error: any) { return { content: [ { type: 'text', text: `❌ Authentication error: ${error.message}`, }, ], }; } } private handleLogout(args: any) { try { const stats = this.sessionManager.getStats(); if (args.username) { const removed = this.sessionManager.removeUserSession(args.username); if (removed) { return { content: [ { type: 'text', text: `✅ Successfully logged out user: ${args.username}\n\n📊 **Server Status:** ${stats.activeSessions - 1} active session(s) remaining`, }, ], }; } else { return { content: [ { type: 'text', text: `⚠️ No active session found for user: ${args.username}`, }, ], }; } } else { const activeSessions = this.sessionManager.getActiveSessions(); if (activeSessions.length === 0) { return { content: [ { type: 'text', text: `ℹ️ No active sessions to logout.`, }, ], }; } else { let output = `📊 **Active Sessions (${activeSessions.length}):**\n\n`; activeSessions.forEach(session => { output += `- **${session.username}** (${session.id})\n`; output += ` Last activity: ${session.lastActivity.toISOString()}\n\n`; }); output += `Use \`logout(username="user@email.com")\` to logout a specific user.`; return { content: [ { type: 'text', text: output, }, ], }; } } } catch (error: any) { return { content: [ { type: 'text', text: `❌ Logout error: ${error.message}`, }, ], }; } } private handleSessionStatus(args: any) { try { const stats = this.sessionManager.getStats(); if (args.username) { const session = this.sessionManager.getUserSessionByUsername(args.username); if (session) { return { content: [ { type: 'text', text: `✅ **Session Status for ${args.username}:**\n\n- **Status:** Authenticated\n- **Session ID:** ${session.id}\n- **Created:** ${session.createdAt.toISOString()}\n- **Last Activity:** ${session.lastActivity.toISOString()}\n- **Active:** Yes`, }, ], }; } else { return { content: [ { type: 'text', text: `❌ **No active session found for:** ${args.username}\n\nUse \`authenticate_user\` to create a session.`, }, ], }; } } else { const activeSessions = this.sessionManager.getActiveSessions(); let output = `📊 **Multi-Tenant Server Status:**\n\n`; output += `- **Total Sessions:** ${stats.totalSessions}\n`; output += `- **Active Sessions:** ${stats.activeSessions}\n`; output += `- **Expired Sessions:** ${stats.expiredSessions}\n\n`; if (activeSessions.length > 0) { output += `**Active Users:**\n`; activeSessions.forEach(session => { output += `- ${session.username} (active ${Math.round((new Date().getTime() - session.lastActivity.getTime()) / 1000 / 60)} min ago)\n`; }); } else { output += `**No active user sessions.**\n\nUse \`authenticate_user\` to create a session.`; } return { content: [ { type: 'text', text: output, }, ], }; } } catch (error: any) { return { content: [ { type: 'text', text: `❌ Session status error: ${error.message}`, }, ], }; } } private handleListEndpoints() { const endpointsByCategory = UMBRELLA_ENDPOINTS.reduce((acc: any, endpoint) => { if (!acc[endpoint.category]) { acc[endpoint.category] = []; } acc[endpoint.category].push(endpoint); return acc; }, {}); let output = '# Available Umbrella Cost API Endpoints\n\n'; Object.entries(endpointsByCategory).forEach(([category, endpoints]) => { output += `## ${category}\n\n`; (endpoints as any[]).forEach((endpoint) => { const toolName = `api_${endpoint.path.replace(/\//g, '_').replace(/[-]/g, '_')}`; output += `### ${toolName}\n`; output += `**Path:** \`${endpoint.method} ${endpoint.path}\`\n`; output += `**Description:** ${endpoint.description}\n`; if (endpoint.parameters && Object.keys(endpoint.parameters).length > 0) { output += `**Parameters:**\n`; Object.entries(endpoint.parameters).forEach(([param, desc]) => { output += `- \`${param}\`: ${desc}\n`; }); } output += '\n'; }); }); return { content: [ { type: 'text', text: output, }, ], }; } private handleHelp(topic?: string) { let helpText = ''; if (!topic || topic === 'general') { helpText = `# Umbrella MCP Server Help\n\n`; helpText += `This MCP server provides read-only access to the Umbrella Cost finops SaaS platform.\n\n`; helpText += `## Getting Started\n`; helpText += `1. First, authenticate using your Umbrella Cost credentials:\n`; helpText += ` \`\`\`\n authenticate(username="your-email@domain.com", password="your-password")\n \`\`\`\n\n`; helpText += `2. List available endpoints:\n`; helpText += ` \`\`\`\n list_endpoints()\n \`\`\`\n\n`; helpText += `3. Make API calls to retrieve your cloud cost data.\n\n`; helpText += `## Available Help Topics\n`; helpText += `- \`authentication\`: How to authenticate and manage credentials\n`; helpText += `- \`endpoints\`: Information about available API endpoints\n`; helpText += `- \`parameters\`: How to use query parameters\n\n`; } else if (topic === 'authentication') { helpText = `# Authentication Help\n\n`; helpText += `To use the Umbrella MCP server, you need to authenticate with your Umbrella Cost credentials.\n\n`; helpText += `## Authentication Process\n`; helpText += `1. Call the \`authenticate\` tool with your username and password\n`; helpText += `2. The server will obtain an authentication token from Umbrella Cost\n`; helpText += `3. The token will be used automatically for all subsequent API calls\n\n`; helpText += `## Security Notes\n`; helpText += `- Your credentials are only used to obtain a token and are not stored\n`; helpText += `- All API access is read-only for security\n`; helpText += `- Tokens expire after a certain time and you may need to re-authenticate\n\n`; } else if (topic === 'endpoints') { helpText = `# API Endpoints Help\n\n`; helpText += `The Umbrella MCP server provides access to various Umbrella Cost API endpoints organized by category:\n\n`; helpText += `## Categories\n`; helpText += `- **Cost Analysis**: Core cost and usage data\n`; helpText += `- **Usage Analysis**: Detailed resource usage information\n`; helpText += `- **Recommendations**: Cost optimization suggestions\n`; helpText += `- **Anomaly Detection**: Cost anomaly identification\n`; helpText += `- **User Management**: User and customer information\n\n`; helpText += `Use \`list_endpoints()\` to see all available endpoints with descriptions.\n\n`; } else if (topic === 'parameters') { helpText = `# Query Parameters Help\n\n`; helpText += `Many API endpoints accept query parameters to filter and customize the results:\n\n`; helpText += `## Common Parameters\n`; helpText += `- \`startDate\`: Start date for queries (YYYY-MM-DD format)\n`; helpText += `- \`endDate\`: End date for queries (YYYY-MM-DD format)\n`; helpText += `- \`accountId\`: Filter by specific AWS account ID\n`; helpText += `- \`region\`: Filter by AWS region\n`; helpText += `- \`serviceNames\`: Filter by service names (array)\n`; helpText += `- \`limit\`: Limit the number of results returned\n`; helpText += `- \`offset\`: Skip a number of results (for pagination)\n\n`; } else { helpText = `Unknown help topic: "${topic}". Available topics: general, authentication, endpoints, parameters`; } return { content: [ { type: 'text', text: helpText, }, ], }; } private async handleApiCall(path: string, args: any) { try { console.error(`[API-CALL] ${path}`); // Determine which user to use let username = args?.username; let session = null; if (username) { session = this.sessionManager.getUserSessionByUsername(username); if (!session) { return { content: [ { type: 'text', text: `❌ No active session found for user: ${username}\n\nPlease authenticate first using \`authenticate_user\`.`, }, ], }; } } else { // In no-auth mode (OAuth), use getCurrentSession which checks for OAuth session if (this.noAuthMode) { session = this.sessionManager.getCurrentSession(); if (session) { username = session.username; } } else { // In regular mode, check active sessions const activeSessions = this.sessionManager.getActiveSessions(); if (activeSessions.length === 0) { return { content: [ { type: 'text', text: `❌ No authenticated users found.\n\nPlease authenticate first using \`authenticate_user(username="your-email@domain.com", password="your-password")\`.`, }, ], }; } else if (activeSessions.length === 1) { session = this.sessionManager.getUserSessionByUsername(activeSessions[0].username); username = activeSessions[0].username; } else { let output = `❌ Multiple users authenticated. Please specify which user:\n\n`; output += `**Active Users:**\n`; activeSessions.forEach(s => { output += `- ${s.username}\n`; }); output += `\n**Usage:** Add \`username="user@email.com"\` to your API call.`; return { content: [ { type: 'text', text: output, }, ], }; } } } if (!session) { return { content: [ { type: 'text', text: `❌ Unable to find authenticated session. Please authenticate first.`, }, ], }; } // Store original args for cloud context detection const originalArgs = args || {}; const cloudContext = originalArgs.cloud_context?.toLowerCase(); // Remove only username from args (keep cloud_context for API filtering) const { username: _, ...apiArgs } = args || {}; // Validate and parse query parameters const queryParams: any = {}; if (apiArgs) { Object.entries(apiArgs).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { // Handle boolean parameter conversion if (key === 'isUnblended' || key === 'isAmortized' || key === 'isShowAmortize' || key === 'isNetAmortized' || key === 'isNetUnblended' || key === 'isPublicCost' || key === 'isDistributed' || key === 'isListUnitPrice' || key === 'isSavingsCost' || key === 'isPpApplied' || key === 'isFull' || key === 'only_metadata') { if (typeof value === 'string') { queryParams[key] = value.toLowerCase() === 'true'; } else { queryParams[key] = !!value; } } // Handle costType parameter conversion else if (key === 'costType') { if (typeof value === 'string') { // Try to parse as JSON first (Desktop sends JSON strings like '["cost", "discount"]') if (value.startsWith('[') && value.endsWith(']')) { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { queryParams['costType'] = parsed; console.error(`[COSTTYPE-PARSE] Parsed JSON costType: "${value}" -> [${parsed.join(', ')}]`); } } catch (e) { // Not valid JSON, continue with other parsing methods console.error(`[COSTTYPE-PARSE] Failed to parse as JSON: "${value}"`); } } // Handle comma-separated cost types (Claude Desktop pattern: "cost,discount") else if (value.includes(',')) { queryParams['costType'] = value.split(',').map(t => t.trim()); console.error(`[COSTTYPE-PARSE] Parsed comma-separated costType: "${value}" -> [${queryParams['costType'].join(', ')}]`); } else { const costType = value.toLowerCase().trim(); switch (costType) { case 'amortized': queryParams['isAmortized'] = true; break; case 'net amortized': case 'netamortized': case 'net_amortized': case 'net_amortized_cost': queryParams['isNetAmortized'] = true; break; case 'net unblended': case 'netunblended': queryParams['isNetUnblended'] = true; break; case 'unblended': // Pass unblended as costType parameter queryParams['costType'] = ['unblended']; break; default: // For other cost types, pass them as-is queryParams['costType'] = [value]; break; } } } else if (Array.isArray(value)) { queryParams['costType'] = value; } } // Handle service filtering with intelligent matching (convert filters object to API format) else if (key === 'filters' && typeof value === 'object' && value !== null && path === '/v1/invoices/caui') { const filters = value as any; // Handle service filter with dynamic discovery if (filters.service) { // Store the original service name for intelligent matching later queryParams['_originalServiceFilter'] = filters.service; queryParams['filters[service]'] = filters.service; console.error(`[SERVICE-FILTER] Processing service filter: ${filters.service}`); } // Handle any other filter types generically Object.entries(filters).forEach(([filterKey, filterValue]) => { if (filterKey !== 'service') { queryParams[`filters[${filterKey}]`] = filterValue; console.error(`[FILTER] Adding filter: filters[${filterKey}]=${filterValue}`); } }); } // Handle direct service parameter - use filters[service] for API else if (key === 'service' && path === '/v1/invoices/caui') { // Map service name to API format if needed const serviceMappings: Record<string, string> = { 'CloudWatch': 'AmazonCloudWatch', 'EC2': 'Amazon Elastic Compute Cloud', 'S3': 'Amazon Simple Storage Service', 'RDS': 'Amazon Relational Database Service', 'Lambda': 'AWS Lambda', 'DynamoDB': 'Amazon DynamoDB' }; const mappedService = serviceMappings[value as string] || value; queryParams['groupBy'] = 'service'; queryParams['filters[service]'] = mappedService; console.error(`[SERVICE-FILTER] Using filters[service]=${mappedService} for API`); } // Handle other parameters else { queryParams[key] = value; } } }); } // Separate filter parameters from regular params for validation const filterParams: Record<string, any> = {}; const regularParams: Record<string, any> = {}; const specialParams: Record<string, any> = {}; // For params that shouldn't be validated Object.entries(queryParams).forEach(([key, value]) => { if (key.startsWith('filters[')) { filterParams[key] = value; } else { regularParams[key] = value; } }); // Validate regular params and add filter params and special params back let validatedParams: any; // Use different schema validation based on API path if (path === '/v1/budgets/v2/i/') { // Use budget-specific validation for budget APIs try { validatedParams = { ...BudgetParamsSchema.partial().parse(regularParams), ...filterParams, ...specialParams }; console.error(`[BUDGET-API] Using budget parameter validation for path: ${path}`); } catch (error: any) { console.error(`[BUDGET-API-VALIDATION-ERROR] ${JSON.stringify(error.issues || error, null, 2)}`); throw error; } } else { // Use general query parameter validation for other APIs validatedParams = { ...QueryParamsSchema.partial().parse(regularParams), ...filterParams, ...specialParams }; } // CLAUDE DESKTOP CLIENT COMPATIBILITY: Log parameter patterns for debugging if (path === '/v1/invoices/caui') { console.error(`[CLAUDE-DESKTOP-PARAMS] Received: costType="${originalArgs.costType}", isUnblended="${originalArgs.isUnblended}", isAmortized="${originalArgs.isAmortized}", isNetAmortized="${originalArgs.isNetAmortized}"`); } // DISABLED: Account auto-selection to prevent session corruption console.error('[CLOUD-CONTEXT] Account auto-selection disabled to preserve recommendations'); // Customer name recognition for MSP users (before adding defaults) const currentSession = this.sessionManager.getCurrentSession(); if (currentSession?.isAuthenticated) { // ALWAYS run customer detection first, regardless of provided parameters console.error(`[MSP-CUSTOMER-DETECTION] Claude provided customer_account_key: ${validatedParams.customer_account_key || 'none'}, but will run detection anyway`); const customerInfo = await this.detectCustomerFromQuery(args.userQuery || '', path, validatedParams, currentSession); if (customerInfo) { console.error(`[MSP-CUSTOMER-DETECTION] Detection found: accountKey=${customerInfo.accountKey}, division=${customerInfo.divisionId} from query: "${args.userQuery || ''}"`); // Override any provided customer parameters with detected ones validatedParams.customer_account_key = customerInfo.accountKey; validatedParams.customer_division_id = customerInfo.divisionId; // Clean up conflicting parameters delete validatedParams.accountId; delete validatedParams.divisionId; console.error(`[MSP-CUSTOMER-DETECTION] Final params: customer_account_key=${customerInfo.accountKey}, customer_division_id=${customerInfo.divisionId}`); } else { console.error(`[MSP-CUSTOMER-DETECTION] No customer detected from query: "${args.userQuery || ''}", keeping provided params`); } } // Add default parameters for cost API (skip for MSP customers using frontend API) if (path === '/v1/invoices/caui' && !validatedParams.customer_account_key) { console.error(`[DIRECT-CUSTOMER] Processing direct customer request (not MSP)`); // Default groupBy: 'none' (per requirements) if (!validatedParams.groupBy) { validatedParams.groupBy = 'none'; console.error(`[GROUPBY-DEFAULT] Using default groupBy: none`); } // Default time aggregation: 'day' (per requirements) if (!validatedParams.periodGranLevel) { validatedParams.periodGranLevel = 'day'; console.error(`[PERIOD-DEFAULT] Using default periodGranLevel: day`); } // Default cost calculation: isUnblended=true (per requirements) if (!validatedParams.isAmortized && !validatedParams.isNetAmortized && !validatedParams.isUnblended) { validatedParams.isUnblended = true; console.error(`[COST-CALC-DEFAULT] No cost calculation specified -> isUnblended: true`); } // Default cost types - preserve user's costType if provided // Match Claude Desktop's pattern: cost,discount (tax excluded) if (!validatedParams.costType) { validatedParams.costType = ['cost', 'discount']; console.error(`[COSTTYPE-DEFAULT] Using default costType: ["cost", "discount"] (tax excluded)`); } else if (typeof validatedParams.costType === 'string') { // Convert single string to array validatedParams.costType = [validatedParams.costType]; } // Default tax exclusion for all cloud providers (exclude tax by default to match UI) if (!validatedParams.excludeFilters) { validatedParams.excludeFilters = { chargetype: ['Tax'] }; console.error(`[TAX-EXCLUSION] Excluding tax by default to match UI behavior (cloud: ${cloudContext || 'unknown'})`); } // Log final parameters for direct customers console.error(`[DIRECT-CUSTOMER-PARAMS] Final params:`, JSON.stringify(validatedParams, null, 2)); } else if (path === '/v1/invoices/caui' && validatedParams.customer_account_key) { // MSP customer using frontend API - apply UI-compatible defaults console.error(`[MSP-FRONTEND] Customer ${validatedParams.customer_account_key} - applying UI-compatible defaults`); // Default groupBy for MSP customers to match UI behavior if (!validatedParams.groupBy) { validatedParams.groupBy = 'none'; console.error(`[MSP-GROUPBY] Using default groupBy: none`); } // Default periodGranLevel for monthly breakdown if (!validatedParams.periodGranLevel) { validatedParams.periodGranLevel = 'month'; console.error(`[MSP-PERIOD] Using periodGranLevel: month`); } // Default cost types to match UI: cost,discount (not NET_AMORTIZED) if (!validatedParams.costType) { validatedParams.costType = ['cost', 'discount']; console.error(`[MSP-COSTTYPE] Using UI-compatible costType: ["cost", "discount"]`); } else if (typeof validatedParams.costType === 'string') { // Convert single string to array validatedParams.costType = [validatedParams.costType]; } // Exclude tax to match UI behavior if (!validatedParams.excludeFilters) { validatedParams.excludeFilters = { chargetype: ['Tax'] }; console.error(`[MSP-TAX] Excluding tax to match UI behavior`); } // Convert isNetAmortized to costType for UI compatibility if (validatedParams.isNetAmortized) { delete validatedParams.isNetAmortized; // If costType wasn't already set, use cost,discount if (!validatedParams.costType) { validatedParams.costType = ['cost', 'discount']; console.error(`[MSP-COSTCALC] Converted isNetAmortized to costType: ["cost", "discount"]`); } } } // Add default parameters for budget API if (path === '/v1/budgets/v2/i/') { console.error(`[BUDGET-API-PARAMS] Processing budget API request with cloud_context: ${cloudContext}`); // Default only_metadata=true for performance (like browser) if (validatedParams.only_metadata === undefined) { validatedParams.only_metadata = true; console.error(`[BUDGET-METADATA] Using default only_metadata: true`); } else { console.error(`[BUDGET-METADATA] Set only_metadata: ${validatedParams.only_metadata}`); } // Handle cloud context filtering for budget API if (cloudContext) { // Budget API supports cloud_context parameter directly validatedParams.cloud_context = cloudContext; console.error(`[BUDGET-CLOUD-CONTEXT] Added cloud_context filter: ${cloudContext}`); } } // Make API request (recommendations use the same method now) let response; const paramsWithFilters = validatedParams as any; console.error('[DEBUG-FLOW] Step 1: About to make API request to', path); response = await session.apiClient.makeRequest(path, paramsWithFilters); console.error('[DEBUG-FLOW] Step 2: API request completed, success:', response.success); if (response.success) { console.error('[DEBUG-FLOW] Step 3: Starting response formatting'); let output = `# API Response: ${path}\n\n`; output += `**Authenticated as:** ${username}\n`; output += `**Session ID:** ${session.id}\n\n`; output += `**Status:** ✅ Success\n\n`; console.error('[DEBUG-FLOW] Step 4: Basic output header created'); if (response.data) { // Special formatting for recommendations if (Array.isArray(response.data) && path.includes('recommendations')) { const recommendations = response.data; const totalSavings = recommendations.reduce((sum: number, rec: any) => sum + (rec.annual_savings || 0), 0); const byCategory = response.category_breakdown || {}; const actualTotalSavings = response.total_potential_savings || totalSavings; output += `**Results:** ${response.total_count || recommendations.length} recommendations\n`; output += `**Total Potential Annual Savings:** $${actualTotalSavings.toLocaleString()}\n\n`; if (Object.keys(byCategory).length > 0) { output += '**💰 Breakdown by Category:**\n'; Object.entries(byCategory) .sort(([, a]: any, [, b]: any) => b.amount - a.amount) .forEach(([category, data]: any) => { const percentage = ((data.amount / actualTotalSavings) * 100).toFixed(1); output += `- **${category}**: $${data.amount.toLocaleString()} (${data.count} recs, ${percentage}%)\n`; }); } if (recommendations.length > 0) { output += '\n**🔝 Top 10 Recommendations:**\n'; const topRecs = recommendations .sort((a: any, b: any) => (b.annual_savings || 0) - (a.annual_savings || 0)) .slice(0, 10); topRecs.forEach((rec: any, i: number) => { const monthly = Math.round((rec.annual_savings || 0) / 12); output += `${i + 1}. **${rec.type}** - ${rec.resource_name || rec.resource_id}\n`; output += ` 💰 $${(rec.annual_savings || 0).toLocaleString()}/year ($${monthly}/month)\n`; output += ` 🔧 ${rec.service} | ${rec.recommended_action}\n\n`; }); } } // Special formatting for plain-sub-users endpoint (customer divisions) else if (path === '/v1/users/plain-sub-users' && response.data?.customerDivisions) { console.error('[DEBUG-FLOW] Step 6: Processing customer divisions'); const customerDivisions = response.data.customerDivisions; console.error('[DEBUG-FLOW] Step 7: Got customerDivisions object'); const allCustomers = Object.keys(customerDivisions); console.error(`[DEBUG-FLOW] Step 8: Found ${allCustomers.length} total customers`); // Filter customers if userQuery is provided let filteredCustomers = allCustomers; if (args?.userQuery) { const queryLower = args.userQuery.toLowerCase(); filteredCustomers = allCustomers.filter(customerName => customerName.toLowerCase().includes(queryLower) ); // If no exact match, try partial matches if (filteredCustomers.length === 0) { const queryWords = queryLower.split(/\s+/); filteredCustomers = allCustomers.filter(customerName => queryWords.some((word: string) => customerName.toLowerCase().includes(word)) ); } } output += `**🏢 Customer Divisions (${filteredCustomers.length} of ${allCustomers.length} total):**\n\n`; if (filteredCustomers.length === 0) { output += `No customers found matching "${args?.userQuery || ''}"\n`; } else if (filteredCustomers.length > 50 && !args?.userQuery) { // If too many customers and no filter, show summary output += `**Too many customers to display (${allCustomers.length} total)**\n\n`; output += `Please specify a customer name using \`userQuery\` parameter.\n\n`; output += `**Sample customers:**\n`; allCustomers.slice(0, 20).forEach((customerName, i) => { const divisions = customerDivisions[customerName]; const divCount = Array.isArray(divisions) ? divisions.length : 0; output += `${i + 1}. ${customerName} (${divCount} division${divCount !== 1 ? 's' : ''})\n`; }); output += `\n... and ${allCustomers.length - 20} more customers\n`; } else { console.error('[DEBUG-FLOW] Step 13c: Showing filtered customers'); // Show filtered customers with their accounts filteredCustomers.forEach((customerName, index) => { console.error(`[DEBUG-FLOW] Step 14: Processing customer ${index + 1}/${filteredCustomers.length}: ${customerName}`); const divisions = customerDivisions[customerName]; const divisionData = Array.isArray(divisions) ? divisions : []; output += `**${index + 1}. ${customerName}** (${divisionData.length} accounts)\n`; if (divisionData.length > 0) { divisionData.slice(0, 5).forEach((div: any) => { output += ` - Account: ${div.accountName || 'N/A'} (Key: ${div.accountKey}, Division: ${div.divisionId})\n`; }); if (divisionData.length > 5) { output += ` ... and ${divisionData.length - 5} more accounts\n`; } } output += '\n'; }); } } // Special formatting for budget endpoint else if (path === '/v1/budgets/v2/i/' && Array.isArray(response.data)) { const budgets = response.data; output += `**💰 AWS Budgets (${budgets.length} found):**\n\n`; if (budgets.length === 0) { output += 'No budgets configured for your AWS account.\n\n'; } else { // Sort budgets by budget amount (largest first) const sortedBudgets = budgets.sort((a: any, b: any) => (b.budgetAmount || 0) - (a.budgetAmount || 0)); // Format each budget sortedBudgets.forEach((budget: any, index: number) => { const budgetName = budget.budgetName || `Budget #${index + 1}`; const budgetAmount = budget.budgetAmount || 0; const totalCost = budget.totalCost || 0; const forecastedCost = parseFloat(budget.totalForecastedCost || budget.totalForcasted || 0); const percentage = budgetAmount > 0 ? ((totalCost / budgetAmount) * 100) : 0; const forecastPercentage = budgetAmount > 0 ? ((forecastedCost / budgetAmount) * 100) : 0; // Status based on actual and forecasted spend let status = '🟢 On Track'; if (percentage > 100 || forecastPercentage > 120) { status = '🔴 Over Budget'; } else if (percentage > 80 || forecastPercentage > 100) { status = '🟡 Warning'; } output += `### ${index + 1}. **${budgetName}**\n`; output += `- **💰 Budget Amount:** $${budgetAmount.toLocaleString()}\n`; output += `- **📊 Actual Spend:** $${totalCost.toLocaleString()} (${percentage.toFixed(1)}% used)\n`; if (forecastedCost > 0) { output += `- **🔮 Forecasted:** $${forecastedCost.toLocaleString()} (${forecastPercentage.toFixed(1)}% of budget)\n`; } output += `- **📈 Status:** ${status}\n`; // Period information if (budget.startDate && budget.endDate) { const startDate = new Date(budget.startDate).toLocaleDateString(); const endDate = new Date(budget.endDate).toLocaleDateString(); output += `- **📅 Period:** ${startDate} → ${endDate}\n`; } // Cost type information const costTypeLabel = budget.isAmortized ? 'Amortized' : budget.costType || 'Unblended'; output += `- **💳 Cost Type:** ${costTypeLabel}\n`; // Budget type output += `- **🔄 Type:** ${budget.budgetType || 'N/A'} (${budget.budgetAmountType || 'fixed'})\n`; // Account information if (budget.accountId) { output += `- **☁️ Account:** ${budget.accountId}\n`; } // Alerts if (budget.alerts && budget.alerts.length > 0) { output += `- **🚨 Alerts:** ${budget.alerts.length} configured\n`; } else { output += `- **🚨 Alerts:** None configured\n`; } // Description if (budget.description && budget.description.trim()) { output += `- **📝 Description:** ${budget.description}\n`; } // Include/Exclude filters summary if (budget.includeFilters && Object.keys(budget.includeFilters).length > 0) { const includeCount = Object.values(budget.includeFilters).flat().length; output += `- **✅ Include Filters:** ${includeCount} applied\n`; } if (budget.excludeFilters && Object.keys(budget.excludeFilters).length > 0) { const excludeCount = Object.values(budget.excludeFilters).flat().length; output += `- **❌ Exclude Filters:** ${excludeCount} applied (e.g., Tax excluded)\n`; } output += '\n'; }); // Summary statistics const totalBudgetAmount = budgets.reduce((sum: number, budget: any) => sum + (budget.budgetAmount || 0), 0); const totalActualSpend = budgets.reduce((sum: number, budget: any) => sum + (budget.totalCost || 0), 0); const totalForecastedSpend = budgets.reduce((sum: number, budget: any) => sum + parseFloat(budget.totalForecastedCost || budget.totalForcasted || 0), 0); const overallPercentage = totalBudgetAmount > 0 ? ((totalActualSpend / totalBudgetAmount) * 100) : 0; const forecastPercentage = totalBudgetAmount > 0 ? ((totalForecastedSpend / totalBudgetAmount) * 100) : 0; // Count budgets by status const onTrackCount = budgets.filter((b: any) => { const pct = (b.totalCost || 0) / (b.budgetAmount || 1) * 100; const fcst = parseFloat(b.totalForecastedCost || b.totalForcasted || 0) / (b.budgetAmount || 1) * 100; return pct <= 80 && fcst <= 100; }).length; const warningCount = budgets.filter((b: any) => { const pct = (b.totalCost || 0) / (b.budgetAmount || 1) * 100; const fcst = parseFloat(b.totalForecastedCost || b.totalForcasted || 0) / (b.budgetAmount || 1) * 100; return (pct > 80 && pct <= 100) || (fcst > 100 && fcst <= 120); }).length; const overBudgetCount = budgets.length - onTrackCount - warningCount; output += '---\n### 📊 **Summary Dashboard:**\n'; output += `- **💰 Total Budgeted:** $${totalBudgetAmount.toLocaleString()}\n`; output += `- **💸 Total Actual Spend:** $${totalActualSpend.toLocaleString()}\n`; if (totalForecastedSpend > 0) { output += `- **🔮 Total Forecasted:** $${totalForecastedSpend.toLocaleString()}\n`; } output += `- **📈 Overall Utilization:** ${overallPercentage.toFixed(1)}% actual`; if (forecastPercentage > 0) { output += `, ${forecastPercentage.toFixed(1)}% forecasted`; } output += '\n'; output += `- **🚦 Budget Health:** ${onTrackCount} on track, ${warningCount} warning, ${overBudgetCount} over budget\n\n`; } } // Special formatting for heatmap summary endpoint else if (path === '/v1/recommendationsNew/heatmap/summary' && typeof response.data === 'object') { const heatmapData = response.data; const potentialSavings = heatmapData.potentialAnnualSavings || 0; const actualSavings = heatmapData.actualAnnualSavings || 0; const potentialCount = heatmapData.potentialSavingsRecommendationCount || 0; const actualCount = heatmapData.actualSavingsRecommendationCount || 0; output += `**🎯 Recommendations Summary:**\n\n`; output += `**💰 Potential Annual Savings:** $${potentialSavings.toLocaleString()}\n`; output += `**📊 Potential Recommendations:** ${potentialCount}\n`; output += `**✅ Actual Annual Savings:** $${actualSavings.toLocaleString()}\n`; output += `**📈 Actual Recommendations:** ${actualCount}\n`; if (heatmapData.expectedSavingsRatePercent) { output += `**📋 Expected Savings Rate:** ${heatmapData.expectedSavingsRatePercent}%\n`; } if (heatmapData.effectiveSavingsRatePercent) { output += `**⚡ Effective Savings Rate:** ${heatmapData.effectiveSavingsRatePercent}%\n`; } output += '\n**Raw Data:**\n```json\n'; output += JSON.stringify(heatmapData, null, 2); output += '\n```\n'; } // Default formatting else if (Array.isArray(response.data)) { output += `**Results:** ${response.data.length} items\n\n`; if (response.data.length > 0) { output += '```json\n'; output += JSON.stringify(response.data, null, 2); output += '\n```\n'; } } else if (typeof response.data === 'object') { output += '**Data:**\n```json\n'; output += JSON.stringify(response.data, null, 2); output += '\n```\n'; } else { output += `**Data:** ${response.data}\n`; } } console.error('[DEBUG-FLOW] Step 18: About to return MCP response'); return { content: [ { type: 'text', text: output, }, ], }; } else { console.error('[DEBUG-FLOW] API request failed:', response.error); return { content: [ { type: 'text', text: `❌ API Error for ${username}: ${response.error}\n\n${response.message || ''}`, }, ], }; } } catch (error: any) { return { content: [ { type: 'text', text: `❌ Request Error: ${error.message}`, }, ], }; } } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error(`🚀 Umbrella MCP Server started successfully (Multi-Tenant)`); console.error(`📡 Base URL: ${this.baseURL}`); console.error(`🔒 Security: Read-only access enabled`); console.error(`👥 Multi-tenant: Concurrent user sessions supported`); console.error(`💡 Use "authenticate_user" to get started`); // Set up graceful shutdown process.on('SIGINT', () => { console.error('🛑 Received SIGINT, shutting down gracefully...'); this.shutdown(); process.exit(0); }); process.on('SIGTERM', () => { console.error('🛑 Received SIGTERM, shutting down gracefully...'); this.shutdown(); process.exit(0); }); } shutdown(): void { console.error('🔒 Shutting down Umbrella MCP Server...'); this.sessionManager.shutdown(); console.error('✅ Shutdown complete'); } /** * Detect customer names in queries and map them to account keys and division IDs (MSP users only) * This solves the issue where "Bank Leumi recommendations" needs to map to the correct account key */ private async detectCustomerFromQuery(userQuery: string, path: string, currentParams: any, session: any): Promise<{accountKey: string, divisionId: string} | null> { try { // Customer detection runs for any user that might have multiple customers // This is independent of authentication method (Keycloak vs Cognito) // Only process for recommendation or cost-related endpoints const isRelevantEndpoint = path.includes('/recommendations') || path.includes('/invoices/caui') || path.includes('/anomaly'); if (!isRelevantEndpoint) { return null; } // Always run customer detection if the query contains customer names // This allows the existing matching algorithm to find the best account const providedAccountKey = currentParams.customer_account_key; if (providedAccountKey) { console.error(`[CUSTOMER-DETECTION] Claude provided customer_account_key: ${providedAccountKey}, but will run detection anyway to find best match`); } console.error(`[CUSTOMER-DETECTION] Analyzing query for customer names: "${userQuery}"`); // Get MSP customers list for mapping const apiClient = session.apiClient; if (!apiClient) { console.error(`[CUSTOMER-DETECTION] No API client available`); return null; } // Get customer divisions with display names from plain_sub_users endpoint console.error(`[CUSTOMER-DETECTION] Getting customer divisions from plain_sub_users`); const divisionsResponse = await apiClient.makeRequest('/users/plain-sub-users'); let customers = []; if (divisionsResponse.success && divisionsResponse.data?.customerDivisions) { console.error(`[CUSTOMER-DETECTION] Found customerDivisions object with ${Object.keys(divisionsResponse.data.customerDivisions).length} customers`); console.error(`[CUSTOMER-DETECTION] Customer names: ${Object.keys(divisionsResponse.data.customerDivisions).slice(0, 5).join(', ')}...`); // Convert customerDivisions object to array for processing customers = Object.entries(divisionsResponse.data.customerDivisions).map(([customerDisplayName, divisionData]) => ({ customerDisplayName: customerDisplayName, // This is the customer name key like "Bank Leumi" divisionData: divisionData, // This contains array of account objects with accountKey fields })); console.error(`[CUSTOMER-DETECTION] Converted to ${customers.length} customer records`); } else { console.error(`[CUSTOMER-DETECTION] Failed to get customerDivisions: ${divisionsResponse.error || 'No customerDivisions in response'}`); return null; } // DYNAMIC customer detection - NO hardcoded patterns! const queryLower = userQuery.toLowerCase(); // Extract meaningful words from the query for matching against customer names const queryWords = queryLower.split(/[\s-]+/).filter((w: string) => w.length > 2); console.error(`[CUSTOMER-DETECTION] Query words (length > 2): [${queryWords.join(', ')}]`); // Debug: Show first few customers if (customers.length > 0) { console.error(`[CUSTOMER-DETECTION] Sample customers (first 3):`); customers.slice(0, 3).forEach((customer: any, index: number) => { const customerName = customer.customerDisplayName; const accountCount = Array.isArray(customer.divisionData) ? customer.divisionData.length : 0; const firstAccountKey = accountCount > 0 ? customer.divisionData[0].accountKey : 'N/A'; console.error(`[CUSTOMER-DETECTION] ${index + 1}. Customer: "${customerName}" (${accountCount} accounts, first: ${firstAccountKey})`); }); } // Find matching customer by comparing query words with customer names dynamically let matchingCustomer = null; let bestMatchScore = 0; for (const customer of customers) { const customerName = (customer.customerDisplayName || '').toLowerCase(); const customerWords = customerName.split(/[\s-]+/).filter((w: string) => w.length > 2); console.error(`[CUSTOMER-DETECTION] Checking customer "${customer.customerDisplayName}"`); console.error(`[CUSTOMER-DETECTION] Customer words: [${customerWords.join(', ')}]`); // Count word matches between query and customer name const matchingWords = customerWords.filter((customerWord: string) => queryWords.some((queryWord: string) => queryWord.includes(customerWord) || customerWord.includes(queryWord) || queryWord === customerWord ) ); console.error(`[CUSTOMER-DETECTION] Matching words: [${matchingWords.join(', ')}]`); // Calculate match score based on percentage of customer words matched const matchScore = customerWords.length > 0 ? matchingWords.length / customerWords.length : 0; console.error(`[CUSTOMER-DETECTION] Match score: ${matchScore} (${matchingWords.length}/${customerWords.length})`); // Require at least 50% of customer name words to match, or at least 1 word for short names const minRequiredMatches = Math.max(1, Math.ceil(customerWords.length * 0.5)); const hasGoodMatch = matchingWords.length >= minRequiredMatches; console.error(`[CUSTOMER-DETECTION] Has good match: ${hasGoodMatch} (need ${minRequiredMatches} matches)`); if (hasGoodMatch && matchScore > bestMatchScore) { matchingCustomer = customer; bestMatchScore = matchScore; console.error(`[CUSTOMER-DETECTION] 🎯 NEW BEST MATCH: "${customer.customerDisplayName}" (score: ${matchScore})`); } } if (matchingCustomer) { console.error(`[CUSTOMER-DETECTION] ✅ FINAL CUSTOMER MATCH: "${matchingCustomer.customerDisplayName}" (score: ${bestMatchScore})`); // Process the matched customer // Get account key and division ID from divisionData array let accountKey = null; let divisionId = null; let selectedAccount = null; if (matchingCustomer.divisionData && Array.isArray(matchingCustomer.divisionData) && matchingCustomer.divisionData.length > 0) { const queryLower = userQuery.toLowerCase(); // Check if query mentions a specific account name from the divisions for (const account of matchingCustomer.divisionData) { if (account.accountName) { const accountNameLower = account.accountName.toLowerCase(); // Extract meaningful words from both query and account name const queryWords = queryLower.split(/[\s-]+/).filter((w: string) => w.length > 2); const accountWords = accountNameLower.split(/[\s-]+/).filter((w: string) => w.length > 2); // Check for word overlap - if significant words from account name appear in query const matchingWords = accountWords.filter((word: string) => queryWords.some((qWord: string) => qWord.includes(word) || word.includes(qWord)) ); // Consider it a match if we have multiple matching words or the full account name is mentioned if (queryLower.includes(accountNameLower) || (matchingWords.length >= Math.min(2, accountWords.length)) || accountNameLower.split(/[\s-]+/).some((part: string) => part.length > 3 && queryLower.includes(part))) { selectedAccount = account; console.error(`[CUSTOMER-DETECTION] Query mentions specific account: ${account.accountName} (matched words: ${matchingWords.join(', ')})`); break; } } } // If no specific account mentioned, use first non-multicloud account if (!selectedAccount) { for (const account of matchingCustomer.divisionData) { // Skip multi-cloud accounts (cloudTypeId 4 or 10000) if (account.cloudTypeId === 4 || account.cloudTypeId === 10000) { console.error(`[CUSTOMER-DETECTION] Skipping multi-cloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})`); continue; } // Use the first non-multicloud account selectedAccount = account; console.error(`[CUSTOMER-DETECTION] Selected first non-multicloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})`); break; } } // Fallback to first account if all are multi-cloud if (!selectedAccount && matchingCustomer.divisionData.length > 0) { selectedAccount = matchingCustomer.divisionData[0]; console.error(`[CUSTOMER-DETECTION] All accounts are multi-cloud, using first account: ${selectedAccount.accountName}`); } if (selectedAccount) { accountKey = selectedAccount.accountKey; divisionId = selectedAccount.divisionId; console.error(`[CUSTOMER-DETECTION] Using accountKey ${accountKey} and divisionId ${divisionId} from ${selectedAccount.accountName} (${matchingCustomer.divisionData.length} accounts available)`); // Also capture the accountId if available if (selectedAccount.accountId) { console.error(`[CUSTOMER-DETECTION] Account has accountId: ${selectedAccount.accountId}`); } } } if (accountKey && divisionId !== null) { console.error(`[CUSTOMER-DETECTION] ✅ Mapped "${matchingCustomer.customerDisplayName}" to account key: ${accountKey}, division: ${divisionId}`); const result: any = { accountKey: String(accountKey), divisionId: String(divisionId) }; // Include accountId if available from selected account if (selectedAccount && selectedAccount.accountId) { result.accountId = String(selectedAccount.accountId); console.error(`[CUSTOMER-DETECTION] Including accountId ${selectedAccount.accountId} in response`); } return result; } else { console.error(`[CUSTOMER-DETECTION] ❌ No account key or division ID found in customer data`); } } else { console.error(`[CUSTOMER-DETECTION] ❌ No customer matches found for query words: [${queryWords.join(', ')}]`); // Try fallback: direct string matching with customer display names for (const customer of customers) { const customerName = customer.customerDisplayName || ''; // Check if query mentions this customer by name directly if (customerName && queryLower.includes(customerName.toLowerCase())) { console.error(`[CUSTOMER-DETECTION] 🔄 FALLBACK: Found direct match for "${customerName}"`); matchingCustomer = customer; break; } } } // Final processing if we found a customer (either through word matching or fallback) if (matchingCustomer) { let accountKey = null; let divisionId = null; let selectedAccount = null; if (matchingCustomer.divisionData && Array.isArray(matchingCustomer.divisionData) && matchingCustomer.divisionData.length > 0) { // When multiple accounts exist, apply smart selection logic const customerNameLower = (matchingCustomer.customerDisplayName || '').toLowerCase(); // First pass: Look for non-multicloud account with matching name for (const account of matchingCustomer.divisionData) { // Skip multi-cloud accounts (cloudTypeId 4 or 10000) if (account.cloudTypeId === 4 || account.cloudTypeId === 10000) { console.error(`[CUSTOMER-DETECTION] Skipping multi-cloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})`); continue; } // Prioritize accounts whose name contains customer name or initials const accountNameLower = (account.accountName || '').toLowerCase(); const customerInitials = customerNameLower.split(' ').map(word => word[0]).join('').toLowerCase(); if (accountNameLower.includes(customerNameLower) || accountNameLower.includes(customerInitials)) { selectedAccount = account; console.error(`[CUSTOMER-DETECTION] Direct match - Selected account with matching name: ${account.accountName} (matches customer: ${matchingCustomer.customerDisplayName})`); break; } } // Second pass: If no name match, use first non-multicloud account if (!selectedAccount) { for (const account of matchingCustomer.divisionData) { if (account.cloudTypeId !== 4 && account.cloudTypeId !== 10000) { selectedAccount = account; console.error(`[CUSTOMER-DETECTION] Direct match - Selected first non-multicloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})`); break; } } } // Fallback to first account if all are multi-cloud if (!selectedAccount && matchingCustomer.divisionData.length > 0) { selectedAccount = matchingCustomer.divisionData[0]; console.error(`[CUSTOMER-DETECTION] Direct match - Fallback: using first account as all are multi-cloud`); } if (selectedAccount) { accountKey = selectedAccount.accountKey; divisionId = selectedAccount.divisionId; console.error(`[CUSTOMER-DETECTION] Direct match - Using accountKey ${accountKey} and divisionId ${divisionId} from ${selectedAccount.accountName} (${matchingCustomer.divisionData.length} accounts available)`); // Also capture the accountId if available if (selectedAccount.accountId) { console.error(`[CUSTOMER-DETECTION] Direct match - Account has accountId: ${selectedAccount.accountId}`); } } } if (accountKey && divisionId !== null) { console.error(`[CUSTOMER-DETECTION] ✅ Direct customer match "${matchingCustomer.customerDisplayName}" to account key: ${accountKey}, division: ${divisionId}`); const result: any = { accountKey: String(accountKey), divisionId: String(divisionId) }; // Include accountId if available from selected account if (selectedAccount && selectedAccount.accountId) { result.accountId = String(selectedAccount.accountId); console.error(`[CUSTOMER-DETECTION] Direct match - Including accountId ${selectedAccount.accountId} in response`); } return result; } else { console.error(`[CUSTOMER-DETECTION] ⚠️ Customer "${matchingCustomer.customerDisplayName}" found but no account key or division ID in divisionData`); } } console.error(`[CUSTOMER-DETECTION] No customer name patterns found in query`); return null; } catch (error: any) { console.error(`[CUSTOMER-DETECTION] Error detecting customer: ${error.message}`); return null; } } private async findCustomerByAccountIdAndDivision(accountId: string, divisionId: string, session: any): Promise<{ accountKey: string } | null> { try { console.error(`[CUSTOMER-DETECTION] Finding accountKey for accountId: ${accountId}, divisionId: ${divisionId}`); // Get the API client from session const apiClient = session.apiClient; if (!apiClient) { console.error(`[CUSTOMER-DETECTION] No API client available`); return null; } // Get customer divisions from plain_sub_users endpoint const divisionsResponse = await apiClient.makeRequest('/users/plain-sub-users'); if (!divisionsResponse.success || !divisionsResponse.data?.customerDivisions) { console.error('[CUSTOMER-DETECTION] Failed to get customer divisions'); return null; } const customerDivisions = divisionsResponse.data.customerDivisions; console.error(`[CUSTOMER-DETECTION] Searching ${Object.keys(customerDivisions).length} customers for accountId=${accountId}, divisionId=${divisionId}`); // Search for the specific accountId + divisionId combination let foundAccountMatch = false; for (const [customerName, divisions] of Object.entries(customerDivisions)) { if (Array.isArray(divisions)) { for (const division of divisions) { // Check account match first if (String(division.accountId) === String(accountId)) { foundAccountMatch = true; console.error(`[CUSTOMER-DETECTION] Found accountId ${accountId} in "${customerName}", checking divisionId...`); console.error(`[CUSTOMER-DETECTION] Division ${division.divisionId} vs target ${divisionId}`); if (String(division.divisionId) === String(divisionId)) { console.error(`[CUSTOMER-DETECTION] ✅ Found exact match in customer "${customerName}"`); console.error(`[CUSTOMER-DETECTION] Division: ${division.accountName}, accountKey=${division.accountKey}`); return { accountKey: String(division.accountKey) }; } } } } } if (foundAccountMatch) { console.error(`[CUSTOMER-DETECTION] Found account ${accountId} but no division ${divisionId} match`); } console.error(`[CUSTOMER-DETECTION] No match found for accountId=${accountId}, divisionId=${divisionId}`); return null; } catch (error: any) { console.error(`[CUSTOMER-DETECTION] Error finding customer by accountId and division: ${error.message}`); return null; } } private async findCustomerByAccountId(accountId: string, session: any): Promise<{ accountKey: string; divisionId: string } | null> { try { console.error(`[CUSTOMER-DETECTION] Finding customer for accountId: ${accountId} (type: ${typeof accountId})`); // Get the API client from session const apiClient = session.apiClient; if (!apiClient) { console.error(`[CUSTOMER-DETECTION] No API client available`); return null; } // Get customer divisions from plain_sub_users endpoint const divisionsResponse = await apiClient.makeRequest('/users/plain-sub-users'); if (!divisionsResponse.success || !divisionsResponse.data?.customerDivisions) { console.error('[CUSTOMER-DETECTION] Failed to get customer divisions'); return null; } const customerDivisions = divisionsResponse.data.customerDivisions; console.error(`[CUSTOMER-DETECTION] Got ${Object.keys(customerDivisions).length} customers to search`); // Search through all customers for the matching accountId for (const [customerName, divisions] of Object.entries(customerDivisions)) { if (Array.isArray(divisions)) { for (const division of divisions) { if (String(division.accountId) === String(accountId)) { console.error(`[CUSTOMER-DETECTION] Found accountId ${accountId} in customer "${customerName}"`); console.error(`[CUSTOMER-DETECTION] Division: ${division.accountName}, accountKey=${division.accountKey}, divisionId=${division.divisionId}`); return { accountKey: String(division.accountKey), divisionId: String(division.divisionId) }; } } } } console.error(`[CUSTOMER-DETECTION] No customer found for accountId: ${accountId}`); return null; } catch (error: any) { console.error(`[CUSTOMER-DETECTION] Error finding customer by accountId: ${error.message}`); return null; } } /** * Get the underlying MCP server instance */ getServer(): Server { return this.server; } /** * Get the session manager instance */ getSessionManager(): UserSessionManager { return this.sessionManager; } /** * Enable or disable no-auth mode (for HTTPS with OAuth) * When enabled, authentication tools are excluded from the MCP interface */ setNoAuthMode(enabled: boolean): void { this.noAuthTools = enabled; this.noAuthMode = enabled; if (enabled) { console.log('[MCP-SERVER] No-auth mode enabled - authentication handled via OAuth Bearer tokens'); } } /** * Handle JSON-RPC requests directly (for HTTPS transport) * This is a simplified pass-through to the server's request handlers */ async handleJsonRpcRequest(method: string, params: any): Promise<any> { // Handle tools/call method for tool invocations if (method === 'tools/call') { const handler = (this.server as any)._requestHandlers?.get('tools/call'); if (handler) { return await handler({ params }); } throw new Error(`Method ${method} not found`); } // Handle tools/list method if (method === 'tools/list') { const handler = (this.server as any)._requestHandlers?.get('tools/list'); if (handler) { return await handler({ params }); } throw new Error(`Method ${method} not found`); } // For other methods, try to find a handler const handler = (this.server as any)._requestHandlers?.get(method); if (handler) { return await handler({ params }); } throw new Error(`Method ${method} not found`); } }

Latest Blog Posts

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/daviddraiumbrella/invoice-monitoring'

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