UNPKG

@vfarcic/dot-ai

Version:

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

648 lines (647 loc) 32 kB
"use strict"; /** * Model Context Protocol (MCP) Interface for DevOps AI Toolkit * * Provides MCP server capabilities that expose DevOps AI Toolkit functionality * to AI assistants through standardized protocol */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MCPServer = void 0; const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); const node_http_1 = require("node:http"); const node_crypto_1 = require("node:crypto"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const error_handling_1 = require("../core/error-handling"); const recommend_1 = require("../tools/recommend"); const version_1 = require("../tools/version"); const organizational_data_1 = require("../tools/organizational-data"); const remediate_1 = require("../tools/remediate"); const operate_1 = require("../tools/operate"); const project_setup_1 = require("../tools/project-setup"); const query_1 = require("../tools/query"); const manage_knowledge_1 = require("../tools/manage-knowledge"); const impact_analysis_1 = require("../tools/impact-analysis"); const prompts_1 = require("../tools/prompts"); const rest_registry_1 = require("./rest-registry"); const rest_api_1 = require("./rest-api"); const oauth_1 = require("./oauth"); const request_context_1 = require("./request-context"); const rbac_1 = require("../core/rbac"); const express_1 = __importDefault(require("express")); const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js"); const error_response_1 = require("./error-response"); const tracing_1 = require("../core/tracing"); const api_1 = require("@opentelemetry/api"); const telemetry_1 = require("../core/telemetry"); const plugin_registry_1 = require("../core/plugin-registry"); /** Sessions inactive for 1 hour are reaped (matches JWT expiry). */ const SESSION_TTL_MS = 60 * 60 * 1000; /** How often to check for expired sessions. */ const SESSION_GC_INTERVAL_MS = 5 * 60 * 1000; class MCPServer { dotAI; initialized = false; logger; requestIdCounter = 0; config; httpServer; /** Per-session state: each MCP client gets its own McpServer + transport */ sessions = new Map(); sessionGcTimer; restRegistry; restApiRouter; pluginManager; oauthApp; oauthProvider; issuerUrl; constructor(dotAI, config) { this.dotAI = dotAI; this.config = config; this.logger = new error_handling_1.ConsoleLogger('MCPServer'); this.pluginManager = config.pluginManager; // PRD #359: Plugin manager connected to unified registry in server.ts this.logger.info('Initializing MCP Server', { name: config.name, version: config.version, author: config.author, }); // Initialize REST API components (shared across all sessions) this.restRegistry = new rest_registry_1.RestToolRegistry(this.logger); this.restApiRouter = new rest_api_1.RestApiRouter(this.restRegistry, this.dotAI, this.logger, this.pluginManager); // Log AI provider info this.configureHostProvider(); // Register tools with REST registry (one-time, shared) this.registerRestTools(); } /** * Get the current MCP client info (available after client connects). * Returns info from the most recently connected session. */ getMcpClientInfo() { // Return the last connected client's info (for telemetry compatibility) for (const session of this.sessions.values()) { if (session.clientInfo) return session.clientInfo; } return undefined; } /** * Register a tool with the REST registry only (shared, one-time). */ registerRestTool(name, description, inputSchema, handler, category, tags) { const restTracedHandler = async (args) => { return await (0, tracing_1.withToolTracing)(name, args, handler, { mcpClient: { name: 'http', version: 'rest-api' }, }); }; this.restRegistry.registerTool({ name, description, inputSchema: inputSchema, handler: restTracedHandler, category, tags, }); } /** * Register a tool on a per-session McpServer instance. */ registerMcpTool(server, session, name, description, inputSchema, handler) { const mcpTracedHandler = async (args) => { // RBAC enforcement (PRD #392) — invocation-time check as second layer of defense const identity = (0, request_context_1.getCurrentIdentity)(); if (identity) { const rbacResult = await (0, rbac_1.checkToolAccess)(identity, { toolName: name }); if (!rbacResult.allowed) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'FORBIDDEN', message: `Access denied: tool '${name}' not authorized for user '${identity.email}'`, }), }, ], }; } } return await (0, tracing_1.withToolTracing)(name, args, handler, { mcpClient: session.clientInfo, }); }; /* eslint-disable @typescript-eslint/no-explicit-any -- MCP SDK type compatibility */ server.registerTool(name, { description, inputSchema: inputSchema, }, mcpTracedHandler); /* eslint-enable @typescript-eslint/no-explicit-any */ } /** * Tool definitions — shared between REST (registered once) and MCP (registered per session). */ getToolDefs() { return [ { name: recommend_1.RECOMMEND_TOOL_NAME, description: recommend_1.RECOMMEND_TOOL_DESCRIPTION, schema: recommend_1.RECOMMEND_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${recommend_1.RECOMMEND_TOOL_NAME} tool request`, { requestId, }); if (!this.pluginManager) throw new Error('Plugin system not available. Recommend tool requires agentic-tools plugin.'); return await (0, recommend_1.handleRecommendTool)(args, this.dotAI, this.logger, requestId, this.pluginManager); }, category: 'Deployment', tags: ['recommendation', 'kubernetes', 'deployment', 'workflow'], }, { name: version_1.VERSION_TOOL_NAME, description: version_1.VERSION_TOOL_DESCRIPTION, schema: version_1.VERSION_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${version_1.VERSION_TOOL_NAME} tool request`, { requestId, }); return await (0, version_1.handleVersionTool)(args, this.logger, requestId); }, category: 'System', tags: ['version', 'diagnostics', 'status'], }, { name: organizational_data_1.ORGANIZATIONAL_DATA_TOOL_NAME, description: organizational_data_1.ORGANIZATIONAL_DATA_TOOL_DESCRIPTION, schema: organizational_data_1.ORGANIZATIONAL_DATA_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${organizational_data_1.ORGANIZATIONAL_DATA_TOOL_NAME} tool request`, { requestId }); return await (0, organizational_data_1.handleOrganizationalDataTool)(args, this.dotAI, this.logger, requestId); }, category: 'Management', tags: ['patterns', 'policies', 'capabilities', 'data'], }, { name: remediate_1.REMEDIATE_TOOL_NAME, description: remediate_1.REMEDIATE_TOOL_DESCRIPTION, schema: remediate_1.REMEDIATE_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${remediate_1.REMEDIATE_TOOL_NAME} tool request`, { requestId, }); if (!(0, plugin_registry_1.isPluginInitialized)()) throw new Error('Plugin system not available. Remediate tool requires agentic-tools plugin for kubectl operations.'); return await (0, remediate_1.handleRemediateTool)(args); }, category: 'Troubleshooting', tags: ['remediation', 'troubleshooting', 'kubernetes', 'analysis'], }, { name: operate_1.OPERATE_TOOL_NAME, description: operate_1.OPERATE_TOOL_DESCRIPTION, schema: operate_1.OPERATE_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${operate_1.OPERATE_TOOL_NAME} tool request`, { requestId, }); if (!this.pluginManager) throw new Error('Plugin system not available. Operate tool requires agentic-tools plugin for kubectl operations.'); return await (0, operate_1.handleOperateTool)(args, this.pluginManager); }, category: 'Operations', tags: [ 'operate', 'operations', 'kubernetes', 'day2', 'update', 'scale', ], }, { name: project_setup_1.PROJECT_SETUP_TOOL_NAME, description: project_setup_1.PROJECT_SETUP_TOOL_DESCRIPTION, schema: project_setup_1.PROJECT_SETUP_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${project_setup_1.PROJECT_SETUP_TOOL_NAME} tool request`, { requestId }); return await (0, project_setup_1.handleProjectSetupTool)(args, this.logger); }, category: 'Project Setup', tags: ['governance', 'infrastructure', 'configuration', 'files'], }, { name: query_1.QUERY_TOOL_NAME, description: query_1.QUERY_TOOL_DESCRIPTION, schema: query_1.QUERY_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${query_1.QUERY_TOOL_NAME} tool request`, { requestId, }); return await (0, query_1.handleQueryTool)(args, this.pluginManager); }, category: 'Intelligence', tags: ['query', 'search', 'discover', 'capabilities', 'cluster'], }, { name: manage_knowledge_1.MANAGE_KNOWLEDGE_TOOL_NAME, description: manage_knowledge_1.MANAGE_KNOWLEDGE_TOOL_DESCRIPTION, schema: manage_knowledge_1.MANAGE_KNOWLEDGE_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${manage_knowledge_1.MANAGE_KNOWLEDGE_TOOL_NAME} tool request`, { requestId }); return await (0, manage_knowledge_1.handleManageKnowledgeTool)(args, this.dotAI, this.logger, requestId); }, category: 'Knowledge', tags: ['knowledge', 'documents', 'ingest', 'semantic', 'search'], }, { name: impact_analysis_1.IMPACT_ANALYSIS_TOOL_NAME, description: impact_analysis_1.IMPACT_ANALYSIS_TOOL_DESCRIPTION, schema: impact_analysis_1.IMPACT_ANALYSIS_TOOL_INPUT_SCHEMA, handler: async (args) => { const requestId = this.generateRequestId(); this.logger.info(`Processing ${impact_analysis_1.IMPACT_ANALYSIS_TOOL_NAME} tool request`, { requestId }); return await (0, impact_analysis_1.handleImpactAnalysisTool)(args, this.pluginManager); }, category: 'Intelligence', tags: ['impact', 'dependency', 'blast-radius', 'analysis', 'safety'], }, ]; } /** * Register tools with the shared REST registry (called once at startup). */ registerRestTools() { for (const def of this.getToolDefs()) { this.registerRestTool(def.name, def.description, def.schema, def.handler, def.category, def.tags); } const pluginToolCount = this.pluginManager?.getDiscoveredTools().length || 0; this.logger.info('Registered tools with REST registry', { totalRegistered: this.getToolDefs().length, pluginToolsAvailableInternally: pluginToolCount, }); } /** * Create a new McpServer instance with all tools and prompts registered. * Each MCP client session gets its own server instance (SDK limitation: * Protocol only supports one transport per server). */ async createSessionServer(session, authIdentity) { const server = new mcp_js_1.McpServer({ name: this.config.name, version: this.config.version }, { capabilities: { tools: {}, prompts: {} } }); // Track client info per session server.server.oninitialized = () => { const clientVersion = server.server.getClientVersion(); if (clientVersion) { session.clientInfo = { name: clientVersion.name, version: clientVersion.version, }; (0, telemetry_1.getTelemetry)().trackClientConnected(session.clientInfo); this.logger.info('MCP client connected', { client: clientVersion.name, version: clientVersion.version, }); } }; // Register tools on this session server, filtered by RBAC (PRD #392) const allDefs = this.getToolDefs(); const defs = await (0, rbac_1.filterAuthorizedTools)(authIdentity, allDefs); for (const def of defs) { this.registerMcpTool(server, session, def.name, def.description, def.schema, def.handler); } // Register prompts this.registerPromptsOn(server); return server; } /** * Register prompts capability on a given McpServer instance. */ registerPromptsOn(server) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK type compatibility server.server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async (request) => { const requestId = this.generateRequestId(); this.logger.info('Processing prompts/list request', { requestId }); return await (0, prompts_1.handlePromptsListRequest)({ ...request.params, excludeFileSkills: true }, this.logger, requestId); }); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK type compatibility server.server.setRequestHandler(types_js_1.GetPromptRequestSchema, async (request) => { const requestId = this.generateRequestId(); this.logger.info('Processing prompts/get request', { requestId, promptName: request.params?.name, }); return await (0, prompts_1.handlePromptsGetRequest)(request.params || { name: '' }, this.logger, requestId); }); } configureHostProvider() { const aiProvider = this.dotAI.ai; this.logger.info('Using configured AI Provider', { type: aiProvider.getProviderType ? aiProvider.getProviderType() : 'unknown', }); } /** * Reap sessions that have been inactive for longer than SESSION_TTL_MS. */ reapStaleSessions() { const now = Date.now(); for (const [sid, session] of this.sessions) { if (now - session.lastActivity > SESSION_TTL_MS) { this.logger.info('Reaping inactive session', { sessionId: sid }); session.server.close().catch(() => { }); this.sessions.delete(sid); } } } generateRequestId() { return `mcp_${Date.now()}_${++this.requestIdCounter}`; } async start() { this.logger.info('Starting MCP Server'); await this.startHttpTransport(); // Start periodic session cleanup this.sessionGcTimer = setInterval(() => this.reapStaleSessions(), SESSION_GC_INTERVAL_MS); this.sessionGcTimer.unref(); // Don't prevent process exit this.initialized = true; } async startHttpTransport() { const port = process.env.PORT ? parseInt(process.env.PORT) : this.config.port !== undefined ? this.config.port : 3456; const host = process.env.HOST || this.config.host || '0.0.0.0'; this.logger.info('Using HTTP/SSE transport', { port, host }); // Create OAuth provider and Express sub-app with SDK router // Issuer URL: DOT_AI_EXTERNAL_URL for production (HTTPS), localhost for dev/test // SDK exempts localhost from HTTPS requirement const externalUrl = process.env.DOT_AI_EXTERNAL_URL || `http://localhost:${port}`; this.issuerUrl = new URL(externalUrl); this.oauthProvider = new oauth_1.DotAIOAuthProvider(); this.oauthApp = (0, express_1.default)(); this.oauthApp.set('trust proxy', 1); // Middleware to extract client-requested token expiry before SDK processes /token // This allows clients (like CLI) to request longer-lived tokens this.oauthApp.use(express_1.default.urlencoded({ extended: false })); this.oauthApp.use((req, res, next) => { if (req.method === 'POST' && req.url === '/token') { const body = req.body; if (body.code && body.requested_expiry) { const expirySeconds = parseInt(body.requested_expiry, 10); if (!isNaN(expirySeconds)) { this.oauthProvider.setRequestedExpiry(body.code, expirySeconds); } } } next(); }); this.oauthApp.use((0, router_js_1.mcpAuthRouter)({ provider: this.oauthProvider, issuerUrl: this.issuerUrl, })); // Dex OIDC callback — receives redirect from Dex after user authenticates (Task 2.3) const oauthProvider = this.oauthProvider; this.oauthApp.get('/callback', async (req, res) => { await oauthProvider.handleCallback(req, res); }); // Create HTTP server this.httpServer = (0, node_http_1.createServer)(async (req, res) => { // Create HTTP SERVER span for distributed tracing const { span, endSpan } = (0, tracing_1.createHttpServerSpan)(req); // Execute entire request within the span's context for proper propagation await api_1.context.with(api_1.trace.setSpan(api_1.context.active(), span), async () => { try { this.logger.debug('HTTP request received', { method: req.method, url: req.url, headers: req.headers, }); // Handle CORS for browser-based clients res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Session-Id, Authorization, X-Dot-AI-Authorization'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); endSpan(204); return; } // Health check endpoint (unauthenticated, for Kubernetes probes) if (req.url === '/healthz' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); endSpan(200); return; } // OAuth endpoints (unauthenticated) — delegate to Express sub-app with SDK router // SDK handles: discovery metadata, client registration, authorize, token // (with built-in rate limiting, CORS, Zod validation) const oauthPaths = [ '/.well-known/oauth-protected-resource', '/.well-known/oauth-authorization-server', '/register', '/authorize', '/token', '/callback', ]; if (oauthPaths.some(p => req.url?.startsWith(p))) { res.on('finish', () => endSpan(res.statusCode || 200)); this.oauthApp(req, res); return; } // Check Bearer token authentication (only when DOT_AI_AUTH_TOKEN is set) // Skip authentication for OpenAPI specification endpoint (public documentation) const isOpenApiEndpoint = req.url?.startsWith('/api/v1/openapi'); let authIdentity; if (!isOpenApiEndpoint) { const authResult = (0, oauth_1.checkBearerAuth)(req); if (!authResult.authorized) { this.logger.warn('Authentication failed', { message: authResult.message, }); const issuerHref = this.issuerUrl.href.replace(/\/$/, ''); const resourceMetadataUrl = `${issuerHref}/.well-known/oauth-protected-resource`; (0, error_response_1.sendErrorResponse)(res, 401, 'UNAUTHORIZED', authResult.message || 'Authentication required', undefined, { 'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}"`, }); endSpan(401); return; } authIdentity = authResult.identity; } // Propagate identity to all downstream tool handlers (PRD #380) await request_context_1.requestContext.run({ identity: authIdentity }, async () => { // Parse request body for POST requests let body = undefined; if (req.method === 'POST') { body = await this.parseRequestBody(req); } // Check if this is a REST API request if (this.restApiRouter.isApiRequest(req.url || '')) { this.logger.debug('Routing to REST API handler', { url: req.url, }); // Mark span as REST API request span.setAttribute('request.type', 'rest-api'); try { await this.restApiRouter.handleRequest(req, res, body); endSpan(res.statusCode || 200); return; } catch (error) { this.logger.error('REST API request failed', error); if (!res.headersSent) { (0, error_response_1.sendErrorResponse)(res, 500, 'INTERNAL_ERROR', 'REST API internal server error'); } endSpan(500); return; } } // Handle MCP protocol requests — route to per-session transport span.setAttribute('request.type', 'mcp-protocol'); span.updateName('MCP ' + (req.url || '/')); try { // Determine if this is an initialize request (needs new transport) const isInit = req.method === 'POST' && body != null && (Array.isArray(body) ? body.some(m => (0, types_js_1.isInitializeRequest)(m)) : (0, types_js_1.isInitializeRequest)(body)); if (isInit) { // Create a new McpServer + transport pair for this client session. // Each session gets its own McpServer instance because the SDK's // Protocol class only supports one transport at a time. const session = { lastActivity: Date.now(), }; const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(), enableJsonResponse: false, onsessioninitialized: (sid) => { this.logger.info('Session initialized', { sessionId: sid, }); this.sessions.set(sid, session); }, }); const server = await this.createSessionServer(session, authIdentity); session.server = server; session.transport = transport; transport.onclose = () => { const sid = transport.sessionId; if (sid) this.sessions.delete(sid); this.logger.info('Session closed', { sessionId: sid }); }; await server.connect(transport); await transport.handleRequest(req, res, body); endSpan(res.statusCode || 200); } else { // Route to existing session by Mcp-Session-Id header const sessionId = req.headers['mcp-session-id']; const session = sessionId ? this.sessions.get(sessionId) : undefined; if (!session) { (0, error_response_1.sendErrorResponse)(res, 404, 'SESSION_NOT_FOUND', 'Session not found'); endSpan(404); return; } session.lastActivity = Date.now(); await session.transport.handleRequest(req, res, body); endSpan(res.statusCode || 200); } } catch (error) { this.logger.error('Error handling MCP HTTP request', error); if (!res.headersSent) { (0, error_response_1.sendErrorResponse)(res, 500, 'INTERNAL_ERROR', 'MCP internal server error'); } endSpan(500); } }); // Close requestContext.run() } catch (error) { // Handle any unexpected errors in span creation or request handling this.logger.error('Unexpected error in HTTP request handler', error); span.recordException(error); endSpan(500); if (!res.headersSent) { (0, error_response_1.sendErrorResponse)(res, 500, 'INTERNAL_ERROR', 'Internal server error'); } } }); // Close context.with() }); // Start listening await new Promise((resolve, reject) => { this.httpServer.listen(port, host, () => { this.logger.info(`HTTP server listening on ${host}:${port}`); resolve(); }).on('error', reject); }); } async parseRequestBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => (body += chunk.toString())); req.on('end', () => { try { resolve(body ? JSON.parse(body) : undefined); } catch (error) { reject(error); } }); req.on('error', reject); }); } async stop() { // Stop OAuth provider pruning timer if (this.oauthProvider) { this.oauthProvider._stopPruning(); this.oauthProvider = undefined; } // Stop session GC timer if (this.sessionGcTimer) { clearInterval(this.sessionGcTimer); this.sessionGcTimer = undefined; } // Close all session servers and transports for (const [sid, session] of this.sessions) { try { await session.server.close(); } catch { /* ignore */ } this.sessions.delete(sid); } // Stop HTTP server if running if (this.httpServer) { await new Promise(resolve => { this.httpServer.close(() => { this.logger.info('HTTP server stopped'); resolve(); }); }); } this.initialized = false; } isReady() { return this.initialized; } } exports.MCPServer = MCPServer;