@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
JavaScript
"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;