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;
}
}