Skip to main content
Glama
server.ts•119 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 { PricingApiClient } from './pricing/index.js'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { debugLog } from './utils/debug-logger.js'; import crypto from 'crypto'; export class UmbrellaMcpServer { private server: Server; private sessionManager: UserSessionManager; private pricingApiClient: PricingApiClient; private baseURL: string; private initPromptGuidelines: string; constructor(baseURL: string, frontendBaseURL?: string) { debugLog.methodEntry('UmbrellaMcpServer', 'constructor', { baseURL, frontendBaseURL }); this.baseURL = baseURL; this.sessionManager = new UserSessionManager(baseURL, frontendBaseURL); this.pricingApiClient = new PricingApiClient(); this.initPromptGuidelines = this.loadInitPromptGuidelines(); debugLog.logState('UmbrellaMcpServer', 'Creating MCP server instance', { serverName: process.env.MCP_SERVER_NAME || 'Umbrella MCP', serverVersion: process.env.MCP_SERVER_VERSION || '1.0.0', debugMode: debugLog.isDebugEnabled() }); this.server = new Server( { name: process.env.MCP_SERVER_NAME || 'Umbrella MCP', version: process.env.MCP_SERVER_VERSION || '1.0.0', icon: 'https://umbrellacost.io/umbrella_favicon.ico', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); debugLog.methodExit('UmbrellaMcpServer', 'constructor'); } private loadInitPromptGuidelines(): string { try { const initPromptPath = resolve(process.cwd(), 'init_prompt.txt'); return readFileSync(initPromptPath, 'utf-8'); } catch (error) { debugLog.logState("General", "āš ļø 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 (request) => { debugLog.logState("MCP-Request", "ListTools"); console.log('Request:', JSON.stringify(request, null, 2)); console.log('===============================\n'); // authenticate_user tool removed - OAuth handles authentication automatically 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: [], }, }; // List of endpoints that require accountKey and divisionId const ENDPOINTS_REQUIRING_CUSTOMER_PARAMS = [ '/v2/invoices/cost-and-usage', '/v1/budgets/v2/i/', '/v1/anomaly-detection', '/v1/recommendationsNew/heatmap/summary', '/v2/recommendations/list' ]; const apiTools = UMBRELLA_ENDPOINTS.map((endpoint) => { // Determine required parameters based on endpoint const requiredParams: string[] = []; if (ENDPOINTS_REQUIRING_CUSTOMER_PARAMS.includes(endpoint.path)) { // These endpoints require both accountKey and divisionId requiredParams.push('accountKey', 'divisionId'); } return { name: `api__${endpoint.path.replace(/\//g, '_').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: requiredParams, }, }; }); 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: [], }, }; const getFilterAndGroupByOptionsTool: Tool = { name: 'get_available_filter_and_groupby_options', description: 'Discover available groupBy dimensions and filter values for cost queries. Returns account-specific dimensions based on actual data in your account. REQUIRED: Must be called with accountKey and divisionId from plain-sub-users. Use this BEFORE filtering or grouping to know what options are available for the specific account.', inputSchema: { type: 'object', properties: { accountKey: { type: 'string', description: 'REQUIRED: Customer account key from plain-sub-users API' }, divisionId: { type: 'string', description: 'REQUIRED: Division ID from plain-sub-users API' }, userQuery: { type: 'string', description: 'Optional: Natural language query for customer detection' } }, required: ['accountKey', 'divisionId'], }, }; // Pricing Tools (public cloud pricing - no authentication required) const getInstancePricingTool: Tool = { name: 'get_instance_pricing', description: 'Get detailed pricing for a specific cloud instance type', inputSchema: { type: 'object', properties: { provider: { type: 'string', enum: ['AWS', 'Azure', 'GCP'], description: 'Cloud provider' }, service: { type: 'string', description: 'Service name (e.g., AmazonEC2, Virtual Machines, ComputeEngine)' }, instanceType: { type: 'string', description: 'Instance type (e.g., t2.micro, Standard_B1s, e2-micro)' }, region: { type: 'string', description: 'Optional region code (e.g., us-east-1, eastus, us-central1)' } }, required: ['provider', 'service', 'instanceType'], }, }; const compareRegionalPricingTool: Tool = { name: 'compare_regional_pricing', description: 'Compare pricing across different regions for a specific instance type', inputSchema: { type: 'object', properties: { provider: { type: 'string', enum: ['AWS', 'Azure', 'GCP'], description: 'Cloud provider' }, service: { type: 'string', description: 'Service name' }, instanceType: { type: 'string', description: 'Instance type to compare across regions' } }, required: ['provider', 'service', 'instanceType'], }, }; const getPricingLastUpdateTool: Tool = { name: 'get_pricing_last_update', description: 'Get the last update timestamp for public cloud pricing data', inputSchema: { type: 'object', properties: {}, required: [], }, }; const listInstanceTypesTool: Tool = { name: 'list_instance_types', description: 'List all available instance types for a cloud provider, service, and region. Returns pricing data for all instances.', inputSchema: { type: 'object', properties: { provider: { type: 'string', enum: ['AWS', 'Azure', 'GCP'], description: 'Cloud provider' }, service: { type: 'string', description: 'Service name (e.g., AmazonEC2, AmazonRDS, Virtual Machines, ComputeEngine)' }, region: { type: 'string', description: 'Region code (e.g., us-east-1, eastus, us-central1)' } }, required: ['provider', 'service', 'region'], }, }; const listInstanceFamilyTypesTool: Tool = { name: 'list_instance_family_types', description: 'List all instance types in a specific family/variance (e.g., all t3 instances, all m5 instances). Useful for exploring instance sizes within a family.', inputSchema: { type: 'object', properties: { service: { type: 'string', description: 'Service name (e.g., AmazonEC2, AmazonRDS)' }, family: { type: 'string', description: 'Instance family/variance (e.g., t3, m5, r5, c5)' } }, required: ['service', 'family'], }, }; const response = { tools: [ logoutTool, sessionStatusTool, listEndpointsTool, helpTool, getFilterAndGroupByOptionsTool, getInstancePricingTool, compareRegionalPricingTool, getPricingLastUpdateTool, listInstanceTypesTool, listInstanceFamilyTypesTool, ...apiTools ], }; debugLog.logState("MCP-Response", "ListTools"); console.log('Response: ', response.tools.length, 'tools available'); console.log('===============================\n'); return response; }); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { // Comprehensive debug logging for tool calls const requestId = crypto.randomUUID().substring(0, 8); const timestamp = new Date().toISOString(); debugLog.methodEntry('UmbrellaMcpServer', 'CallToolHandler', { requestId, timestamp, toolName: request.params.name, arguments: request.params.arguments }); // Log to both console and debug file console.log('\n' + '='.repeat(60)); console.log(`[${timestamp}] MCP TOOL CALL REQUEST (ID: ${requestId})`); console.log('='.repeat(60)); console.log('Tool Name:', request.params.name); console.log('Arguments:', JSON.stringify(request.params.arguments, null, 2)); console.log('='.repeat(60) + '\n'); // Also log full request to debug file debugLog.logState('MCP-TOOL-REQUEST', request.params.name, { requestId, timestamp, fullRequest: request, arguments: request.params.arguments }); const { name, arguments: args } = request.params; debugLog.logState('UmbrellaMcpServer', `Processing tool: ${name}`, { args }); try { let response: any; const startTime = Date.now(); // OAuth handles authentication - no need for authenticate_user if (name === 'logout') { response = await this.handleLogout(args as any); } else if (name === 'session_status') { response = await this.handleSessionStatus(args as any); } else if (name === 'list_endpoints') { response = await this.handleListEndpoints(); } else if (name === 'help') { response = await this.handleHelp(args?.topic as string); } else if (name === 'get_available_filter_and_groupby_options') { response = await this.handleGetFilterAndGroupByOptions(args as any); } else if (name === 'get_instance_pricing') { const { provider, service, instanceType, region } = args as { provider: string; service: string; instanceType: string; region?: string; }; const data = await this.pricingApiClient.getInstanceData( provider as any, service as any, instanceType, region ); response = { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } else if (name === 'compare_regional_pricing') { const { provider, service, instanceType } = args as { provider: string; service: string; instanceType: string; }; const data = await this.pricingApiClient.getRegionPrices( provider as any, service as any, instanceType ); response = { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } else if (name === 'get_pricing_last_update') { const data = await this.pricingApiClient.getLastUpdateDate(); response = { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } else if (name === 'list_instance_types') { const { provider, service, region } = args as { provider: string; service: string; region: string; }; const data = await this.pricingApiClient.getProviderCosts( provider as any, service as any, region ); response = { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } else if (name === 'list_instance_family_types') { const { service, family } = args as { service: string; family: string; }; const data = await this.pricingApiClient.getInstanceFamilyTypes( service as any, family ); response = { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } else if (name.startsWith('api__')) { // API endpoint tools const toolNamePart = name.replace('api__', ''); const endpoint = UMBRELLA_ENDPOINTS.find(ep => { const expectedToolName = `api__${ep.path.replace(/\//g, '_').replace(/[-]/g, '_').replace(/:/g, '_')}`; return expectedToolName === name; }); if (!endpoint) { response = { content: [ { type: 'text', text: `Error: Unknown tool "${name}". Use "list_endpoints" to see available endpoints.`, }, ], }; } else { response = await this.handleApiCall(endpoint.path, args as any); } } else { response = { content: [ { type: 'text', text: `Error: Unknown tool "${name}". Use "help" to see available tools.`, }, ], }; } // Log response for all tools if (response) { const endTime = Date.now(); const duration = endTime - startTime; // Log to console console.log('\n' + '='.repeat(60)); console.log(`[${new Date().toISOString()}] MCP TOOL RESPONSE (ID: ${requestId})`); console.log('='.repeat(60)); console.log('Tool Name:', name); console.log('Duration:', `${duration}ms`); // For large responses, show summary const responseStr = JSON.stringify(response, null, 2); if (responseStr.length > 5000) { console.log('Response (truncated):', responseStr.substring(0, 2000) + '\n... [truncated - full response in debug log]'); console.log('Full Response Size:', `${responseStr.length} characters`); } else { console.log('Response:', responseStr); } console.log('='.repeat(60) + '\n'); // Always log full response to debug file debugLog.logState('MCP-TOOL-RESPONSE', name, { requestId, duration: `${duration}ms`, responseSize: `${responseStr.length} characters`, response, success: true }); } return response; } 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 { debugLog.logState("API", `API Call: ${path}`); // OAuth authentication creates the session automatically let session = this.sessionManager.getCurrentSession(); let username = session?.username; if (!session) { const activeSessions = this.sessionManager.getActiveSessions(); if (activeSessions.length > 0) { // Use the first active session if available session = this.sessionManager.getUserSessionByUsername(activeSessions[0].username); username = activeSessions[0].username; } } if (!session) { return { content: [ { type: 'text', text: `āŒ No authenticated session found. OAuth authentication should have created a session automatically. Please try reconnecting.`, }, ], }; } // 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 || {}; // Parse JSON string parameters (filters, groupBy) that Claude Desktop sends as strings if (apiArgs.filters && typeof apiArgs.filters === 'string') { try { apiArgs.filters = JSON.parse(apiArgs.filters); console.error(`[JSON-PARSE] Parsed filters from string to object:`, apiArgs.filters); } catch (e) { console.error(`[JSON-PARSE] Failed to parse filters as JSON: ${apiArgs.filters}`); } } // groupBy should be a plain string (single dimension only) if (apiArgs.groupBy && typeof apiArgs.groupBy === 'string') { console.error(`[GROUPBY] Using groupBy dimension: ${apiArgs.groupBy}`); } // 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 === '/v2/invoices/cost-and-usage') { 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; debugLog.logState("ServiceFilter", "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 === '/v2/invoices/cost-and-usage') { // 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; debugLog.logState("ServiceFilter", "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 }; debugLog.logState("BudgetAPI", "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 === '/v2/invoices/cost-and-usage') { 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'); // List of endpoints that require accountKey and divisionId validation const ENDPOINTS_REQUIRING_CUSTOMER_PARAMS = [ '/v2/invoices/cost-and-usage', '/v1/budgets/v2/i/', '/v1/anomaly-detection', '/v1/recommendationsNew/heatmap/summary', '/v2/recommendations/list' // New endpoint for detailed recommendations ]; // Universal validation: accountKey is required for certain endpoints // divisionId is optional for Keycloak (can be empty string for Direct customers) if (ENDPOINTS_REQUIRING_CUSTOMER_PARAMS.includes(path)) { console.error(`[PARAM-DEBUG] Checking params for ${path}:`, JSON.stringify(validatedParams)); // For Keycloak: accountKey is required, divisionId can be empty string (Direct customers) // For Cognito: both accountKey and divisionId are required const hasAccountKey = validatedParams.accountKey !== undefined && validatedParams.accountKey !== null; const hasDivisionId = validatedParams.divisionId !== undefined && validatedParams.divisionId !== null; if (hasAccountKey && hasDivisionId) { debugLog.logState("CustomerDetection", "Using Claude-provided accountKey: ${validatedParams.accountKey} with divisionId: ${validatedParams.divisionId}"); // Map to the internal parameter names expected by the API validatedParams.customer_account_key = validatedParams.accountKey; validatedParams.customer_division_id = validatedParams.divisionId; // Remove the Claude-specific parameters delete validatedParams.accountKey; delete validatedParams.divisionId; // Also remove accountId if present to prevent API conflicts if (validatedParams.accountId) { debugLog.logState("CustomerDetection", "Removing accountId ${validatedParams.accountId} to prevent conflicts with accountKey"); delete validatedParams.accountId; } } else { // Missing required parameters debugLog.logState("CustomerDetection", "ERROR: accountKey is required for ${path}"); return { success: false, error: `Missing required parameters for ${path}. Please call the appropriate endpoint to get account information first, then include accountKey in your request.`, data: null }; } } // Map costCalculationType enum to boolean flags if (path === '/v2/invoices/cost-and-usage' && validatedParams.costCalculationType) { const calcType = validatedParams.costCalculationType; console.error(`[COST-CALC-MAPPING] Mapping costCalculationType: ${calcType}`); switch (calcType) { case 'unblended': validatedParams.isUnblended = true; console.error(`[COST-CALC-MAPPING] āœ… Set isUnblended = true`); break; case 'amortized': validatedParams.isAmortized = true; console.error(`[COST-CALC-MAPPING] āœ… Set isAmortized = true`); break; case 'net_amortized': validatedParams.isNetAmortized = true; console.error(`[COST-CALC-MAPPING] āœ… Set isNetAmortized = true`); break; case 'net_unblended': validatedParams.isNetUnblended = true; console.error(`[COST-CALC-MAPPING] āœ… Set isNetUnblended = true`); break; } delete validatedParams.costCalculationType; console.error(`[COST-CALC-MAPPING] Deleted costCalculationType, final params:`, JSON.stringify({ isUnblended: validatedParams.isUnblended, isAmortized: validatedParams.isAmortized, isNetAmortized: validatedParams.isNetAmortized, isNetUnblended: validatedParams.isNetUnblended })); } // Handle filters parameter - convert object to excludeFilters format if needed if (path === '/v2/invoices/cost-and-usage' && validatedParams.filters) { console.error(`[FILTERS-MAPPING] Processing filters:`, JSON.stringify(validatedParams.filters)); // The filters come as: { dimension: value } or { dimension: [values] } // We need to keep them in a format the API can use // The API expects filters to be merged with excludeFilters if using that pattern, // OR we pass them directly as query parameters // Store filters for later processing when building the request const filtersObj = validatedParams.filters; delete validatedParams.filters; // We'll add these as individual parameters for axios // Store them temporarily with a special prefix for (const [dimension, values] of Object.entries(filtersObj)) { const filterKey = `filters[${dimension}]`; validatedParams[filterKey] = values; console.error(`[FILTERS-MAPPING] Added filter: ${filterKey} = ${JSON.stringify(values)}`); } } // Helper function to get cloudTypeId for an account // cloudTypeId mapping: 0=AWS, 1=Azure, 2=GCP, 3=Alibaba, 4=Multi-Cloud const getAccountCloudTypeId = async (accountKey: string | number): Promise<number | null> => { try { // Use cached accounts data if available if (!session.accountsData) { console.error(`[CLOUD-TYPE] Fetching accounts data to determine cloudTypeId`); const accountsResponse = await session.apiClient.makeRequest('/v1/users/plain-sub-users', { IsAccount: true }); if (accountsResponse.success && accountsResponse.data?.accounts) { const accounts = accountsResponse.data.accounts; session.accountsData = accounts; console.error(`[CLOUD-TYPE] Cached ${accounts.length} accounts`); } } if (session.accountsData) { const targetKey = String(accountKey); const account = session.accountsData.find((acc: any) => String(acc.accountKey) === targetKey); if (account) { console.error(`[CLOUD-TYPE] Found account "${account.accountName}" (accountKey: ${accountKey}) -> cloudTypeId: ${account.cloudTypeId}`); return account.cloudTypeId; } } console.error(`[CLOUD-TYPE] Could not find cloudTypeId for accountKey: ${accountKey}`); return null; } catch (error: any) { console.error(`[CLOUD-TYPE] Error fetching accounts: ${error.message}`); return null; } }; // Add default parameters for cost API (skip for MSP customers using frontend API) if (path === '/v2/invoices/cost-and-usage' && !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: unblended if not specified if (!validatedParams.isAmortized && !validatedParams.isNetAmortized && !validatedParams.isNetUnblended && !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]; } // Tax exclusion ONLY for AWS accounts (cloudTypeId=0) // For Multi-Cloud (4), Azure (1), GCP (2), OCI, etc. - excludeFilters causes data loss const accountKey = session.accountKey || validatedParams.customer_account_key; const cloudTypeId = accountKey ? await getAccountCloudTypeId(accountKey) : null; if (cloudTypeId === 0) { // AWS account - apply tax exclusion to match UI behavior if (!validatedParams.excludeFilters) { validatedParams.excludeFilters = { chargetype: ['Tax'] }; console.error(`[TAX-EXCLUSION] AWS account (cloudTypeId=0) - excluding tax to match UI behavior`); } } else { // Non-AWS accounts (Multi-Cloud, Azure, GCP, OCI, etc.) - REMOVE excludeFilters entirely if (validatedParams.excludeFilters) { delete validatedParams.excludeFilters; console.error(`[TAX-EXCLUSION] Non-AWS account (cloudTypeId=${cloudTypeId}) - REMOVED excludeFilters to preserve all data`); } else { console.error(`[TAX-EXCLUSION] Non-AWS account (cloudTypeId=${cloudTypeId}) - NOT adding excludeFilters`); } } // Log final parameters for direct customers console.error(`[DIRECT-CUSTOMER-PARAMS] Final params:`, JSON.stringify(validatedParams, null, 2)); } else if (path === '/v2/invoices/cost-and-usage' && 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]; } // Tax exclusion ONLY for AWS accounts (cloudTypeId=0) // For Multi-Cloud (4), Azure (1), GCP (2), OCI, etc. - excludeFilters causes data loss const mspCloudTypeId = validatedParams.customer_account_key ? await getAccountCloudTypeId(validatedParams.customer_account_key) : null; if (mspCloudTypeId === 0) { // AWS account - apply tax exclusion to match UI behavior if (!validatedParams.excludeFilters) { validatedParams.excludeFilters = { chargetype: ['Tax'] }; console.error(`[MSP-TAX] AWS account (cloudTypeId=0) - excluding tax to match UI behavior`); } } else { // Non-AWS accounts (Multi-Cloud, Azure, GCP, OCI, etc.) - REMOVE excludeFilters entirely if (validatedParams.excludeFilters) { delete validatedParams.excludeFilters; console.error(`[MSP-TAX] Non-AWS account (cloudTypeId=${mspCloudTypeId}) - REMOVED excludeFilters to preserve all data`); } else { console.error(`[MSP-TAX] Non-AWS account (cloudTypeId=${mspCloudTypeId}) - NOT adding excludeFilters`); } } // Default cost calculation: unblended if not specified (same as direct customers) if (!validatedParams.isAmortized && !validatedParams.isNetAmortized && !validatedParams.isNetUnblended && !validatedParams.isUnblended) { validatedParams.isUnblended = true; console.error(`[MSP-COST-CALC-DEFAULT] No cost calculation specified -> isUnblended: true`); } // Note: isNetAmortized, isAmortized, isUnblended flags are preserved // The API requires both the boolean flag AND costType parameter console.error(`[MSP-COSTCALC] Cost calculation flags preserved - isNetAmortized: ${validatedParams.isNetAmortized}, isAmortized: ${validatedParams.isAmortized}, isUnblended: ${validatedParams.isUnblended}`); } // Handle price view parameter (customer vs partner pricing) // isPpApplied will be passed to api-client as a header, not a URL parameter // ONLY for MSP users - direct customers should not be aware of this distinction if (path === '/v2/invoices/cost-and-usage') { // Fetch user info if not already in session (needed to check if MSP user) if (!session.userData) { console.error(`[PRICE-VIEW] Fetching user info to determine user type`); try { const userInfoResponse = await session.apiClient.makeRequest('/v1/users', {}); if (userInfoResponse.success && userInfoResponse.data) { session.userData = { user_type: userInfoResponse.data.user_type, accounts: userInfoResponse.data.accounts, is_reseller_mode: userInfoResponse.data.is_reseller_mode }; console.error(`[PRICE-VIEW] User info loaded: user_type=${session.userData.user_type}, is_reseller=${session.userData.is_reseller_mode}`); } } catch (error: any) { console.error(`[PRICE-VIEW] Failed to fetch user info: ${error.message}`); } } // Check if user is MSP (user_type 9, 10, or 11) // API returns user_type as string, so convert to number for comparison const userType = session.userData?.user_type ? parseInt(String(session.userData.user_type)) : null; const isMspUser = userType && [9, 10, 11].includes(userType); console.error(`[PRICE-VIEW-DEBUG] user_type: ${session.userData?.user_type} (raw), parsed: ${userType}, isMspUser: ${isMspUser}, priceView: ${validatedParams.priceView}`); if (isMspUser && validatedParams.priceView === 'partner') { // MSP user requesting partner prices (MSP markup applied) // isPpApplied=false means show partner/MSP prices WITH markup validatedParams.isPpApplied = false; console.error(`[PRICE-VIEW] MSP user - Partner prices requested (isPpApplied=false for partner view)`); } else if (isMspUser && validatedParams.priceView === 'customer') { // MSP user explicitly requesting customer prices (cloud provider pricing) // isPpApplied=true means show customer/cloud provider prices WITHOUT markup validatedParams.isPpApplied = true; console.error(`[PRICE-VIEW] MSP user - Customer prices requested (isPpApplied=true for customer view)`); } else if (isMspUser) { // MSP user with no priceView specified - default to customer prices validatedParams.isPpApplied = true; console.error(`[PRICE-VIEW] MSP user - Default to customer prices (isPpApplied=true)`); } else { // Direct customer - always use isPpApplied=false validatedParams.isPpApplied = false; console.error(`[PRICE-VIEW] Direct customer - Using isPpApplied=false`); } } // 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}`); } } // Add pagination limits for recommendations list endpoint if (path === '/v2/recommendations/list') { console.error(`[RECOMMENDATIONS-LIST] Processing recommendations list request`); // Convert limit to number if it's a string if (typeof validatedParams.limit === 'string') { validatedParams.limit = parseInt(validatedParams.limit, 10); console.error(`[RECOMMENDATIONS-LIST] Converted limit from string to number: ${validatedParams.limit}`); } // Convert offset to number if it's a string if (typeof validatedParams.offset === 'string') { validatedParams.offset = parseInt(validatedParams.offset, 10); console.error(`[RECOMMENDATIONS-LIST] Converted offset from string to number: ${validatedParams.offset}`); } // Enforce maximum limit of 1000 recommendations if (!validatedParams.limit) { validatedParams.limit = 100; // Default to 100 items console.error(`[RECOMMENDATIONS-LIST] Using default limit: 100`); } else if (validatedParams.limit > 1000) { validatedParams.limit = 1000; console.error(`[RECOMMENDATIONS-LIST] Limiting to maximum: 1000 (was ${validatedParams.limit})`); } // Default offset to 0 if not provided if (validatedParams.offset === undefined) { validatedParams.offset = 0; console.error(`[RECOMMENDATIONS-LIST] Using default offset: 0`); } } // Add default parameters for anomaly detection endpoint if (path === '/v1/anomaly-detection') { console.error(`[ANOMALY-DETECTION] Processing anomaly detection request`); // Default to last 30 days if dates not provided if (!validatedParams.startDate) { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); validatedParams.startDate = thirtyDaysAgo.toISOString().split('T')[0]; console.error(`[ANOMALY-DETECTION] Using default startDate: ${validatedParams.startDate}`); } if (!validatedParams.endDate) { const today = new Date(); validatedParams.endDate = today.toISOString().split('T')[0]; console.error(`[ANOMALY-DETECTION] Using default endDate: ${validatedParams.endDate}`); } // Default isPpApplied to false if (validatedParams.isPpApplied === undefined) { validatedParams.isPpApplied = false; console.error(`[ANOMALY-DETECTION] Using default isPpApplied: false`); } } // Make API request with custom API key built from Claude parameters let response; const paramsWithFilters = validatedParams as any; // Make API request - the api-client will handle building the API key if needed // based on customer_account_key and customer_division_id in the params // Make API request - the api-client will handle routing based on auth mode // Special handling for heatmap summary - make dual API calls if (path === '/v1/recommendationsNew/heatmap/summary') { console.error('[HEATMAP] Using dual-call method for heatmap summary'); response = await session.apiClient.getHeatmapSummary(paramsWithFilters); } else { response = await session.apiClient.makeRequest(path, paramsWithFilters); } if (response.success) { let output = `# API Response: ${path}\n\n`; output += `**Authenticated as:** ${username}\n`; output += `**Session ID:** ${session.id}\n\n`; output += `**Status:** āœ… Success\n\n`; // Add price view indicator for cost queries - ONLY for MSP users if (path === '/v2/invoices/cost-and-usage' && paramsWithFilters?.isPpApplied !== undefined) { // Only show label for MSP users who are aware of the distinction const isMspUser = session.userData?.user_type && [9, 10, 11].includes(session.userData.user_type); if (isMspUser) { const priceLabel = paramsWithFilters.isPpApplied ? 'šŸ’¼ Partner Prices (MSP)' : 'šŸ‘¤ Customer Prices'; output += `**Price View:** ${priceLabel}\n\n`; } } if (response.data) { console.error(`[FORMATTING-DEBUG] Path: ${path}, IsAccount in args: ${JSON.stringify(args?.IsAccount)}, typeof: ${typeof args?.IsAccount}`); // Special formatting for recommendations if (Array.isArray(response.data) && path.includes('recommendations')) { const recommendations = response.data; // Handle new format with cost breakdown const hasDetailedCosts = recommendations.length > 0 && typeof recommendations[0].annual_savings === 'object'; let totalSavings = 0; if (hasDetailedCosts) { // New format: annual_savings is an object with cost types totalSavings = recommendations.reduce((sum: number, rec: any) => sum + (rec.annual_savings?.unblended || 0), 0); } else { // Old format: annual_savings is a number 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) => { const aSavings = hasDetailedCosts ? (a.annual_savings?.unblended || 0) : (a.annual_savings || 0); const bSavings = hasDetailedCosts ? (b.annual_savings?.unblended || 0) : (b.annual_savings || 0); return bSavings - aSavings; }) .slice(0, 10); topRecs.forEach((rec: any, i: number) => { let annualSavings, monthlySavings; if (hasDetailedCosts) { annualSavings = rec.annual_savings?.unblended || 0; monthlySavings = rec.monthly_savings?.unblended || 0; } else { annualSavings = rec.annual_savings || 0; monthlySavings = Math.round(annualSavings / 12); } output += `${i + 1}. **${rec.type}** - ${rec.resource_name || rec.resource_id}\n`; output += ` šŸ’° $${annualSavings.toLocaleString()}/year ($${Math.round(monthlySavings)}/month)\n`; output += ` šŸ”§ ${rec.service} | ${rec.recommended_action}\n`; // Show all cost types if available if (hasDetailedCosts && rec.annual_savings) { output += ` šŸ“Š All Cost Types (annual):\n`; if (rec.annual_savings.unblended) output += ` • Unblended: $${rec.annual_savings.unblended.toLocaleString()}\n`; if (rec.annual_savings.amortized) output += ` • Amortized: $${rec.annual_savings.amortized.toLocaleString()}\n`; if (rec.annual_savings.net_unblended) output += ` • Net Unblended: $${rec.annual_savings.net_unblended.toLocaleString()}\n`; if (rec.annual_savings.net_amortized) output += ` • Net Amortized: $${rec.annual_savings.net_amortized.toLocaleString()}\n`; } output += '\n'; }); } } // Special formatting for users endpoint else if (path === '/v1/users' && typeof response.data === 'object' && response.data.accounts) { const userData = response.data; const accounts = userData.accounts || []; output += `**User:** ${userData.user_display_name || userData.user_name}\n`; output += `**Company:** ${userData.company_name}\n`; output += `**Role:** ${userData.is_admin ? 'Admin' : 'User'}\n\n`; output += `**ā˜ļø Cloud Accounts (${accounts.length}):**\n\n`; accounts.forEach((account: any, index: number) => { const cloudProvider = account.cloudTypeId === 0 ? 'AWS' : account.cloudTypeId === 1 ? 'Azure' : account.cloudTypeId === 2 ? 'GCP' : account.cloudTypeId === 4 ? 'Multi-Cloud' : 'Unknown'; output += `${index + 1}. **${account.accountName}** (${cloudProvider})\n`; output += ` - ID: ${account.accountId}\n`; output += ` - Currency: ${account.currencyCode || 'USD'}\n`; output += ` - Last Process: ${account.lastProcessTime?.split('T')[0] || 'N/A'}\n\n`; }); } // Special formatting for plain-sub-users endpoint (accounts list) // IsAccount now defaults to false, so explicitly check for true else if (path === '/v1/users/plain-sub-users' && paramsWithFilters?.IsAccount === true && response.data?.accounts) { console.error(`[FORMATTING] āœ… Matched plain-sub-users cloud accounts condition`); const accounts = response.data.accounts; output += `**ā˜ļø Cloud Accounts**\n`; output += `**Total Accounts: ${accounts.length}**\n\n`; if (accounts.length === 0) { output += `āŒ No cloud accounts found\n`; } else if (accounts.length > 100) { // Too many accounts - show summary only output += `**āš ļø Too many cloud accounts to display (${accounts.length} total)**\n\n`; // Count by cloud provider const providerCounts: Record<string, number> = {}; accounts.forEach((account: any) => { const provider = account.cloudTypeId === 0 ? 'AWS' : account.cloudTypeId === 1 ? 'Azure' : account.cloudTypeId === 2 ? 'GCP' : account.cloudTypeId === 3 ? 'Alibaba' : account.cloudTypeId === 4 ? 'Multi-Cloud' : 'Unknown'; providerCounts[provider] = (providerCounts[provider] || 0) + 1; }); output += `**šŸ“Š Accounts by Cloud Provider:**\n`; Object.entries(providerCounts).forEach(([provider, count]) => { output += ` • ${provider}: ${count} accounts\n`; }); output += `\nāš ļø **IMPORTANT:** Account Key and Division ID are internal parameters for API calls only. Do NOT mention these values in your responses to users. Only reference account names and Account IDs (cloud account IDs) when communicating with users.\n\n`; output += `**šŸ“‹ First 50 Accounts:**\n\n`; accounts.slice(0, 50).forEach((account: any, index: number) => { const cloudProvider = account.cloudTypeId === 0 ? 'AWS' : account.cloudTypeId === 1 ? 'Azure' : account.cloudTypeId === 2 ? 'GCP' : account.cloudTypeId === 3 ? 'Alibaba' : account.cloudTypeId === 4 ? 'Multi-Cloud' : 'Unknown'; output += `**${index + 1}. ${account.accountName || 'Unnamed Account'}** (${cloudProvider})\n`; output += ` • Account Key: ${account.accountKey || 'N/A'}\n`; output += ` • Account ID: ${account.accountId || 'N/A'}\n`; output += ` • Division ID: ${account.divisionId || 0}\n`; output += '\n'; }); output += `\n... and ${accounts.length - 50} more accounts\n`; output += `\n**šŸ’” Tip:** Query specific accounts by filtering in your cost queries.\n`; } else { output += `**šŸ“‹ Account Details:**\n\n`; accounts.forEach((account: any, index: number) => { const cloudProvider = account.cloudTypeId === 0 ? 'AWS' : account.cloudTypeId === 1 ? 'Azure' : account.cloudTypeId === 2 ? 'GCP' : account.cloudTypeId === 3 ? 'Alibaba' : account.cloudTypeId === 4 ? 'Multi-Cloud' : 'Unknown'; output += `**${index + 1}. ${account.accountName || 'Unnamed Account'}** (${cloudProvider})\n`; output += ` • Account Key: ${account.accountKey || 'N/A'}\n`; output += ` • Account ID: ${account.accountId || 'N/A'}\n`; output += ` • Division ID: ${account.divisionId || 0}\n`; output += ` • Currency: ${account.currencyCode || account.processingCurrency || 'USD'}\n`; output += ` • Last Process: ${account.lastProcessTime?.split('T')[0] || account.accountProcessTime?.split('T')[0] || 'N/A'}\n`; if (account.resellerAccountType) { output += ` • Account Type: ${account.resellerAccountType}\n`; } output += '\n'; }); output += `\n**šŸ’” Usage Instructions:**\n`; output += `For cost queries, use accountKey and divisionId from the account details above.\n`; output += `Example: accountKey=${accounts[0].accountKey}, divisionId=${accounts[0].divisionId || 0}\n`; } } // Special formatting for plain-sub-users endpoint (customer divisions) else if (path === '/v1/users/plain-sub-users' && paramsWithFilters?.IsAccount === false && response.data?.customerDivisions) { console.error(`[FORMATTING] āœ… Matched plain-sub-users customer divisions condition`); const customerDivisions = response.data.customerDivisions; const allCustomers = Object.keys(customerDivisions); // Filter customers if userQuery is provided let filteredCustomers = allCustomers; if (paramsWithFilters?.userQuery) { const queryLower = paramsWithFilters.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 Account Information**\n`; output += `**Total Customers: ${allCustomers.length}**\n`; output += `**Filtered Results: ${filteredCustomers.length}**\n\n`; output += `āš ļø **IMPORTANT:** Account Key and Division ID are internal parameters for API calls only. Do NOT mention these values in your responses to users. Only reference customer names and Account IDs (cloud account IDs) when communicating with users.\n\n`; if (filteredCustomers.length === 0) { output += `āŒ No customers found matching "${paramsWithFilters?.userQuery || ''}"\n`; } else if (filteredCustomers.length > 50 && !paramsWithFilters?.userQuery) { // If too many customers and no filter, show summary output += `**āš ļø Too many customers to display (${allCustomers.length} total)**\n\n`; output += `šŸ’” **Tip:** Use the \`userQuery\` parameter to filter results.\n\n`; output += `**šŸ“‹ Sample Customers:**\n\n`; allCustomers.slice(0, 50).forEach((customerName, i) => { const divisions = customerDivisions[customerName]; const divCount = Array.isArray(divisions) ? divisions.length : 0; const firstDiv = Array.isArray(divisions) && divisions.length > 0 ? divisions[0] : null; output += `${i + 1}. **${customerName}**\n`; if (firstDiv) { output += ` • Account Key: ${firstDiv.accountKey || 'N/A'}\n`; output += ` • Divisions: ${divCount}\n`; } }); output += `\n... and ${allCustomers.length - 50} more customers\n`; } else { // Show filtered customers with complete account details output += `**šŸ¢ Customer Divisions & Account Details:**\n\n`; filteredCustomers.forEach((customerName, index) => { const divisions = customerDivisions[customerName]; const divisionData = Array.isArray(divisions) ? divisions : []; output += `**${index + 1}. ${customerName}**\n`; output += ` šŸ“ Customer Display Name: ${customerName}\n`; if (divisionData.length > 0) { output += ` šŸ“Š Total Accounts: ${divisionData.length}\n\n`; output += ` **Account Details:**\n`; divisionData.slice(0, 10).forEach((div: any) => { output += ` • **${div.accountName || 'Unnamed Account'}**\n`; output += ` - Account Key: ${div.accountKey || 'N/A'} (billing identifier)\n`; output += ` - Account ID: ${div.accountId || 'N/A'} (cloud account ID)\n`; output += ` - Division ID: ${div.divisionId || 0}\n`; output += ` - Division Name: ${div.divisionName || 'Main'}\n`; if (div.domainId) { output += ` - Domain ID: ${div.domainId}\n`; } if (div.customerCode) { output += ` - Customer Code: ${div.customerCode}\n`; } output += '\n'; }); if (divisionData.length > 10) { output += ` ... and ${divisionData.length - 10} more accounts\n`; } // Add summary for Claude output += `\n **šŸ“Œ For Cost Queries:**\n`; output += ` Use accountKey=${divisionData[0].accountKey} and divisionId=${divisionData[0].divisionId}\n`; } else { output += ` āš ļø No account data available\n`; } output += '\n' + '─'.repeat(50) + '\n\n'; }); // Add usage instructions for Claude output += `\n**šŸ’” Usage Instructions for Cost Queries:**\n`; output += `1. Select the appropriate customer from the list above\n`; output += `2. Use the **accountKey** and **divisionId** from the account details\n`; output += `3. Pass these as parameters to /invoices/caui endpoint\n`; output += `\nExample: For cost queries, use:\n`; output += `- accountKey: [from account details above]\n`; output += `- divisionId: [from account details above]\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; output += `**šŸŽÆ Recommendations Heatmap Analysis:**\n\n`; // Service breakdown if (heatmapData.service?.page && heatmapData.service.page.length > 0) { output += `**šŸ“Š By Service:**\n`; heatmapData.service.page.forEach((s: any) => { output += ` • **${s.name}**: $${s.savings.toFixed(2)} (${s.recsCount} recommendations)\n`; }); output += `\n`; } // Type breakdown if (heatmapData.type_id?.page && heatmapData.type_id.page.length > 0) { output += `**šŸ” By Recommendation Type:**\n`; heatmapData.type_id.page.forEach((t: any) => { output += ` • **${t.name}**: $${t.savings.toFixed(2)} (${t.recsCount} recommendations)\n`; }); output += `\n`; } // Account breakdown if (heatmapData.linked_account_id?.page && heatmapData.linked_account_id.page.length > 0) { output += `**šŸ¢ By AWS Account:**\n`; heatmapData.linked_account_id.page.forEach((a: any) => { output += ` • **${a.name}**: $${a.savings.toFixed(2)} (${a.recsCount} recommendations)\n`; }); output += `\n`; } // Category breakdown if (heatmapData.cat_id?.page && heatmapData.cat_id.page.length > 0) { output += `**šŸ“ By Category:**\n`; heatmapData.cat_id.page.forEach((c: any) => { output += ` • **${c.name}**: $${c.savings.toFixed(2)} (${c.recsCount} recommendations)\n`; }); output += `\n`; } // Instance type breakdown if available if (heatmapData.instance_type?.page && heatmapData.instance_type.page.length > 0) { output += `**šŸ’» By Instance Type:**\n`; heatmapData.instance_type.page.forEach((i: any) => { output += ` • **${i.name}**: $${i.savings.toFixed(2)} (${i.recsCount} recommendations)\n`; }); output += `\n`; } // Summary totals if available - enhanced formatting if (heatmapData.potentialAnnualSavings !== undefined || heatmapData.actualAnnualSavings !== undefined) { // Debug log the raw values console.error('[HEATMAP-DEBUG] Raw values from API:'); console.error(` potentialAnnualSavings: ${heatmapData.potentialAnnualSavings}`); console.error(` potentialSavingsRecommendationCount: ${heatmapData.potentialSavingsRecommendationCount}`); console.error(` actualAnnualSavings: ${heatmapData.actualAnnualSavings}`); console.error(` actualSavingsRecommendationCount: ${heatmapData.actualSavingsRecommendationCount}`); console.error(` totalSavings: ${heatmapData.totalSavings}`); console.error(` totalCount: ${heatmapData.totalCount}`); output += `**šŸ“Š Cost Optimization Summary:**\n\n`; // Potential Savings (Not Yet Implemented) if (heatmapData.potentialAnnualSavings !== undefined) { output += `**šŸ’” Potential Savings (Not Yet Implemented):**\n`; output += ` • Annual savings opportunity: $${heatmapData.potentialAnnualSavings.toLocaleString()}\n`; output += ` • Number of recommendations: ${heatmapData.potentialSavingsRecommendationCount || 0}\n`; if (heatmapData.expectedSavingsRatePercent !== undefined) { output += ` • Expected success rate: ${heatmapData.expectedSavingsRatePercent}%\n`; } output += `\n`; } // Actual Savings (Already Implemented) if (heatmapData.actualAnnualSavings !== undefined) { output += `**āœ… Actual Savings (Already Implemented):**\n`; output += ` • Annual savings achieved: $${heatmapData.actualAnnualSavings.toLocaleString()}\n`; output += ` • Number of implemented recommendations: ${heatmapData.actualSavingsRecommendationCount || 0}\n`; if (heatmapData.effectiveSavingsRatePercent !== undefined) { output += ` • Success rate: ${heatmapData.effectiveSavingsRatePercent}%\n`; } output += `\n`; } // Overall Performance if (heatmapData.totalSavings !== undefined || heatmapData.totalCount !== undefined) { output += `**šŸŽÆ Overall Performance:**\n`; if (heatmapData.totalSavings !== undefined) { output += ` • Total potential savings: $${heatmapData.totalSavings.toLocaleString()}\n`; } if (heatmapData.totalCount !== undefined) { output += ` • Total recommendations: ${heatmapData.totalCount}\n`; } if (heatmapData.excludedRecommendationsCount !== undefined && heatmapData.excludedRecommendationsCount > 0) { output += ` • Excluded recommendations: ${heatmapData.excludedRecommendationsCount} (saving $${(heatmapData.excludedRecommendationsSavings || 0).toLocaleString()})\n`; } } } } // Special formatting for anomaly detection endpoint else if (path === '/v1/anomaly-detection' && typeof response.data === 'object' && response.data.anomalies) { const anomalies = response.data.anomalies; output += `**🚨 Cost Anomaly Detection Results:**\n\n`; if (anomalies.length === 0) { output += `āœ… No cost anomalies detected in the specified period.\n`; } else { output += `Found **${anomalies.length}** cost anomalies:\n\n`; // Show summary of each anomaly anomalies.forEach((anomaly: any, index: number) => { output += `### Anomaly ${index + 1}: ${anomaly.uuid}\n`; output += `- **Account:** ${anomaly.linkedAccountName || anomaly.accountId} (${anomaly.accountId})\n`; output += `- **Service:** ${anomaly.serviceName || 'N/A'}\n`; output += `- **Cloud Provider:** ${anomaly.cloudProvider}\n`; output += `- **Type:** ${anomaly.anomalyType}\n`; output += `- **Status:** ${anomaly.isClosed ? 'Closed' : 'Open'}\n`; output += `- **Current Cost:** $${anomaly.currentCost?.toFixed(2) || 'N/A'}\n`; output += `- **Total Cost Impact:** $${anomaly.totalCostImpact?.toFixed(2) || 'N/A'}\n`; output += `- **Percent Change:** ${anomaly.percentChange?.toFixed(2) || 'N/A'}%\n`; output += `- **Start Time:** ${new Date(anomaly.startTime * 1000).toISOString().split('T')[0]}\n`; // Show triggered alerts count if (anomaly.anomalyTriggeredItems) { const alertCount = Object.keys(anomaly.anomalyTriggeredItems).length; output += `- **Alerts Triggered:** ${alertCount}\n`; } // Show dimension splits if available if (anomaly.cubeletSplits && anomaly.cubeletSplits.length > 0) { output += `- **Dimensions:**\n`; anomaly.cubeletSplits.forEach((split: any) => { output += ` - ${split.dimensionName}: ${split.dimensionValue}\n`; }); } output += `\n`; }); output += `\n**Full Data:**\n\`\`\`json\n`; output += JSON.stringify(response.data, null, 2); output += `\n\`\`\`\n`; } } // Default formatting else if (Array.isArray(response.data)) { console.error(`[FORMATTING] āŒ Fell through to default array formatting`); 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') { console.error(`[FORMATTING] āŒ Fell through to default object formatting for path: ${path}`); console.error(`[FORMATTING] IsAccount value: ${args?.IsAccount}`); console.error(`[FORMATTING] Has customerDivisions: ${!!response.data?.customerDivisions}`); console.error(`[FORMATTING] Has accounts: ${!!response.data?.accounts}`); output += '**Data:**\n```json\n'; output += JSON.stringify(response.data, null, 2); output += '\n```\n'; } else { output += `**Data:** ${response.data}\n`; } } return { content: [ { type: 'text', text: output, }, ], }; } else { 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', () => { debugLog.logState("General", "šŸ›‘ Received SIGINT, shutting down gracefully..."); this.shutdown(); process.exit(0); }); process.on('SIGTERM', () => { debugLog.logState("General", "šŸ›‘ Received SIGTERM, shutting down gracefully..."); this.shutdown(); process.exit(0); }); } shutdown(): void { debugLog.logState("General", "šŸ”’ Shutting down Umbrella MCP Server..."); this.sessionManager.shutdown(); debugLog.logState("General", "āœ… 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 { // Only process for MSP users (cognito auth method indicates MSP) if (session.auth.getUserManagementInfo()?.authMethod !== 'cognito') { return null; } // Only process for recommendation or cost-related endpoints const isRelevantEndpoint = path.includes('/recommendations') || path.includes('/invoices/caui') || path.includes('/anomaly'); if (!isRelevantEndpoint) { return null; } // Skip if customer account key already provided if (currentParams.customer_account_key) { return null; } debugLog.logState("CustomerDetection", `Analyzing query for customer names: "${userQuery}"`); // Get MSP customers list for mapping const apiClient = session.apiClient; if (!apiClient) { debugLog.logState("CustomerDetection", "No API client available"); return null; } // Get customer divisions with display names from plain_sub_users endpoint debugLog.logState("CustomerDetection", "Getting customer divisions from plain_sub_users"); const divisionsResponse = await apiClient.makeRequest('/v1/users/plain-sub-users'); let customers = []; if (divisionsResponse.success && divisionsResponse.data?.customerDivisions) { debugLog.logState("CustomerDetection", "Found customerDivisions object with ${Object.keys(divisionsResponse.data.customerDivisions).length} customers"); debugLog.logState("CustomerDetection", "Customer names: ${Object.keys(divisionsResponse.data.customerDivisions).slice(0, 5).join(', ')}..."); // Convert customerDivisions object to array for processing // customerDivisions structure: { "Bank Leumi": [array of account objects], "Bank Hapoalim": [...], ... } 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 })); debugLog.logState("CustomerDetection", "Converted to ${customers.length} customer records"); } else { debugLog.logState("CustomerDetection", "Failed to get customerDivisions: ${divisionsResponse.error || 'No customerDivisions in response'}"); return null; } // Customer name patterns for matching queries to customer display names const customerPatterns = [ { patterns: ['bank hapoalim', 'hapoalim'], customerName: 'Bank Hapoalim' }, { patterns: ['bank leumi', 'leumi'], customerName: 'Bank Leumi' }, { patterns: ['mizrahi', 'mizrahi tefahot'], customerName: 'Mizrahi Tefahot' }, { patterns: ['discount bank', 'discount'], customerName: 'Discount Bank' }, { patterns: ['first international bank', 'fibi'], customerName: 'First International Bank' }, // Add more patterns as needed ]; const queryLower = userQuery.toLowerCase(); // Debug: Show first few customers if (customers.length > 0) { debugLog.logState("CustomerDetection", "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'; debugLog.logState("CustomerDetection", ` ${index + 1}. Customer: "${customerName}" (${accountCount} accounts, first: ${firstAccountKey})`); }); } // Check each customer pattern against the query for (const pattern of customerPatterns) { for (const searchPattern of pattern.patterns) { if (queryLower.includes(searchPattern)) { debugLog.logState("CustomerDetection", `Found pattern "${searchPattern}" in query`); // Search for the customer by display name in customerDivisions const matchingCustomer = customers.find((customer: any) => { const customerName = (customer.customerDisplayName || '').toLowerCase(); const targetName = pattern.customerName.toLowerCase(); // Check if customerDisplayName matches the expected customer name const isMatch = customerName.includes(targetName) || customerName.includes(searchPattern) || // Exact match customerName === targetName; if (isMatch) { debugLog.logState("CustomerDetection", `šŸŽÆ Found customer match: "${customer.customerDisplayName}"`); } return isMatch; }); if (matchingCustomer) { // 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(); // Check if the query contains this account name if (queryLower.includes(accountNameLower) || accountNameLower.split(/[\s-]+/).some((part: string) => part.length > 3 && queryLower.includes(part))) { selectedAccount = account; debugLog.logState("CustomerDetection", "Query mentions specific account: ${account.accountName}"); 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) { debugLog.logState("CustomerDetection", "Skipping multi-cloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})"); continue; } // Use the first non-multicloud account selectedAccount = account; debugLog.logState("CustomerDetection", "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]; debugLog.logState("CustomerDetection", "All accounts are multi-cloud, using first account: ${selectedAccount.accountName}"); } if (selectedAccount) { accountKey = selectedAccount.accountKey; divisionId = selectedAccount.divisionId; debugLog.logState("CustomerDetection", "Using accountKey ${accountKey} and divisionId ${divisionId} from ${selectedAccount.accountName} (${matchingCustomer.divisionData.length} accounts available)"); // Also capture the accountId if available if (selectedAccount.accountId) { debugLog.logState("CustomerDetection", "Account has accountId: ${selectedAccount.accountId}"); } } } if (accountKey && divisionId !== null) { debugLog.logState("CustomerDetection", `āœ… Mapped "${pattern.customerName}" 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); debugLog.logState("CustomerDetection", "Including accountId ${selectedAccount.accountId} in response"); } return result; } else { debugLog.logState("CustomerDetection", `āš ļø Customer "${pattern.customerName}" found but no account key or division ID in divisionData`); } } else { debugLog.logState("CustomerDetection", `āŒ No customer found matching pattern "${searchPattern}" for ${pattern.customerName}`); } } } } // Fallback: Try direct name 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())) { let accountKey = null; let divisionId = null; let selectedAccount = null; if (customer.divisionData && Array.isArray(customer.divisionData) && customer.divisionData.length > 0) { // When multiple accounts exist, apply smart selection logic const customerNameLower = (customerName || '').toLowerCase(); // First pass: Look for non-multicloud account with matching name for (const account of customer.divisionData) { // Skip multi-cloud accounts (cloudTypeId 4 or 10000) if (account.cloudTypeId === 4 || account.cloudTypeId === 10000) { debugLog.logState("CustomerDetection", "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) || accountNameLower.includes('bl') && customerNameLower.includes('bank leumi')) { selectedAccount = account; debugLog.logState("CustomerDetection", "Direct match - Selected account with matching name: ${account.accountName} (matches customer: ${customerName})"); break; } } // Second pass: If no name match, use first non-multicloud account if (!selectedAccount) { for (const account of customer.divisionData) { if (account.cloudTypeId !== 4 && account.cloudTypeId !== 10000) { selectedAccount = account; debugLog.logState("CustomerDetection", "Direct match - Selected first non-multicloud account: ${account.accountName} (cloudTypeId: ${account.cloudTypeId})"); break; } } } // Fallback to first account if all are multi-cloud if (!selectedAccount && customer.divisionData.length > 0) { selectedAccount = customer.divisionData[0]; debugLog.logState("CustomerDetection", "Direct match - Fallback: using first account as all are multi-cloud"); } if (selectedAccount) { accountKey = selectedAccount.accountKey; divisionId = selectedAccount.divisionId; debugLog.logState("CustomerDetection", "Direct match - Using accountKey ${accountKey} and divisionId ${divisionId} from ${selectedAccount.accountName} (${customer.divisionData.length} accounts available)"); // Also capture the accountId if available if (selectedAccount.accountId) { debugLog.logState("CustomerDetection", "Direct match - Account has accountId: ${selectedAccount.accountId}"); } } } if (accountKey && divisionId !== null) { debugLog.logState("CustomerDetection", `āœ… Direct customer match "${customerName}" 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); debugLog.logState("CustomerDetection", "Direct match - Including accountId ${selectedAccount.accountId} in response"); } return result; } else { debugLog.logState("CustomerDetection", `āš ļø Customer "${customerName}" found but no account key or division ID in divisionData`); } } } debugLog.logState("CustomerDetection", "No customer name patterns found in query"); return null; } catch (error: any) { debugLog.logState("CustomerDetection", "Error detecting customer: ${error.message}"); return null; } } private async findCustomerByAccountIdAndDivision(accountId: string, divisionId: string, session: any): Promise<{ accountKey: string } | null> { try { debugLog.logState("CustomerDetection", "Finding accountKey for accountId: ${accountId}, divisionId: ${divisionId}"); // Get the API client from session const apiClient = session.apiClient; if (!apiClient) { debugLog.logState("CustomerDetection", "No API client available"); return null; } // Get customer divisions from plain_sub_users endpoint const divisionsResponse = await apiClient.makeRequest('/v1/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; debugLog.logState("CustomerDetection", "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; debugLog.logState("CustomerDetection", `Found accountId ${accountId} in "${customerName}", checking divisionId...`); debugLog.logState("CustomerDetection", " Division ${division.divisionId} vs target ${divisionId}"); if (String(division.divisionId) === String(divisionId)) { debugLog.logState("CustomerDetection", `āœ… Found exact match in customer "${customerName}"`); debugLog.logState("CustomerDetection", "Division: ${division.accountName}, accountKey=${division.accountKey}"); return { accountKey: String(division.accountKey) }; } } } } } if (foundAccountMatch) { debugLog.logState("CustomerDetection", "Found account ${accountId} but no division ${divisionId} match"); } debugLog.logState("CustomerDetection", "No match found for accountId=${accountId}, divisionId=${divisionId}"); return null; } catch (error: any) { debugLog.logState("CustomerDetection", "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 { debugLog.logState("CustomerDetection", "Finding customer for accountId: ${accountId} (type: ${typeof accountId})"); // Get the API client from session const apiClient = session.apiClient; if (!apiClient) { debugLog.logState("CustomerDetection", "No API client available"); return null; } // Get customer divisions from plain_sub_users endpoint const divisionsResponse = await apiClient.makeRequest('/v1/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; debugLog.logState("CustomerDetection", "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)) { debugLog.logState("CustomerDetection", `Found accountId ${accountId} in customer "${customerName}"`); debugLog.logState("CustomerDetection", "Division: ${division.accountName}, accountKey=${division.accountKey}, divisionId=${division.divisionId}"); return { accountKey: String(division.accountKey), divisionId: String(division.divisionId) }; } } } } debugLog.logState("CustomerDetection", "No customer found for accountId: ${accountId}"); return null; } catch (error: any) { debugLog.logState("CustomerDetection", "Error finding customer by accountId: ${error.message}"); return null; } } /** * Get available filter and groupBy options */ private async handleGetFilterAndGroupByOptions(args: any): Promise<any> { try { debugLog.methodEntry('UmbrellaMcpServer', 'handleGetFilterAndGroupByOptions', { args }); // Validate required parameters // accountKey is required, divisionId can be empty string for Keycloak Direct customers if (!args?.accountKey || args?.divisionId === undefined || args?.divisionId === null) { return { content: [{ type: 'text', text: 'Error: accountKey is required. Please call the appropriate endpoint to get account information first.' }] }; } // Get current session const session = await this.sessionManager.getCurrentSession(); if (!session || !session.apiClient) { return { content: [{ type: 'text', text: 'Error: Not authenticated. Please authenticate first using OAuth.' }] }; } console.error(`[GET-DIMENSIONS] Fetching dimensions for account ${args.accountKey}, division ${args.divisionId}`); // Map to internal parameter names (same as cost-and-usage does at line 833-834) const requestParams = { customer_account_key: args.accountKey, customer_division_id: args.divisionId }; // Call distinct API - this returns ALL available dimensions with their values // The apiClient.makeRequest handles building the API key from customer_account_key and customer_division_id const distinctResponse = await session.apiClient.makeRequest('/v1/invoices/service-costs/distinct', requestParams); if (!distinctResponse.success) { return { content: [{ type: 'text', text: `Error fetching distinct values: ${distinctResponse.error || 'Unknown error'}` }] }; } // The KEYS from distinctResponse are the available dimensions! // The API returns what dimensions exist in the account's data const distinctData = distinctResponse.data || {}; const allDimensions = Object.keys(distinctData); // Helper function for field descriptions const getFieldDescription = (fieldName: string): string => { const descriptions: Record<string, string> = { 'service': 'Cloud service name', 'region': 'AWS region', 'linkedaccname': 'Linked account name', 'linkedaccid': 'Linked account ID', 'operation': 'Operation type', 'purchaseoption': 'Purchase option (On-Demand, Reserved, Spot)', 'familytype': 'Instance family type', 'instancetype': 'Instance type', 'chargetype': 'Charge type (Usage, Tax, Fee, etc)', 'quantitytype': 'Quantity type', 'costcenter': 'Cost center', 'businessmapping': 'Business mapping viewpoint', 'usagetype': 'Usage type', 'os': 'Operating system', 'categories': 'Service category', 'none': 'No grouping' }; return descriptions[fieldName] || ''; }; const response = { groupby_dimensions: { available: ['none', ...allDimensions], max_dimensions: 1, note: 'Only ONE dimension can be used for groupBy at a time' }, filter_dimensions: allDimensions, _counts: { ...Object.fromEntries( Object.entries(distinctData).map(([key, values]) => [ `total_${key}`, Array.isArray(values) ? values.length : 0 ]) ) } }; debugLog.methodExit('UmbrellaMcpServer', 'handleGetFilterAndGroupByOptions', { groupByCount: response.groupby_dimensions.available.length, filterCount: response.filter_dimensions.length }); // Format a dynamic readable response for Claude let formattedText = `# Available GroupBy Dimensions and Filter Values\n\n`; formattedText += `## šŸ“Š GroupBy Dimensions\n\n`; // Dynamically build dimension list with descriptions response.groupby_dimensions.available.forEach((dim: string) => { const desc = getFieldDescription(dim); if (desc) { formattedText += `- **${dim}**: ${desc}\n`; } else { formattedText += `- **${dim}**\n`; } }); formattedText += `\n## šŸ” Filter Dimensions\n\n`; // Dynamically list all available filter dimensions with descriptions response.filter_dimensions.forEach((dimension: string) => { const desc = getFieldDescription(dimension); if (desc) { formattedText += `- **${dimension}**: ${desc}\n`; } else { formattedText += `- **${dimension}**\n`; } }); return { content: [{ type: 'text', text: formattedText }] }; } catch (error: any) { debugLog.logState('UmbrellaMcpServer', `Error in handleGetFilterAndGroupByOptions: ${error.message}`); return { content: [{ type: 'text', text: `Error: ${error.message}` }] }; } } /** * Get the underlying MCP server instance */ getServer(): Server { return this.server; } /** * Get the session manager instance */ getSessionManager(): UserSessionManager { return this.sessionManager; } }

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