| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621 |
- import express from 'express';
- import { promises as fs } from 'fs';
- import path from 'path';
- import os from 'os';
- import { fileURLToPath } from 'url';
- import { dirname } from 'path';
- import { spawn } from 'child_process';
- import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
- import {
- listMcpConfigFiles,
- readMcpConfigFile,
- writeMcpConfigFile,
- normalizeMcpConfig,
- } from '../services/mcpConfig.js';
- const router = express.Router();
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = dirname(__filename);
- router.get('/config', async (req, res) => {
- try {
- const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : undefined;
- const configs = await listMcpConfigFiles(projectPath);
- res.json({ success: true, ...configs });
- } catch (error) {
- res.status(500).json({ error: 'Failed to read MCP config', details: error.message });
- }
- });
- router.get('/config/:scope', async (req, res) => {
- try {
- const { scope } = req.params;
- if (scope !== 'global' && scope !== 'project') {
- return res.status(400).json({ error: 'Invalid scope. Use global or project.' });
- }
- const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : undefined;
- const config = await readMcpConfigFile(scope, projectPath);
- res.json({ success: true, scope, ...config });
- } catch (error) {
- res.status(500).json({ error: 'Failed to read MCP config', details: error.message });
- }
- });
- router.post('/config/validate', async (req, res) => {
- try {
- const raw = typeof req.body?.raw === 'string' ? req.body.raw : JSON.stringify(req.body ?? {});
- normalizeMcpConfig(JSON.parse(raw));
- res.json({ valid: true, errors: [] });
- } catch (error) {
- res.json({ valid: false, errors: [error.message] });
- }
- });
- router.put('/config/:scope', async (req, res) => {
- try {
- const { scope } = req.params;
- if (scope !== 'global' && scope !== 'project') {
- return res.status(400).json({ error: 'Invalid scope. Use global or project.' });
- }
- const raw = typeof req.body?.raw === 'string' ? req.body.raw : JSON.stringify(req.body ?? {});
- const projectPath = typeof req.body?.projectPath === 'string' ? req.body.projectPath : undefined;
- const saved = await writeMcpConfigFile(scope, raw, projectPath);
- let reload = null;
- try {
- const gateway = await getPilotDeckGateway();
- reload = gateway.reloadExtensions
- ? await gateway.reloadExtensions({
- projectKey: scope === 'project' ? projectPath : undefined,
- changedPaths: [saved.path],
- })
- : await gateway.reloadConfig?.();
- } catch (error) {
- reload = { reloaded: false, error: error.message };
- }
- res.json({ success: true, scope, reload, ...saved });
- } catch (error) {
- res.status(400).json({ error: 'Failed to save MCP config', details: error.message });
- }
- });
- router.get('/cli/list', async (req, res) => {
- try {
- console.log('📋 Listing MCP servers using Claude CLI');
-
- const { spawn } = await import('child_process');
- const { promisify } = await import('util');
- const exec = promisify(spawn);
-
- const process = spawn('claude', ['mcp', 'list'], {
- stdio: ['pipe', 'pipe', 'pipe']
- });
-
- let stdout = '';
- let stderr = '';
-
- process.stdout.on('data', (data) => {
- stdout += data.toString();
- });
-
- process.stderr.on('data', (data) => {
- stderr += data.toString();
- });
-
- process.on('close', (code) => {
- if (res.headersSent) return;
- if (code === 0) {
- res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
- } else {
- console.error('Claude CLI error:', stderr);
- res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
- }
- });
-
- process.on('error', (error) => {
- if (res.headersSent) return;
- console.error('Error running Claude CLI:', error);
- res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
- });
- } catch (error) {
- console.error('Error listing MCP servers via CLI:', error);
- res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
- }
- });
- router.post('/cli/add', async (req, res) => {
- try {
- const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
-
- console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);
-
- const { spawn } = await import('child_process');
-
- let cliArgs = ['mcp', 'add'];
-
- // Add scope flag
- cliArgs.push('--scope', scope);
-
- if (type === 'http') {
- cliArgs.push('--transport', 'http', name, url);
- // Add headers if provided
- Object.entries(headers).forEach(([key, value]) => {
- cliArgs.push('--header', `${key}: ${value}`);
- });
- } else if (type === 'sse') {
- cliArgs.push('--transport', 'sse', name, url);
- // Add headers if provided
- Object.entries(headers).forEach(([key, value]) => {
- cliArgs.push('--header', `${key}: ${value}`);
- });
- } else {
- cliArgs.push(name);
- // Add environment variables
- Object.entries(env).forEach(([key, value]) => {
- cliArgs.push('-e', `${key}=${value}`);
- });
- cliArgs.push(command);
- if (args && args.length > 0) {
- cliArgs.push(...args);
- }
- }
-
- console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
-
- // For local scope, we need to run the command in the project directory
- const spawnOptions = {
- stdio: ['pipe', 'pipe', 'pipe']
- };
-
- if (scope === 'local' && projectPath) {
- spawnOptions.cwd = projectPath;
- console.log('📁 Running in project directory:', projectPath);
- }
-
- const process = spawn('claude', cliArgs, spawnOptions);
-
- let stdout = '';
- let stderr = '';
-
- process.stdout.on('data', (data) => {
- stdout += data.toString();
- });
-
- process.stderr.on('data', (data) => {
- stderr += data.toString();
- });
-
- process.on('close', (code) => {
- if (res.headersSent) return;
- if (code === 0) {
- res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
- } else {
- console.error('Claude CLI error:', stderr);
- res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
- }
- });
-
- process.on('error', (error) => {
- if (res.headersSent) return;
- console.error('Error running Claude CLI:', error);
- res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
- });
- } catch (error) {
- console.error('Error adding MCP server via CLI:', error);
- res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
- }
- });
- // POST /api/mcp/cli/add-json - Add MCP server using JSON format
- router.post('/cli/add-json', async (req, res) => {
- try {
- const { name, jsonConfig, scope = 'user', projectPath } = req.body;
-
- console.log('➕ Adding MCP server using JSON format:', name);
-
- // Validate and parse JSON config
- let parsedConfig;
- try {
- parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
- } catch (parseError) {
- return res.status(400).json({
- error: 'Invalid JSON configuration',
- details: parseError.message
- });
- }
-
- // Validate required fields
- if (!parsedConfig.type) {
- return res.status(400).json({
- error: 'Invalid configuration',
- details: 'Missing required field: type'
- });
- }
-
- if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
- return res.status(400).json({
- error: 'Invalid configuration',
- details: 'stdio type requires a command field'
- });
- }
-
- if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
- return res.status(400).json({
- error: 'Invalid configuration',
- details: `${parsedConfig.type} type requires a url field`
- });
- }
-
- const { spawn } = await import('child_process');
-
- const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
-
- // Add the JSON config as a properly formatted string
- const jsonString = JSON.stringify(parsedConfig);
- cliArgs.push(jsonString);
-
- console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
-
- // For local scope, we need to run the command in the project directory
- const spawnOptions = {
- stdio: ['pipe', 'pipe', 'pipe']
- };
-
- if (scope === 'local' && projectPath) {
- spawnOptions.cwd = projectPath;
- console.log('📁 Running in project directory:', projectPath);
- }
-
- const process = spawn('claude', cliArgs, spawnOptions);
-
- let stdout = '';
- let stderr = '';
-
- process.stdout.on('data', (data) => {
- stdout += data.toString();
- });
-
- process.stderr.on('data', (data) => {
- stderr += data.toString();
- });
-
- process.on('close', (code) => {
- if (res.headersSent) return;
- if (code === 0) {
- res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
- } else {
- console.error('Claude CLI error:', stderr);
- res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
- }
- });
-
- process.on('error', (error) => {
- if (res.headersSent) return;
- console.error('Error running Claude CLI:', error);
- res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
- });
- } catch (error) {
- console.error('Error adding MCP server via JSON:', error);
- res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
- }
- });
- router.delete('/cli/remove/:name', async (req, res) => {
- try {
- const { name } = req.params;
- const { scope } = req.query; // Get scope from query params
-
- // Handle the ID format (remove scope prefix if present)
- let actualName = name;
- let actualScope = scope;
-
- // If the name includes a scope prefix like "local:test", extract it
- if (name.includes(':')) {
- const [prefix, serverName] = name.split(':');
- actualName = serverName;
- actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
- }
-
- console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
-
- const { spawn } = await import('child_process');
-
- // Build command args based on scope
- let cliArgs = ['mcp', 'remove'];
-
- // Add scope flag if it's local scope
- if (actualScope === 'local') {
- cliArgs.push('--scope', 'local');
- } else if (actualScope === 'user' || !actualScope) {
- // User scope is default, but we can be explicit
- cliArgs.push('--scope', 'user');
- }
-
- cliArgs.push(actualName);
-
- console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
-
- const process = spawn('claude', cliArgs, {
- stdio: ['pipe', 'pipe', 'pipe']
- });
-
- let stdout = '';
- let stderr = '';
-
- process.stdout.on('data', (data) => {
- stdout += data.toString();
- });
-
- process.stderr.on('data', (data) => {
- stderr += data.toString();
- });
-
- process.on('close', (code) => {
- if (res.headersSent) return;
- if (code === 0) {
- res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
- } else {
- console.error('Claude CLI error:', stderr);
- res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
- }
- });
-
- process.on('error', (error) => {
- if (res.headersSent) return;
- console.error('Error running Claude CLI:', error);
- res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
- });
- } catch (error) {
- console.error('Error removing MCP server via CLI:', error);
- res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
- }
- });
- router.get('/cli/get/:name', async (req, res) => {
- try {
- const { name } = req.params;
-
- console.log('📄 Getting MCP server details using Claude CLI:', name);
-
- const { spawn } = await import('child_process');
-
- const process = spawn('claude', ['mcp', 'get', name], {
- stdio: ['pipe', 'pipe', 'pipe']
- });
-
- let stdout = '';
- let stderr = '';
-
- process.stdout.on('data', (data) => {
- stdout += data.toString();
- });
-
- process.stderr.on('data', (data) => {
- stderr += data.toString();
- });
-
- process.on('close', (code) => {
- if (res.headersSent) return;
- if (code === 0) {
- res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
- } else {
- console.error('Claude CLI error:', stderr);
- res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
- }
- });
-
- process.on('error', (error) => {
- if (res.headersSent) return;
- console.error('Error running Claude CLI:', error);
- res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
- });
- } catch (error) {
- console.error('Error getting MCP server details via CLI:', error);
- res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
- }
- });
- router.get('/config/read', async (req, res) => {
- try {
- console.log('📖 Reading MCP servers from Claude config files');
-
- const homeDir = os.homedir();
- const configPaths = [
- path.join(homeDir, '.claude.json'),
- path.join(homeDir, '.claude', 'settings.json')
- ];
-
- let configData = null;
- let configPath = null;
-
- // Try to read from either config file
- for (const filepath of configPaths) {
- try {
- const fileContent = await fs.readFile(filepath, 'utf8');
- configData = JSON.parse(fileContent);
- configPath = filepath;
- console.log(`✅ Found Claude config at: ${filepath}`);
- break;
- } catch (error) {
- // File doesn't exist or is not valid JSON, try next
- console.log(`ℹ️ Config not found or invalid at: ${filepath}`);
- }
- }
-
- if (!configData) {
- return res.json({
- success: false,
- message: 'No Claude configuration file found',
- servers: []
- });
- }
-
- // Extract MCP servers from the config
- const servers = [];
-
- // Check for user-scoped MCP servers (at root level)
- if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
- console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
- for (const [name, config] of Object.entries(configData.mcpServers)) {
- const server = {
- id: name,
- name: name,
- type: 'stdio', // Default type
- scope: 'user', // User scope - available across all projects
- config: {},
- raw: config // Include raw config for full details
- };
-
- // Determine transport type and extract config
- if (config.command) {
- server.type = 'stdio';
- server.config.command = config.command;
- server.config.args = config.args || [];
- server.config.env = config.env || {};
- } else if (config.url) {
- server.type = config.transport || 'http';
- server.config.url = config.url;
- server.config.headers = config.headers || {};
- }
-
- servers.push(server);
- }
- }
-
- // Check for local-scoped MCP servers (project-specific)
- const currentProjectPath = process.cwd();
-
- // Check under 'projects' key
- if (configData.projects && configData.projects[currentProjectPath]) {
- const projectConfig = configData.projects[currentProjectPath];
- if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
- console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
- for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
- const server = {
- id: `local:${name}`, // Prefix with scope for uniqueness
- name: name, // Keep original name
- type: 'stdio', // Default type
- scope: 'local', // Local scope - only for this project
- projectPath: currentProjectPath,
- config: {},
- raw: config // Include raw config for full details
- };
-
- // Determine transport type and extract config
- if (config.command) {
- server.type = 'stdio';
- server.config.command = config.command;
- server.config.args = config.args || [];
- server.config.env = config.env || {};
- } else if (config.url) {
- server.type = config.transport || 'http';
- server.config.url = config.url;
- server.config.headers = config.headers || {};
- }
-
- servers.push(server);
- }
- }
- }
-
- console.log(`📋 Found ${servers.length} MCP servers in config`);
-
- res.json({
- success: true,
- configPath: configPath,
- servers: servers
- });
- } catch (error) {
- console.error('Error reading Claude config:', error);
- res.status(500).json({
- error: 'Failed to read Claude configuration',
- details: error.message
- });
- }
- });
- function parseClaudeListOutput(output) {
- const servers = [];
- const lines = output.split('\n').filter(line => line.trim());
-
- for (const line of lines) {
- // Skip the header line
- if (line.includes('Checking MCP server health')) continue;
-
- // Parse lines like "test: test test - ✗ Failed to connect"
- // or "server-name: command or description - ✓ Connected"
- if (line.includes(':')) {
- const colonIndex = line.indexOf(':');
- const name = line.substring(0, colonIndex).trim();
-
- // Skip empty names
- if (!name) continue;
-
- // Extract the rest after the name
- const rest = line.substring(colonIndex + 1).trim();
-
- // Try to extract description and status
- let description = rest;
- let status = 'unknown';
- let type = 'stdio'; // default type
-
- // Check for status indicators
- if (rest.includes('✓') || rest.includes('✗')) {
- const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
- if (statusMatch) {
- description = statusMatch[1].trim();
- status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
- }
- }
-
- // Try to determine type from description
- if (description.startsWith('http://') || description.startsWith('https://')) {
- type = 'http';
- }
-
- servers.push({
- name,
- type,
- status: status || 'active',
- description
- });
- }
- }
-
- console.log('🔍 Parsed Claude CLI servers:', servers);
- return servers;
- }
- function parseClaudeGetOutput(output) {
- // This is a simple parser - might need adjustment based on actual output format
- try {
- // Try to extract JSON if present
- const jsonMatch = output.match(/\{[\s\S]*\}/);
- if (jsonMatch) {
- return JSON.parse(jsonMatch[0]);
- }
-
- // Otherwise, parse as text
- const server = { raw_output: output };
- const lines = output.split('\n');
-
- for (const line of lines) {
- if (line.includes('Name:')) {
- server.name = line.split(':')[1]?.trim();
- } else if (line.includes('Type:')) {
- server.type = line.split(':')[1]?.trim();
- } else if (line.includes('Command:')) {
- server.command = line.split(':')[1]?.trim();
- } else if (line.includes('URL:')) {
- server.url = line.split(':')[1]?.trim();
- }
- }
-
- return server;
- } catch (error) {
- return { raw_output: output, parse_error: error.message };
- }
- }
- export default router;
|