/** * MCP Routes Module * Handles all MCP-related API endpoints */ import type { IncomingMessage, ServerResponse } from 'http'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; // Claude config file path const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); export interface RouteContext { pathname: string; url: URL; req: IncomingMessage; res: ServerResponse; initialPath: string; handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise) => void; broadcastToClients: (data: unknown) => void; } // ======================================== // Helper Functions // ======================================== /** * Get enterprise managed MCP path (platform-specific) */ function getEnterpriseMcpPath(): string { const platform = process.platform; if (platform === 'darwin') { return '/Library/Application Support/ClaudeCode/managed-mcp.json'; } else if (platform === 'win32') { return 'C:\\Program Files\\ClaudeCode\\managed-mcp.json'; } else { // Linux and WSL return '/etc/claude-code/managed-mcp.json'; } } /** * Safely read and parse JSON file */ function safeReadJson(filePath) { try { if (!existsSync(filePath)) return null; const content = readFileSync(filePath, 'utf8'); return JSON.parse(content); } catch { return null; } } /** * Get MCP servers from a JSON file (expects mcpServers key at top level) * @param {string} filePath * @returns {Object} mcpServers object or empty object */ function getMcpServersFromFile(filePath) { const config = safeReadJson(filePath); if (!config) return {}; return config.mcpServers || {}; } /** * Get MCP configuration from multiple sources (per official Claude Code docs): * * Priority (highest to lowest): * 1. Enterprise managed-mcp.json (cannot be overridden) * 2. Local scope (project-specific private in ~/.claude.json) * 3. Project scope (.mcp.json in project root) * 4. User scope (mcpServers in ~/.claude.json) * * Note: ~/.claude/settings.json is for MCP PERMISSIONS, NOT definitions! * * @returns {Object} */ function getMcpConfig() { try { const result = { projects: {}, userServers: {}, // User-level servers from ~/.claude.json mcpServers enterpriseServers: {}, // Enterprise managed servers (highest priority) configSources: [] // Track where configs came from for debugging }; // 1. Read Enterprise managed MCP servers (highest priority) const enterprisePath = getEnterpriseMcpPath(); if (existsSync(enterprisePath)) { const enterpriseConfig = safeReadJson(enterprisePath); if (enterpriseConfig?.mcpServers) { result.enterpriseServers = enterpriseConfig.mcpServers; result.configSources.push({ type: 'enterprise', path: enterprisePath, count: Object.keys(enterpriseConfig.mcpServers).length }); } } // 2. Read from ~/.claude.json if (existsSync(CLAUDE_CONFIG_PATH)) { const claudeConfig = safeReadJson(CLAUDE_CONFIG_PATH); if (claudeConfig) { // 2a. User-level mcpServers (top-level mcpServers key) if (claudeConfig.mcpServers) { result.userServers = claudeConfig.mcpServers; result.configSources.push({ type: 'user', path: CLAUDE_CONFIG_PATH, count: Object.keys(claudeConfig.mcpServers).length }); } // 2b. Project-specific configurations (projects[path].mcpServers) if (claudeConfig.projects) { result.projects = claudeConfig.projects; } } } // 3. For each known project, check for .mcp.json (project-level config) const projectPaths = Object.keys(result.projects); for (const projectPath of projectPaths) { const mcpJsonPath = join(projectPath, '.mcp.json'); if (existsSync(mcpJsonPath)) { const mcpJsonConfig = safeReadJson(mcpJsonPath); if (mcpJsonConfig?.mcpServers) { // Merge .mcp.json servers into project config // Project's .mcp.json has lower priority than ~/.claude.json projects[path].mcpServers const existingServers = result.projects[projectPath]?.mcpServers || {}; result.projects[projectPath] = { ...result.projects[projectPath], mcpServers: { ...mcpJsonConfig.mcpServers, // .mcp.json (lower priority) ...existingServers // ~/.claude.json projects[path] (higher priority) }, mcpJsonPath: mcpJsonPath // Track source for debugging }; result.configSources.push({ type: 'project-mcp-json', path: mcpJsonPath, count: Object.keys(mcpJsonConfig.mcpServers).length }); } } } // Build globalServers by merging user and enterprise servers // Enterprise servers override user servers result.globalServers = { ...result.userServers, ...result.enterpriseServers }; return result; } catch (error: unknown) { console.error('Error reading MCP config:', error); return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: (error as Error).message }; } } /** * Normalize project path for .claude.json (Windows backslash format) * @param {string} path * @returns {string} */ function normalizeProjectPathForConfig(path) { // Convert forward slashes to backslashes for Windows .claude.json format let normalized = path.replace(/\//g, '\\'); // Handle /d/path format -> D:\path if (normalized.match(/^\\[a-zA-Z]\\/)) { normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2); } return normalized; } /** * Toggle MCP server enabled/disabled * @param {string} projectPath * @param {string} serverName * @param {boolean} enable * @returns {Object} */ function toggleMcpServerEnabled(projectPath, serverName, enable) { try { if (!existsSync(CLAUDE_CONFIG_PATH)) { return { error: '.claude.json not found' }; } const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); const config = JSON.parse(content); const normalizedPath = normalizeProjectPathForConfig(projectPath); if (!config.projects || !config.projects[normalizedPath]) { return { error: `Project not found: ${normalizedPath}` }; } const projectConfig = config.projects[normalizedPath]; // Ensure disabledMcpServers array exists if (!projectConfig.disabledMcpServers) { projectConfig.disabledMcpServers = []; } if (enable) { // Remove from disabled list projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); } else { // Add to disabled list if not already there if (!projectConfig.disabledMcpServers.includes(serverName)) { projectConfig.disabledMcpServers.push(serverName); } } // Write back to file writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return { success: true, serverName, enabled: enable, disabledMcpServers: projectConfig.disabledMcpServers }; } catch (error: unknown) { console.error('Error toggling MCP server:', error); return { error: (error as Error).message }; } } /** * Add MCP server to project * @param {string} projectPath * @param {string} serverName * @param {Object} serverConfig * @returns {Object} */ function addMcpServerToProject(projectPath, serverName, serverConfig) { try { if (!existsSync(CLAUDE_CONFIG_PATH)) { return { error: '.claude.json not found' }; } const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); const config = JSON.parse(content); const normalizedPath = normalizeProjectPathForConfig(projectPath); // Create project entry if it doesn't exist if (!config.projects) { config.projects = {}; } if (!config.projects[normalizedPath]) { config.projects[normalizedPath] = { allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [], hasTrustDialogAccepted: false, projectOnboardingSeenCount: 0, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false }; } const projectConfig = config.projects[normalizedPath]; // Ensure mcpServers exists if (!projectConfig.mcpServers) { projectConfig.mcpServers = {}; } // Add the server projectConfig.mcpServers[serverName] = serverConfig; // Write back to file writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return { success: true, serverName, serverConfig }; } catch (error: unknown) { console.error('Error adding MCP server:', error); return { error: (error as Error).message }; } } /** * Remove MCP server from project * @param {string} projectPath * @param {string} serverName * @returns {Object} */ function removeMcpServerFromProject(projectPath, serverName) { try { if (!existsSync(CLAUDE_CONFIG_PATH)) { return { error: '.claude.json not found' }; } const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); const config = JSON.parse(content); const normalizedPath = normalizeProjectPathForConfig(projectPath); if (!config.projects || !config.projects[normalizedPath]) { return { error: `Project not found: ${normalizedPath}` }; } const projectConfig = config.projects[normalizedPath]; if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) { return { error: `Server not found: ${serverName}` }; } // Remove the server delete projectConfig.mcpServers[serverName]; // Also remove from disabled list if present if (projectConfig.disabledMcpServers) { projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); } // Write back to file writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return { success: true, serverName, removed: true }; } catch (error: unknown) { console.error('Error removing MCP server:', error); return { error: (error as Error).message }; } } /** * Add MCP server to global/user scope (top-level mcpServers in ~/.claude.json) * @param {string} serverName * @param {Object} serverConfig * @returns {Object} */ function addGlobalMcpServer(serverName, serverConfig) { try { if (!existsSync(CLAUDE_CONFIG_PATH)) { return { error: '.claude.json not found' }; } const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); const config = JSON.parse(content); // Ensure top-level mcpServers exists if (!config.mcpServers) { config.mcpServers = {}; } // Add the server to top-level mcpServers config.mcpServers[serverName] = serverConfig; // Write back to file writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return { success: true, serverName, serverConfig, scope: 'global' }; } catch (error: unknown) { console.error('Error adding global MCP server:', error); return { error: (error as Error).message }; } } /** * Remove MCP server from global/user scope (top-level mcpServers) * @param {string} serverName * @returns {Object} */ function removeGlobalMcpServer(serverName) { try { if (!existsSync(CLAUDE_CONFIG_PATH)) { return { error: '.claude.json not found' }; } const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); const config = JSON.parse(content); if (!config.mcpServers || !config.mcpServers[serverName]) { return { error: `Global server not found: ${serverName}` }; } // Remove the server from top-level mcpServers delete config.mcpServers[serverName]; // Write back to file writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); return { success: true, serverName, removed: true, scope: 'global' }; } catch (error: unknown) { console.error('Error removing global MCP server:', error); return { error: (error as Error).message }; } } /** * Read settings file safely * @param {string} filePath * @returns {Object} */ function readSettingsFile(filePath) { try { if (!existsSync(filePath)) { return {}; } const content = readFileSync(filePath, 'utf8'); return JSON.parse(content); } catch (error: unknown) { console.error(`Error reading settings file ${filePath}:`, error); return {}; } } /** * Write settings file safely * @param {string} filePath * @param {Object} settings */ function writeSettingsFile(filePath, settings) { const dirPath = dirname(filePath); // Ensure directory exists if (!existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); } writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); } /** * Get project settings path * @param {string} projectPath * @returns {string} */ function getProjectSettingsPath(projectPath) { const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\'); return join(normalizedPath, '.claude', 'settings.json'); } // ======================================== // Route Handlers // ======================================== /** * Handle MCP routes * @returns true if route was handled, false otherwise */ export async function handleMcpRoutes(ctx: RouteContext): Promise { const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx; // API: Get MCP configuration if (pathname === '/api/mcp-config') { const mcpData = getMcpConfig(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(mcpData)); return true; } // API: Toggle MCP server enabled/disabled if (pathname === '/api/mcp-toggle' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { projectPath, serverName, enable } = body; if (!projectPath || !serverName) { return { error: 'projectPath and serverName are required', status: 400 }; } return toggleMcpServerEnabled(projectPath, serverName, enable); }); return true; } // API: Copy MCP server to project if (pathname === '/api/mcp-copy-server' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { projectPath, serverName, serverConfig } = body; if (!projectPath || !serverName || !serverConfig) { return { error: 'projectPath, serverName, and serverConfig are required', status: 400 }; } return addMcpServerToProject(projectPath, serverName, serverConfig); }); return true; } // API: Install CCW MCP server to project if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { projectPath } = body; if (!projectPath) { return { error: 'projectPath is required', status: 400 }; } // Generate CCW MCP server config const ccwMcpConfig = { command: "ccw-mcp", args: [] }; // Use existing addMcpServerToProject to install CCW MCP return addMcpServerToProject(projectPath, 'ccw-mcp', ccwMcpConfig); }); return true; } // API: Remove MCP server from project if (pathname === '/api/mcp-remove-server' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { projectPath, serverName } = body; if (!projectPath || !serverName) { return { error: 'projectPath and serverName are required', status: 400 }; } return removeMcpServerFromProject(projectPath, serverName); }); return true; } // API: Add MCP server to global scope (top-level mcpServers in ~/.claude.json) if (pathname === '/api/mcp-add-global-server' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { serverName, serverConfig } = body; if (!serverName || !serverConfig) { return { error: 'serverName and serverConfig are required', status: 400 }; } return addGlobalMcpServer(serverName, serverConfig); }); return true; } // API: Remove MCP server from global scope if (pathname === '/api/mcp-remove-global-server' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { const { serverName } = body; if (!serverName) { return { error: 'serverName is required', status: 400 }; } return removeGlobalMcpServer(serverName); }); return true; } return false; }