mcp.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. import express from 'express';
  2. import { promises as fs } from 'fs';
  3. import path from 'path';
  4. import os from 'os';
  5. import { fileURLToPath } from 'url';
  6. import { dirname } from 'path';
  7. import { spawn } from 'child_process';
  8. import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
  9. import {
  10. listMcpConfigFiles,
  11. readMcpConfigFile,
  12. writeMcpConfigFile,
  13. normalizeMcpConfig,
  14. } from '../services/mcpConfig.js';
  15. const router = express.Router();
  16. const __filename = fileURLToPath(import.meta.url);
  17. const __dirname = dirname(__filename);
  18. router.get('/config', async (req, res) => {
  19. try {
  20. const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : undefined;
  21. const configs = await listMcpConfigFiles(projectPath);
  22. res.json({ success: true, ...configs });
  23. } catch (error) {
  24. res.status(500).json({ error: 'Failed to read MCP config', details: error.message });
  25. }
  26. });
  27. router.get('/config/:scope', async (req, res) => {
  28. try {
  29. const { scope } = req.params;
  30. if (scope !== 'global' && scope !== 'project') {
  31. return res.status(400).json({ error: 'Invalid scope. Use global or project.' });
  32. }
  33. const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : undefined;
  34. const config = await readMcpConfigFile(scope, projectPath);
  35. res.json({ success: true, scope, ...config });
  36. } catch (error) {
  37. res.status(500).json({ error: 'Failed to read MCP config', details: error.message });
  38. }
  39. });
  40. router.post('/config/validate', async (req, res) => {
  41. try {
  42. const raw = typeof req.body?.raw === 'string' ? req.body.raw : JSON.stringify(req.body ?? {});
  43. normalizeMcpConfig(JSON.parse(raw));
  44. res.json({ valid: true, errors: [] });
  45. } catch (error) {
  46. res.json({ valid: false, errors: [error.message] });
  47. }
  48. });
  49. router.put('/config/:scope', async (req, res) => {
  50. try {
  51. const { scope } = req.params;
  52. if (scope !== 'global' && scope !== 'project') {
  53. return res.status(400).json({ error: 'Invalid scope. Use global or project.' });
  54. }
  55. const raw = typeof req.body?.raw === 'string' ? req.body.raw : JSON.stringify(req.body ?? {});
  56. const projectPath = typeof req.body?.projectPath === 'string' ? req.body.projectPath : undefined;
  57. const saved = await writeMcpConfigFile(scope, raw, projectPath);
  58. let reload = null;
  59. try {
  60. const gateway = await getPilotDeckGateway();
  61. reload = gateway.reloadExtensions
  62. ? await gateway.reloadExtensions({
  63. projectKey: scope === 'project' ? projectPath : undefined,
  64. changedPaths: [saved.path],
  65. })
  66. : await gateway.reloadConfig?.();
  67. } catch (error) {
  68. reload = { reloaded: false, error: error.message };
  69. }
  70. res.json({ success: true, scope, reload, ...saved });
  71. } catch (error) {
  72. res.status(400).json({ error: 'Failed to save MCP config', details: error.message });
  73. }
  74. });
  75. router.get('/cli/list', async (req, res) => {
  76. try {
  77. console.log('📋 Listing MCP servers using Claude CLI');
  78. const { spawn } = await import('child_process');
  79. const { promisify } = await import('util');
  80. const exec = promisify(spawn);
  81. const process = spawn('claude', ['mcp', 'list'], {
  82. stdio: ['pipe', 'pipe', 'pipe']
  83. });
  84. let stdout = '';
  85. let stderr = '';
  86. process.stdout.on('data', (data) => {
  87. stdout += data.toString();
  88. });
  89. process.stderr.on('data', (data) => {
  90. stderr += data.toString();
  91. });
  92. process.on('close', (code) => {
  93. if (res.headersSent) return;
  94. if (code === 0) {
  95. res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
  96. } else {
  97. console.error('Claude CLI error:', stderr);
  98. res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
  99. }
  100. });
  101. process.on('error', (error) => {
  102. if (res.headersSent) return;
  103. console.error('Error running Claude CLI:', error);
  104. res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
  105. });
  106. } catch (error) {
  107. console.error('Error listing MCP servers via CLI:', error);
  108. res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
  109. }
  110. });
  111. router.post('/cli/add', async (req, res) => {
  112. try {
  113. const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
  114. console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);
  115. const { spawn } = await import('child_process');
  116. let cliArgs = ['mcp', 'add'];
  117. // Add scope flag
  118. cliArgs.push('--scope', scope);
  119. if (type === 'http') {
  120. cliArgs.push('--transport', 'http', name, url);
  121. // Add headers if provided
  122. Object.entries(headers).forEach(([key, value]) => {
  123. cliArgs.push('--header', `${key}: ${value}`);
  124. });
  125. } else if (type === 'sse') {
  126. cliArgs.push('--transport', 'sse', name, url);
  127. // Add headers if provided
  128. Object.entries(headers).forEach(([key, value]) => {
  129. cliArgs.push('--header', `${key}: ${value}`);
  130. });
  131. } else {
  132. cliArgs.push(name);
  133. // Add environment variables
  134. Object.entries(env).forEach(([key, value]) => {
  135. cliArgs.push('-e', `${key}=${value}`);
  136. });
  137. cliArgs.push(command);
  138. if (args && args.length > 0) {
  139. cliArgs.push(...args);
  140. }
  141. }
  142. console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
  143. // For local scope, we need to run the command in the project directory
  144. const spawnOptions = {
  145. stdio: ['pipe', 'pipe', 'pipe']
  146. };
  147. if (scope === 'local' && projectPath) {
  148. spawnOptions.cwd = projectPath;
  149. console.log('📁 Running in project directory:', projectPath);
  150. }
  151. const process = spawn('claude', cliArgs, spawnOptions);
  152. let stdout = '';
  153. let stderr = '';
  154. process.stdout.on('data', (data) => {
  155. stdout += data.toString();
  156. });
  157. process.stderr.on('data', (data) => {
  158. stderr += data.toString();
  159. });
  160. process.on('close', (code) => {
  161. if (res.headersSent) return;
  162. if (code === 0) {
  163. res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
  164. } else {
  165. console.error('Claude CLI error:', stderr);
  166. res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
  167. }
  168. });
  169. process.on('error', (error) => {
  170. if (res.headersSent) return;
  171. console.error('Error running Claude CLI:', error);
  172. res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
  173. });
  174. } catch (error) {
  175. console.error('Error adding MCP server via CLI:', error);
  176. res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
  177. }
  178. });
  179. // POST /api/mcp/cli/add-json - Add MCP server using JSON format
  180. router.post('/cli/add-json', async (req, res) => {
  181. try {
  182. const { name, jsonConfig, scope = 'user', projectPath } = req.body;
  183. console.log('➕ Adding MCP server using JSON format:', name);
  184. // Validate and parse JSON config
  185. let parsedConfig;
  186. try {
  187. parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
  188. } catch (parseError) {
  189. return res.status(400).json({
  190. error: 'Invalid JSON configuration',
  191. details: parseError.message
  192. });
  193. }
  194. // Validate required fields
  195. if (!parsedConfig.type) {
  196. return res.status(400).json({
  197. error: 'Invalid configuration',
  198. details: 'Missing required field: type'
  199. });
  200. }
  201. if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
  202. return res.status(400).json({
  203. error: 'Invalid configuration',
  204. details: 'stdio type requires a command field'
  205. });
  206. }
  207. if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
  208. return res.status(400).json({
  209. error: 'Invalid configuration',
  210. details: `${parsedConfig.type} type requires a url field`
  211. });
  212. }
  213. const { spawn } = await import('child_process');
  214. const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
  215. // Add the JSON config as a properly formatted string
  216. const jsonString = JSON.stringify(parsedConfig);
  217. cliArgs.push(jsonString);
  218. console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
  219. // For local scope, we need to run the command in the project directory
  220. const spawnOptions = {
  221. stdio: ['pipe', 'pipe', 'pipe']
  222. };
  223. if (scope === 'local' && projectPath) {
  224. spawnOptions.cwd = projectPath;
  225. console.log('📁 Running in project directory:', projectPath);
  226. }
  227. const process = spawn('claude', cliArgs, spawnOptions);
  228. let stdout = '';
  229. let stderr = '';
  230. process.stdout.on('data', (data) => {
  231. stdout += data.toString();
  232. });
  233. process.stderr.on('data', (data) => {
  234. stderr += data.toString();
  235. });
  236. process.on('close', (code) => {
  237. if (res.headersSent) return;
  238. if (code === 0) {
  239. res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
  240. } else {
  241. console.error('Claude CLI error:', stderr);
  242. res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
  243. }
  244. });
  245. process.on('error', (error) => {
  246. if (res.headersSent) return;
  247. console.error('Error running Claude CLI:', error);
  248. res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
  249. });
  250. } catch (error) {
  251. console.error('Error adding MCP server via JSON:', error);
  252. res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
  253. }
  254. });
  255. router.delete('/cli/remove/:name', async (req, res) => {
  256. try {
  257. const { name } = req.params;
  258. const { scope } = req.query; // Get scope from query params
  259. // Handle the ID format (remove scope prefix if present)
  260. let actualName = name;
  261. let actualScope = scope;
  262. // If the name includes a scope prefix like "local:test", extract it
  263. if (name.includes(':')) {
  264. const [prefix, serverName] = name.split(':');
  265. actualName = serverName;
  266. actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
  267. }
  268. console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
  269. const { spawn } = await import('child_process');
  270. // Build command args based on scope
  271. let cliArgs = ['mcp', 'remove'];
  272. // Add scope flag if it's local scope
  273. if (actualScope === 'local') {
  274. cliArgs.push('--scope', 'local');
  275. } else if (actualScope === 'user' || !actualScope) {
  276. // User scope is default, but we can be explicit
  277. cliArgs.push('--scope', 'user');
  278. }
  279. cliArgs.push(actualName);
  280. console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
  281. const process = spawn('claude', cliArgs, {
  282. stdio: ['pipe', 'pipe', 'pipe']
  283. });
  284. let stdout = '';
  285. let stderr = '';
  286. process.stdout.on('data', (data) => {
  287. stdout += data.toString();
  288. });
  289. process.stderr.on('data', (data) => {
  290. stderr += data.toString();
  291. });
  292. process.on('close', (code) => {
  293. if (res.headersSent) return;
  294. if (code === 0) {
  295. res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
  296. } else {
  297. console.error('Claude CLI error:', stderr);
  298. res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
  299. }
  300. });
  301. process.on('error', (error) => {
  302. if (res.headersSent) return;
  303. console.error('Error running Claude CLI:', error);
  304. res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
  305. });
  306. } catch (error) {
  307. console.error('Error removing MCP server via CLI:', error);
  308. res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
  309. }
  310. });
  311. router.get('/cli/get/:name', async (req, res) => {
  312. try {
  313. const { name } = req.params;
  314. console.log('📄 Getting MCP server details using Claude CLI:', name);
  315. const { spawn } = await import('child_process');
  316. const process = spawn('claude', ['mcp', 'get', name], {
  317. stdio: ['pipe', 'pipe', 'pipe']
  318. });
  319. let stdout = '';
  320. let stderr = '';
  321. process.stdout.on('data', (data) => {
  322. stdout += data.toString();
  323. });
  324. process.stderr.on('data', (data) => {
  325. stderr += data.toString();
  326. });
  327. process.on('close', (code) => {
  328. if (res.headersSent) return;
  329. if (code === 0) {
  330. res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
  331. } else {
  332. console.error('Claude CLI error:', stderr);
  333. res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
  334. }
  335. });
  336. process.on('error', (error) => {
  337. if (res.headersSent) return;
  338. console.error('Error running Claude CLI:', error);
  339. res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
  340. });
  341. } catch (error) {
  342. console.error('Error getting MCP server details via CLI:', error);
  343. res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
  344. }
  345. });
  346. router.get('/config/read', async (req, res) => {
  347. try {
  348. console.log('📖 Reading MCP servers from Claude config files');
  349. const homeDir = os.homedir();
  350. const configPaths = [
  351. path.join(homeDir, '.claude.json'),
  352. path.join(homeDir, '.claude', 'settings.json')
  353. ];
  354. let configData = null;
  355. let configPath = null;
  356. // Try to read from either config file
  357. for (const filepath of configPaths) {
  358. try {
  359. const fileContent = await fs.readFile(filepath, 'utf8');
  360. configData = JSON.parse(fileContent);
  361. configPath = filepath;
  362. console.log(`✅ Found Claude config at: ${filepath}`);
  363. break;
  364. } catch (error) {
  365. // File doesn't exist or is not valid JSON, try next
  366. console.log(`ℹ️ Config not found or invalid at: ${filepath}`);
  367. }
  368. }
  369. if (!configData) {
  370. return res.json({
  371. success: false,
  372. message: 'No Claude configuration file found',
  373. servers: []
  374. });
  375. }
  376. // Extract MCP servers from the config
  377. const servers = [];
  378. // Check for user-scoped MCP servers (at root level)
  379. if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
  380. console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
  381. for (const [name, config] of Object.entries(configData.mcpServers)) {
  382. const server = {
  383. id: name,
  384. name: name,
  385. type: 'stdio', // Default type
  386. scope: 'user', // User scope - available across all projects
  387. config: {},
  388. raw: config // Include raw config for full details
  389. };
  390. // Determine transport type and extract config
  391. if (config.command) {
  392. server.type = 'stdio';
  393. server.config.command = config.command;
  394. server.config.args = config.args || [];
  395. server.config.env = config.env || {};
  396. } else if (config.url) {
  397. server.type = config.transport || 'http';
  398. server.config.url = config.url;
  399. server.config.headers = config.headers || {};
  400. }
  401. servers.push(server);
  402. }
  403. }
  404. // Check for local-scoped MCP servers (project-specific)
  405. const currentProjectPath = process.cwd();
  406. // Check under 'projects' key
  407. if (configData.projects && configData.projects[currentProjectPath]) {
  408. const projectConfig = configData.projects[currentProjectPath];
  409. if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
  410. console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
  411. for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
  412. const server = {
  413. id: `local:${name}`, // Prefix with scope for uniqueness
  414. name: name, // Keep original name
  415. type: 'stdio', // Default type
  416. scope: 'local', // Local scope - only for this project
  417. projectPath: currentProjectPath,
  418. config: {},
  419. raw: config // Include raw config for full details
  420. };
  421. // Determine transport type and extract config
  422. if (config.command) {
  423. server.type = 'stdio';
  424. server.config.command = config.command;
  425. server.config.args = config.args || [];
  426. server.config.env = config.env || {};
  427. } else if (config.url) {
  428. server.type = config.transport || 'http';
  429. server.config.url = config.url;
  430. server.config.headers = config.headers || {};
  431. }
  432. servers.push(server);
  433. }
  434. }
  435. }
  436. console.log(`📋 Found ${servers.length} MCP servers in config`);
  437. res.json({
  438. success: true,
  439. configPath: configPath,
  440. servers: servers
  441. });
  442. } catch (error) {
  443. console.error('Error reading Claude config:', error);
  444. res.status(500).json({
  445. error: 'Failed to read Claude configuration',
  446. details: error.message
  447. });
  448. }
  449. });
  450. function parseClaudeListOutput(output) {
  451. const servers = [];
  452. const lines = output.split('\n').filter(line => line.trim());
  453. for (const line of lines) {
  454. // Skip the header line
  455. if (line.includes('Checking MCP server health')) continue;
  456. // Parse lines like "test: test test - ✗ Failed to connect"
  457. // or "server-name: command or description - ✓ Connected"
  458. if (line.includes(':')) {
  459. const colonIndex = line.indexOf(':');
  460. const name = line.substring(0, colonIndex).trim();
  461. // Skip empty names
  462. if (!name) continue;
  463. // Extract the rest after the name
  464. const rest = line.substring(colonIndex + 1).trim();
  465. // Try to extract description and status
  466. let description = rest;
  467. let status = 'unknown';
  468. let type = 'stdio'; // default type
  469. // Check for status indicators
  470. if (rest.includes('✓') || rest.includes('✗')) {
  471. const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
  472. if (statusMatch) {
  473. description = statusMatch[1].trim();
  474. status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
  475. }
  476. }
  477. // Try to determine type from description
  478. if (description.startsWith('http://') || description.startsWith('https://')) {
  479. type = 'http';
  480. }
  481. servers.push({
  482. name,
  483. type,
  484. status: status || 'active',
  485. description
  486. });
  487. }
  488. }
  489. console.log('🔍 Parsed Claude CLI servers:', servers);
  490. return servers;
  491. }
  492. function parseClaudeGetOutput(output) {
  493. // This is a simple parser - might need adjustment based on actual output format
  494. try {
  495. // Try to extract JSON if present
  496. const jsonMatch = output.match(/\{[\s\S]*\}/);
  497. if (jsonMatch) {
  498. return JSON.parse(jsonMatch[0]);
  499. }
  500. // Otherwise, parse as text
  501. const server = { raw_output: output };
  502. const lines = output.split('\n');
  503. for (const line of lines) {
  504. if (line.includes('Name:')) {
  505. server.name = line.split(':')[1]?.trim();
  506. } else if (line.includes('Type:')) {
  507. server.type = line.split(':')[1]?.trim();
  508. } else if (line.includes('Command:')) {
  509. server.command = line.split(':')[1]?.trim();
  510. } else if (line.includes('URL:')) {
  511. server.url = line.split(':')[1]?.trim();
  512. }
  513. }
  514. return server;
  515. } catch (error) {
  516. return { raw_output: output, parse_error: error.message };
  517. }
  518. }
  519. export default router;