UNPKG

@vfarcic/dot-ai

Version:

AI-powered development productivity platform that enhances software development workflows through intelligent automation and AI-driven assistance

553 lines (552 loc) 24.9 kB
"use strict"; /** * MCP Client Manager for dot-ai MCP Server Integration * * Connects to external MCP servers running in the cluster, discovers their tools, * and makes them available to dot-ai operations (remediate, operate, query) via * the attachTo routing mechanism. * * PRD #358: MCP Server Integration * PRD #414: MCP Client Outbound Authentication */ Object.defineProperty(exports, "__esModule", { value: true }); exports.McpClientManager = exports.StaticTokenAuthProvider = void 0; exports.resolveTransportAuth = resolveTransportAuth; const node_fs_1 = require("node:fs"); const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js"); const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); const auth_extensions_js_1 = require("@modelcontextprotocol/sdk/client/auth-extensions.js"); const mcp_client_types_1 = require("./mcp-client-types"); /** Path for MCP servers config file (mounted from ConfigMap in K8s) */ const MCP_SERVERS_CONFIG_PATH = '/etc/dot-ai-mcp/mcp-servers.json'; /** Separator used to namespace MCP tools: {serverName}__{toolName} */ const TOOL_NAME_SEPARATOR = '__'; /** Default timeout for MCP requests in milliseconds */ const DEFAULT_TIMEOUT_MS = 30_000; /** * Minimal OAuthClientProvider that returns a static bearer token. * * Used when the MCP server expects MCP-spec-compliant auth (authProvider) * but the token is a pre-provisioned service account JWT or API key * rather than an interactive OAuth flow. * * PRD #414: MCP Client Outbound Authentication (M1) */ class StaticTokenAuthProvider { token; constructor(token) { this.token = token; } get redirectUrl() { return undefined; } get clientMetadata() { return { redirect_uris: [], client_name: 'dot-ai', }; } clientInformation() { return undefined; } async tokens() { return { access_token: this.token, token_type: 'bearer', }; } async saveTokens() { } async redirectToAuthorization() { } async saveCodeVerifier() { } // Returns empty string because the SDK type requires string (not undefined). // Static tokens do not use PKCE, so the verifier is never meaningful. async codeVerifier() { return ''; } async invalidateCredentials(_scope) { // No-op for all scopes: static tokens are pre-provisioned and cannot be refreshed. // 'client'/'verifier' are for interactive OAuth (authorization_code + PKCE). // 'tokens'/'discovery' have no effect since the token is fixed at construction. } async saveDiscoveryState(_state) { } async discoveryState() { return undefined; } } exports.StaticTokenAuthProvider = StaticTokenAuthProvider; /** * Resolve transport options (authProvider and/or requestInit) from auth config. * * Reads token/header values from environment variables (sourced from K8s Secrets). * Returns partial options to merge into StreamableHTTPClientTransportOptions. * * PRD #414: MCP Client Outbound Authentication (M1 + M2 + M4) */ function resolveTransportAuth(auth, serverName, logger) { if (!auth) return {}; const result = {}; // M1: Static token → authProvider if (auth.tokenEnvVar) { const token = process.env[auth.tokenEnvVar]; if (token) { result.authProvider = new StaticTokenAuthProvider(token); logger.info('MCP server auth configured via authProvider (static token)', { server: serverName, envVar: auth.tokenEnvVar, }); } else { throw new Error(`MCP server '${serverName}' auth.tokenEnvVar references env var '${auth.tokenEnvVar}' but it is empty or unset — fix the K8s Secret or remove the auth config`); } } // M2: Custom headers → requestInit if (auth.headersEnvVar) { const headersJson = process.env[auth.headersEnvVar]; if (headersJson) { try { const headers = JSON.parse(headersJson); if (typeof headers !== 'object' || headers === null || Array.isArray(headers)) { throw new Error('Headers must be a JSON object of key-value pairs'); } // Validate all header values are strings — non-string values cause HTTP errors for (const [key, value] of Object.entries(headers)) { if (typeof value !== 'string') { throw new Error(`Header "${key}" value must be a string, got ${typeof value}`); } } result.requestInit = { headers }; logger.info('MCP server auth configured via requestInit headers', { server: serverName, envVar: auth.headersEnvVar, headerCount: Object.keys(headers).length, }); } catch (err) { throw new Error(`MCP server '${serverName}' auth.headersEnvVar env var '${auth.headersEnvVar}' contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } } else { throw new Error(`MCP server '${serverName}' auth.headersEnvVar references env var '${auth.headersEnvVar}' but it is empty or unset — fix the K8s Secret or remove the auth config`); } } // M4: OAuth client_credentials → authProvider (takes precedence over tokenEnvVar) // Uses SDK built-in ClientCredentialsProvider (@modelcontextprotocol/sdk ^1.27.1) // instead of custom implementation. The SDK handles: // - prepareTokenRequest() → sets grant_type=client_credentials + scope // - client_secret_basic auth method (RFC 6749 §2.3.1) // - Token caching and automatic refresh on 401 // No PKCE: client_credentials is non-interactive (RFC 6749 §4.4), PKCE is for // authorization_code grants only (RFC 7636 §1). if (auth.oauth) { const clientSecret = process.env[auth.oauth.clientSecretEnvVar]; if (clientSecret) { result.authProvider = new auth_extensions_js_1.ClientCredentialsProvider({ clientId: auth.oauth.clientId, clientSecret, clientName: 'dot-ai', scope: auth.oauth.scope, }); logger.info('MCP server auth configured via authProvider (OAuth client_credentials)', { server: serverName, clientId: auth.oauth.clientId, clientSecretEnvVar: auth.oauth.clientSecretEnvVar, scope: auth.oauth.scope, }); } else { throw new Error(`MCP server '${serverName}' auth.oauth.clientSecretEnvVar references env var '${auth.oauth.clientSecretEnvVar}' but it is empty or unset — fix the K8s Secret or remove the auth config`); } } return result; } /** * Manages MCP server connections, tool discovery, and tool routing. * * Follows the same structural patterns as PluginManager but uses * the MCP SDK (Client + StreamableHTTPClientTransport) instead of HTTP REST. */ class McpClientManager { logger; /** MCP SDK Client instances keyed by server name */ clients = new Map(); /** Transport instances keyed by server name (needed for cleanup) */ transports = new Map(); /** Discovered server metadata keyed by server name */ discoveredServers = new Map(); /** Maps namespaced tool name → server name for routing */ toolToServer = new Map(); constructor(logger) { this.logger = logger; } /** * Parse MCP server configuration from file. * * Reads from /etc/dot-ai-mcp/mcp-servers.json (mounted from ConfigMap in K8s). * Returns empty array if file doesn't exist (MCP servers only work in-cluster). * Throws on invalid JSON or malformed configuration. */ static parseMcpServerConfig() { if (!(0, node_fs_1.existsSync)(MCP_SERVERS_CONFIG_PATH)) { return []; } let content; try { content = (0, node_fs_1.readFileSync)(MCP_SERVERS_CONFIG_PATH, 'utf-8'); } catch (err) { throw new Error(`Failed to read MCP server config at ${MCP_SERVERS_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } let parsed; try { parsed = JSON.parse(content); } catch (err) { throw new Error(`Invalid JSON in MCP server config at ${MCP_SERVERS_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } if (!Array.isArray(parsed)) { throw new Error(`MCP server config at ${MCP_SERVERS_CONFIG_PATH} must be an array, got ${typeof parsed}`); } const validOperations = ['remediate', 'operate', 'query']; return parsed.map((s, index) => { if (!s || typeof s !== 'object') { throw new Error(`MCP server at index ${index} must be an object`); } if (!s.endpoint || typeof s.endpoint !== 'string') { throw new Error(`MCP server at index ${index} (${s.name || 'unnamed'}) is missing required 'endpoint' field`); } if (!Array.isArray(s.attachTo) || s.attachTo.length === 0) { throw new Error(`MCP server at index ${index} (${s.name || 'unnamed'}) must have a non-empty 'attachTo' array`); } for (const op of s.attachTo) { if (!validOperations.includes(op)) { throw new Error(`MCP server at index ${index} (${s.name || 'unnamed'}) has invalid attachTo value '${op}'. Must be one of: ${validOperations.join(', ')}`); } } // Parse optional auth config (PRD #414) // Fail-fast on malformed auth — silent degradation to unauthenticated is a security risk let auth; const serverLabel = s.name || 'unnamed'; if (s.auth !== undefined) { if (!s.auth || typeof s.auth !== 'object' || Array.isArray(s.auth)) { throw new Error(`MCP server at index ${index} (${serverLabel}) auth must be an object`); } const rawAuth = s.auth; auth = {}; if ('tokenEnvVar' in rawAuth) { if (typeof rawAuth.tokenEnvVar !== 'string' || rawAuth.tokenEnvVar.trim() === '') { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.tokenEnvVar must be a non-empty string`); } auth.tokenEnvVar = rawAuth.tokenEnvVar; } if ('headersEnvVar' in rawAuth) { if (typeof rawAuth.headersEnvVar !== 'string' || rawAuth.headersEnvVar.trim() === '') { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.headersEnvVar must be a non-empty string`); } auth.headersEnvVar = rawAuth.headersEnvVar; } // M4: OAuth client_credentials config if ('oauth' in rawAuth) { if (!rawAuth.oauth || typeof rawAuth.oauth !== 'object' || Array.isArray(rawAuth.oauth)) { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.oauth must be an object`); } const rawOAuth = rawAuth.oauth; if (!rawOAuth.clientId || typeof rawOAuth.clientId !== 'string') { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.oauth is missing required 'clientId' field`); } if (!rawOAuth.clientSecretEnvVar || typeof rawOAuth.clientSecretEnvVar !== 'string') { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.oauth is missing required 'clientSecretEnvVar' field`); } if ('scope' in rawOAuth && (typeof rawOAuth.scope !== 'string' || rawOAuth.scope.trim() === '')) { throw new Error(`MCP server at index ${index} (${serverLabel}) auth.oauth.scope must be a non-empty string`); } auth.oauth = { clientId: rawOAuth.clientId, clientSecretEnvVar: rawOAuth.clientSecretEnvVar, scope: typeof rawOAuth.scope === 'string' ? rawOAuth.scope : undefined, }; } // Fail-fast: auth block present but no valid fields configured if (!auth.tokenEnvVar && !auth.headersEnvVar && !auth.oauth) { throw new Error(`MCP server at index ${index} (${serverLabel}) auth is present but contains no valid auth fields (tokenEnvVar, headersEnvVar, or oauth)`); } // Mutual exclusivity: tokenEnvVar and oauth cannot both be specified // because both set authProvider — oauth would silently overwrite the static token if (auth.tokenEnvVar && auth.oauth) { throw new Error(`MCP server at index ${index} (${serverLabel}) auth specifies both 'tokenEnvVar' and 'oauth' — these are mutually exclusive (both set authProvider)`); } } return { name: s.name || `mcp-server-${index}`, endpoint: s.endpoint, attachTo: s.attachTo, timeout: s.timeout, auth, }; }); } /** * Discover all configured MCP servers. * * Connects to each server, performs MCP handshake, and discovers available tools. * All servers must connect successfully — any failure throws McpDiscoveryError. */ async discoverMcpServers(configs) { if (configs.length === 0) { this.logger.debug('No MCP servers configured for discovery'); return; } this.logger.info('Starting MCP server discovery', { serverCount: configs.length, servers: configs.map(c => c.name), }); const results = await Promise.allSettled(configs.map(config => this.connectAndDiscover(config))); const failed = []; results.forEach((result, index) => { const config = configs[index]; if (result.status === 'rejected') { const error = result.reason instanceof Error ? result.reason.message : String(result.reason); failed.push({ name: config.name, error }); } }); if (failed.length > 0) { throw new mcp_client_types_1.McpDiscoveryError(`MCP server discovery failed: ${failed.map(f => `${f.name} (${f.error})`).join(', ')}`, failed); } this.logger.info('MCP server discovery complete', { discovered: this.discoveredServers.size, totalTools: this.toolToServer.size, }); } /** * Connect to a single MCP server and discover its tools. */ async connectAndDiscover(config) { const timeout = config.timeout || DEFAULT_TIMEOUT_MS; this.logger.debug('Connecting to MCP server', { name: config.name, endpoint: config.endpoint, attachTo: config.attachTo, hasAuth: !!config.auth, }); // Resolve authentication options from config + env vars (PRD #414) const authOptions = resolveTransportAuth(config.auth, config.name, this.logger); const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(config.endpoint), { reconnectionOptions: { maxReconnectionDelay: 30_000, initialReconnectionDelay: 1_000, reconnectionDelayGrowFactor: 1.5, maxRetries: 2, }, ...authOptions, }); const client = new index_js_1.Client({ name: 'dot-ai', version: '1.0.0' }, { capabilities: {} }); // Connect with timeout const connectPromise = client.connect(transport); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Connection timed out after ${timeout}ms`)), timeout)); try { await Promise.race([connectPromise, timeoutPromise]); } catch (err) { // Clean up transport on failure try { await transport.close(); } catch { /* ignore cleanup errors */ } throw new Error(`Failed to connect to MCP server '${config.name}' at ${config.endpoint}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } // Discover tools let tools; try { const result = await client.listTools(); tools = result.tools.map(t => ({ name: t.name, description: t.description, inputSchema: { type: t.inputSchema.type, properties: t.inputSchema.properties, required: t.inputSchema.required, }, })); } catch (err) { try { await transport.close(); } catch { /* ignore cleanup errors */ } throw new Error(`Failed to list tools from MCP server '${config.name}': ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } // Store client and transport this.clients.set(config.name, client); this.transports.set(config.name, transport); // Store discovered server metadata this.discoveredServers.set(config.name, { name: config.name, endpoint: config.endpoint, attachTo: config.attachTo, version: client.getServerVersion()?.version, tools, discoveredAt: new Date(), }); // Map namespaced tools to server for (const tool of tools) { const namespacedName = `${config.name}${TOOL_NAME_SEPARATOR}${tool.name}`; if (this.toolToServer.has(namespacedName)) { this.logger.warn('Namespaced tool name conflict - overwriting', { tool: namespacedName, existingServer: this.toolToServer.get(namespacedName), newServer: config.name, }); } this.toolToServer.set(namespacedName, config.name); } this.logger.info('MCP server discovered', { name: config.name, version: client.getServerVersion()?.version, tools: tools.map(t => t.name), namespacedTools: tools.map(t => `${config.name}${TOOL_NAME_SEPARATOR}${t.name}`), }); } /** * Get tools available for a specific dot-ai operation, filtered by attachTo. * * Returns tools as AITool[] with namespaced names ({serverName}__{toolName}). */ getToolsForOperation(operation) { const tools = []; for (const server of this.discoveredServers.values()) { if (!server.attachTo.includes(operation)) { continue; } for (const tool of server.tools) { const namespacedName = `${server.name}${TOOL_NAME_SEPARATOR}${tool.name}`; // Only include if this server owns the routing if (this.toolToServer.get(namespacedName) === server.name) { tools.push(this.convertToAITool(server.name, tool)); } } } return tools; } /** * Get all discovered tools across all servers. */ getAllDiscoveredTools() { const tools = []; for (const server of this.discoveredServers.values()) { for (const tool of server.tools) { const namespacedName = `${server.name}${TOOL_NAME_SEPARATOR}${tool.name}`; if (this.toolToServer.get(namespacedName) === server.name) { tools.push(this.convertToAITool(server.name, tool)); } } } return tools; } /** * Check if a tool name belongs to an MCP server (is namespaced). */ isMcpTool(toolName) { return this.toolToServer.has(toolName); } /** * Create a ToolExecutor that routes MCP tools to their servers. * * Returns a function compatible with toolLoop's toolExecutor parameter. * MCP tools (namespaced) are routed to their MCP servers; non-MCP tools * are routed to the optional fallback executor. */ createToolExecutor(fallbackExecutor) { return async (toolName, input) => { if (this.isMcpTool(toolName)) { this.logger.debug('Routing tool to MCP server', { tool: toolName, server: this.toolToServer.get(toolName), }); try { const serverName = this.toolToServer.get(toolName); const originalToolName = toolName.substring(serverName.length + TOOL_NAME_SEPARATOR.length); const client = this.clients.get(serverName); if (!client) { return `Error: MCP server '${serverName}' is not connected`; } const result = await client.callTool({ name: originalToolName, arguments: input, }); // Extract text content from MCP response const contentArray = result.content; if (result.isError) { const errorText = contentArray ?.filter(c => c.type === 'text') .map(c => c.text) .join('\n') || 'Unknown MCP tool error'; return `Error: ${errorText}`; } // Return text content for AI consumption const textContent = contentArray ?.filter(c => c.type === 'text') .map(c => c.text) .join('\n'); return textContent || ''; } catch (err) { const message = err instanceof Error ? err.message : String(err); this.logger.error('MCP tool invocation failed', new Error(message), { tool: toolName, server: this.toolToServer.get(toolName) }); return `Error: ${message}`; } } // Fall back to provided executor for non-MCP tools if (fallbackExecutor) { return fallbackExecutor(toolName, input); } return `Error: Tool '${toolName}' not found in MCP servers or fallback executor`; }; } /** * Get statistics about MCP server connections. */ getStats() { return { serverCount: this.discoveredServers.size, toolCount: this.toolToServer.size, servers: Array.from(this.discoveredServers.keys()), }; } /** * Get discovered server metadata. */ getDiscoveredServers() { return Array.from(this.discoveredServers.values()); } /** * Close all MCP server connections. */ async close() { for (const [name, transport] of this.transports.entries()) { try { await transport.close(); this.logger.debug('Closed MCP server connection', { name }); } catch (err) { this.logger.warn('Error closing MCP server connection', { name, error: err instanceof Error ? err.message : String(err), }); } } this.clients.clear(); this.transports.clear(); this.discoveredServers.clear(); this.toolToServer.clear(); } /** * Convert an MCP tool definition to AITool format with namespaced name. */ convertToAITool(serverName, tool) { const namespacedName = `${serverName}${TOOL_NAME_SEPARATOR}${tool.name}`; return { name: namespacedName, description: `[${serverName}] ${tool.description || tool.name}`, inputSchema: { type: 'object', properties: tool.inputSchema.properties || {}, required: tool.inputSchema.required, }, }; } } exports.McpClientManager = McpClientManager;