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