@ian2018cs/agenthub 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,630 @@
1
+ import express from 'express';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { spawn } from 'child_process';
5
+ import { getUserPaths, getPublicPaths } from '../services/user-directories.js';
6
+ import { SYSTEM_MCP_REPO_OWNER, SYSTEM_MCP_REPO_NAME } from '../services/system-mcp-repo.js';
7
+ import { addToCodexConfig, removeFromCodexConfig } from '../services/codex-mcp.js';
8
+
9
+ const router = express.Router();
10
+
11
+ // Trusted git hosting domains
12
+ const TRUSTED_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org', 'git.amberweather.com'];
13
+
14
+ function isSshUrl(url) {
15
+ return /^[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+:.+$/.test(url);
16
+ }
17
+
18
+ function parseSshUrl(url) {
19
+ const match = url.match(/^[a-zA-Z0-9_-]+@([a-zA-Z0-9.-]+):(.+)$/);
20
+ if (!match) return null;
21
+
22
+ const host = match[1].toLowerCase();
23
+ const pathPart = match[2].replace(/\.git$/, '');
24
+ const pathParts = pathPart.split('/');
25
+
26
+ if (pathParts.length >= 2) {
27
+ return { host, owner: pathParts[0], repo: pathParts[1] };
28
+ }
29
+ return { host, owner: null, repo: null };
30
+ }
31
+
32
+ function validateGitUrl(url) {
33
+ if (!url || typeof url !== 'string') {
34
+ return { valid: false, error: 'Repository URL is required' };
35
+ }
36
+
37
+ if (isSshUrl(url)) {
38
+ const parsed = parseSshUrl(url);
39
+ if (!parsed) {
40
+ return { valid: false, error: 'Invalid SSH URL format' };
41
+ }
42
+ if (!TRUSTED_GIT_HOSTS.includes(parsed.host)) {
43
+ return { valid: false, error: `Only trusted git hosts are allowed: ${TRUSTED_GIT_HOSTS.join(', ')}` };
44
+ }
45
+ return { valid: true, isSsh: true };
46
+ }
47
+
48
+ let parsedUrl;
49
+ try {
50
+ parsedUrl = new URL(url);
51
+ } catch {
52
+ return { valid: false, error: 'Invalid URL format' };
53
+ }
54
+
55
+ const host = parsedUrl.hostname.toLowerCase();
56
+ const allowedProtocols = ['https:', 'http:'];
57
+
58
+ if (host === 'git.amberweather.com') {
59
+ if (!allowedProtocols.includes(parsedUrl.protocol)) {
60
+ return { valid: false, error: 'Only HTTPS or HTTP URLs are allowed for this host' };
61
+ }
62
+ } else {
63
+ if (parsedUrl.protocol !== 'https:') {
64
+ return { valid: false, error: 'Only HTTPS URLs are allowed' };
65
+ }
66
+ }
67
+
68
+ if (!TRUSTED_GIT_HOSTS.includes(host)) {
69
+ return { valid: false, error: `Only trusted git hosts are allowed: ${TRUSTED_GIT_HOSTS.join(', ')}` };
70
+ }
71
+
72
+ return { valid: true };
73
+ }
74
+
75
+ function parseGitUrl(url) {
76
+ if (isSshUrl(url)) {
77
+ const parsed = parseSshUrl(url);
78
+ if (parsed && parsed.owner && parsed.repo) {
79
+ return { owner: parsed.owner, repo: parsed.repo };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ const parsedUrl = new URL(url);
85
+ const pathParts = parsedUrl.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
86
+ if (pathParts.length >= 2) {
87
+ return { owner: pathParts[0], repo: pathParts[1] };
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function cloneRepository(url, destinationPath) {
93
+ return new Promise((resolve, reject) => {
94
+ const gitProcess = spawn('git', ['clone', '--depth', '1', url, destinationPath], {
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
97
+ });
98
+
99
+ let stderr = '';
100
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
101
+ gitProcess.on('close', (code) => {
102
+ if (code === 0) resolve();
103
+ else reject(new Error(stderr || `Git clone failed with code ${code}`));
104
+ });
105
+ gitProcess.on('error', (err) => reject(err));
106
+ });
107
+ }
108
+
109
+ function updateRepository(repoPath) {
110
+ return new Promise((resolve, reject) => {
111
+ const gitProcess = spawn('git', ['pull', '--ff-only'], {
112
+ cwd: repoPath,
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
115
+ });
116
+
117
+ let stderr = '';
118
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
119
+ gitProcess.on('close', (code) => {
120
+ if (code === 0) resolve();
121
+ else reject(new Error(stderr || `Git pull failed with code ${code}`));
122
+ });
123
+ gitProcess.on('error', (err) => reject(err));
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Parse mcp.yaml metadata from an MCP service directory
129
+ */
130
+ async function parseMcpMetadata(servicePath) {
131
+ try {
132
+ const yamlPath = path.join(servicePath, 'mcp.yaml');
133
+ const content = await fs.readFile(yamlPath, 'utf-8');
134
+
135
+ let name = path.basename(servicePath);
136
+ let description = '';
137
+
138
+ const nameMatch = content.match(/^name:\s*(.+)$/m);
139
+ const descMatch = content.match(/^description:\s*(.+)$/m);
140
+ if (nameMatch) name = nameMatch[1].trim();
141
+ if (descMatch) description = descMatch[1].trim();
142
+
143
+ return { name, description };
144
+ } catch {
145
+ return { name: path.basename(servicePath), description: '' };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Check if a directory is a valid MCP service (must contain mcp.json)
151
+ */
152
+ async function isValidMcpService(servicePath) {
153
+ try {
154
+ const stat = await fs.stat(servicePath);
155
+ if (!stat.isDirectory()) return false;
156
+ await fs.access(path.join(servicePath, 'mcp.json'));
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Scan a repository directory for MCP service subdirectories
165
+ */
166
+ async function scanRepoForMcpServices(repoPath, repository, installedServerNames) {
167
+ const found = [];
168
+
169
+ let entries;
170
+ try {
171
+ entries = await fs.readdir(repoPath, { withFileTypes: true });
172
+ } catch {
173
+ return found;
174
+ }
175
+
176
+ for (const entry of entries) {
177
+ if (entry.name.startsWith('.') || !entry.isDirectory()) continue;
178
+
179
+ const servicePath = path.join(repoPath, entry.name);
180
+ if (await isValidMcpService(servicePath)) {
181
+ const metadata = await parseMcpMetadata(servicePath);
182
+
183
+ // Read mcp.json to get server names and type info
184
+ let mcpJson = {};
185
+ let serverNames = [];
186
+ let serviceType = 'stdio';
187
+ try {
188
+ const jsonContent = await fs.readFile(path.join(servicePath, 'mcp.json'), 'utf-8');
189
+ mcpJson = JSON.parse(jsonContent);
190
+ serverNames = Object.keys(mcpJson);
191
+ // Detect type from first server config
192
+ if (serverNames.length > 0) {
193
+ const firstConfig = mcpJson[serverNames[0]];
194
+ serviceType = firstConfig.type || 'stdio';
195
+ }
196
+ } catch {
197
+ // ignore parse errors
198
+ }
199
+
200
+ const installed = serverNames.length > 0 && serverNames.every(n => installedServerNames.has(n));
201
+
202
+ found.push({
203
+ dirName: entry.name,
204
+ name: metadata.name,
205
+ description: metadata.description,
206
+ repository,
207
+ type: serviceType,
208
+ serverNames,
209
+ installed,
210
+ path: servicePath
211
+ });
212
+ }
213
+ }
214
+
215
+ return found;
216
+ }
217
+
218
+ /**
219
+ * Read installed MCP server names from user's .claude.json
220
+ */
221
+ async function getInstalledMcpServerNames(userUuid) {
222
+ try {
223
+ const userPaths = getUserPaths(userUuid);
224
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
225
+ const content = await fs.readFile(claudeJsonPath, 'utf-8');
226
+ const config = JSON.parse(content);
227
+ return new Set(Object.keys(config.mcpServers || {}));
228
+ } catch {
229
+ return new Set();
230
+ }
231
+ }
232
+
233
+ /**
234
+ * GET /api/mcp-repos
235
+ * List user's MCP repositories
236
+ */
237
+ router.get('/', async (req, res) => {
238
+ try {
239
+ const userUuid = req.user?.uuid;
240
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
241
+
242
+ const userPaths = getUserPaths(userUuid);
243
+ await fs.mkdir(userPaths.mcpRepoDir, { recursive: true });
244
+
245
+ const entries = await fs.readdir(userPaths.mcpRepoDir, { withFileTypes: true });
246
+ const repos = [];
247
+
248
+ for (const ownerEntry of entries) {
249
+ if (ownerEntry.name.startsWith('.') || !ownerEntry.isDirectory()) continue;
250
+
251
+ const ownerPath = path.join(userPaths.mcpRepoDir, ownerEntry.name);
252
+ const repoEntries = await fs.readdir(ownerPath, { withFileTypes: true });
253
+
254
+ for (const repoEntry of repoEntries) {
255
+ if (repoEntry.name.startsWith('.') || (!repoEntry.isDirectory() && !repoEntry.isSymbolicLink())) continue;
256
+
257
+ const repoPath = path.join(ownerPath, repoEntry.name);
258
+ const services = await scanRepoForMcpServices(repoPath, `${ownerEntry.name}/${repoEntry.name}`, new Set());
259
+
260
+ repos.push({
261
+ owner: ownerEntry.name,
262
+ repo: repoEntry.name,
263
+ serviceCount: services.length,
264
+ path: repoPath,
265
+ isSystem: ownerEntry.name === SYSTEM_MCP_REPO_OWNER && repoEntry.name === SYSTEM_MCP_REPO_NAME
266
+ });
267
+ }
268
+ }
269
+
270
+ res.json({ repos });
271
+ } catch (error) {
272
+ console.error('Error listing MCP repos:', error);
273
+ res.status(500).json({ error: 'Failed to list MCP repos', details: error.message });
274
+ }
275
+ });
276
+
277
+ /**
278
+ * POST /api/mcp-repos
279
+ * Add (clone) a new MCP repository
280
+ */
281
+ router.post('/', async (req, res) => {
282
+ try {
283
+ const userUuid = req.user?.uuid;
284
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
285
+
286
+ let { url, branch = 'main' } = req.body;
287
+
288
+ // Support short format: owner/repo → GitHub URL
289
+ if (url && !url.includes('://') && !isSshUrl(url) && url.includes('/')) {
290
+ url = `https://github.com/${url}`;
291
+ }
292
+
293
+ const validation = validateGitUrl(url);
294
+ if (!validation.valid) {
295
+ return res.status(400).json({ error: validation.error });
296
+ }
297
+
298
+ const parsed = parseGitUrl(url);
299
+ if (!parsed) {
300
+ return res.status(400).json({ error: 'Could not parse repository owner/name from URL' });
301
+ }
302
+
303
+ const { owner, repo } = parsed;
304
+ const publicPaths = getPublicPaths();
305
+ const publicRepoPath = path.join(publicPaths.mcpRepoDir, owner, repo);
306
+
307
+ // Clone into shared public dir if not already there
308
+ let alreadyCloned = false;
309
+ try {
310
+ await fs.access(publicRepoPath);
311
+ alreadyCloned = true;
312
+ } catch {
313
+ // not cloned yet
314
+ }
315
+
316
+ if (!alreadyCloned) {
317
+ await fs.mkdir(path.join(publicPaths.mcpRepoDir, owner), { recursive: true });
318
+ console.log(`[MCP Repos] Cloning ${url} → ${publicRepoPath}`);
319
+ await cloneRepository(url, publicRepoPath);
320
+ }
321
+
322
+ // Create per-user symlink
323
+ const userPaths = getUserPaths(userUuid);
324
+ const userOwnerDir = path.join(userPaths.mcpRepoDir, owner);
325
+ await fs.mkdir(userOwnerDir, { recursive: true });
326
+
327
+ const userRepoLink = path.join(userOwnerDir, repo);
328
+ try {
329
+ await fs.lstat(userRepoLink);
330
+ // symlink already exists - that's fine
331
+ } catch {
332
+ await fs.symlink(publicRepoPath, userRepoLink);
333
+ }
334
+
335
+ const services = await scanRepoForMcpServices(publicRepoPath, `${owner}/${repo}`, new Set());
336
+
337
+ res.json({ success: true, owner, repo, serviceCount: services.length });
338
+ } catch (error) {
339
+ console.error('Error adding MCP repo:', error);
340
+ res.status(500).json({ error: 'Failed to add MCP repo', details: error.message });
341
+ }
342
+ });
343
+
344
+ /**
345
+ * POST /api/mcp-repos/refresh
346
+ * Pull updates for all user repos
347
+ */
348
+ router.post('/refresh', async (req, res) => {
349
+ try {
350
+ const userUuid = req.user?.uuid;
351
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
352
+
353
+ const publicPaths = getPublicPaths();
354
+ const publicMcpRepoDir = publicPaths.mcpRepoDir;
355
+
356
+ let updated = 0;
357
+ let failed = 0;
358
+
359
+ try {
360
+ const ownerEntries = await fs.readdir(publicMcpRepoDir, { withFileTypes: true });
361
+ for (const ownerEntry of ownerEntries) {
362
+ if (!ownerEntry.isDirectory() || ownerEntry.name.startsWith('.')) continue;
363
+ const ownerPath = path.join(publicMcpRepoDir, ownerEntry.name);
364
+ const repoEntries = await fs.readdir(ownerPath, { withFileTypes: true });
365
+ for (const repoEntry of repoEntries) {
366
+ if (!repoEntry.isDirectory() || repoEntry.name.startsWith('.')) continue;
367
+ const repoPath = path.join(ownerPath, repoEntry.name);
368
+ try {
369
+ await updateRepository(repoPath);
370
+ updated++;
371
+ } catch (err) {
372
+ console.error(`[MCP Repos] Failed to update ${ownerEntry.name}/${repoEntry.name}:`, err.message);
373
+ failed++;
374
+ }
375
+ }
376
+ }
377
+ } catch {
378
+ // public dir may not exist yet
379
+ }
380
+
381
+ res.json({ success: true, updated, failed });
382
+ } catch (error) {
383
+ console.error('Error refreshing MCP repos:', error);
384
+ res.status(500).json({ error: 'Failed to refresh repos', details: error.message });
385
+ }
386
+ });
387
+
388
+ /**
389
+ * GET /api/mcp-repos/available
390
+ * Scan all user repos for available MCP services
391
+ */
392
+ router.get('/available', async (req, res) => {
393
+ try {
394
+ const userUuid = req.user?.uuid;
395
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
396
+
397
+ const userPaths = getUserPaths(userUuid);
398
+ await fs.mkdir(userPaths.mcpRepoDir, { recursive: true });
399
+
400
+ const installedNames = await getInstalledMcpServerNames(userUuid);
401
+ const services = [];
402
+
403
+ const ownerEntries = await fs.readdir(userPaths.mcpRepoDir, { withFileTypes: true });
404
+ for (const ownerEntry of ownerEntries) {
405
+ if (ownerEntry.name.startsWith('.') || !ownerEntry.isDirectory()) continue;
406
+ const ownerPath = path.join(userPaths.mcpRepoDir, ownerEntry.name);
407
+ const repoEntries = await fs.readdir(ownerPath, { withFileTypes: true });
408
+ for (const repoEntry of repoEntries) {
409
+ if (repoEntry.name.startsWith('.') || (!repoEntry.isDirectory() && !repoEntry.isSymbolicLink())) continue;
410
+ const repoPath = path.join(ownerPath, repoEntry.name);
411
+ const found = await scanRepoForMcpServices(repoPath, `${ownerEntry.name}/${repoEntry.name}`, installedNames);
412
+ services.push(...found);
413
+ }
414
+ }
415
+
416
+ res.json({ services });
417
+ } catch (error) {
418
+ console.error('Error scanning MCP repos:', error);
419
+ res.status(500).json({ error: 'Failed to scan repos', details: error.message });
420
+ }
421
+ });
422
+
423
+ /**
424
+ * Run claude mcp add-json for a single server entry
425
+ * Format: claude mcp add-json --scope <scope> <name> '<json>'
426
+ */
427
+ function addMcpServerViaJson(name, config, scope, claudeDir) {
428
+ return new Promise((resolve, reject) => {
429
+ const jsonString = JSON.stringify(config);
430
+ const proc = spawn('claude', ['mcp', 'add-json', '--scope', scope, name, jsonString], {
431
+ stdio: ['ignore', 'pipe', 'pipe'],
432
+ env: { ...process.env, CLAUDE_CONFIG_DIR: claudeDir }
433
+ });
434
+
435
+ let stderr = '';
436
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
437
+ proc.on('close', (code) => {
438
+ if (code === 0) resolve();
439
+ else reject(new Error(stderr || `claude mcp add-json failed with code ${code}`));
440
+ });
441
+ proc.on('error', (err) => reject(err));
442
+ });
443
+ }
444
+
445
+ /**
446
+ * POST /api/mcp-repos/install
447
+ * Install an MCP service (add its config to user's .claude.json via claude mcp add-json)
448
+ */
449
+ router.post('/install', async (req, res) => {
450
+ try {
451
+ const userUuid = req.user?.uuid;
452
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
453
+
454
+ const { servicePath, scope = 'user' } = req.body;
455
+ if (!servicePath) return res.status(400).json({ error: 'servicePath is required' });
456
+
457
+ // Security: ensure servicePath is within mcp-repo directory (public or user symlink dir)
458
+ const publicPaths = getPublicPaths();
459
+ const userPaths = getUserPaths(userUuid);
460
+ const realServicePath = path.resolve(servicePath);
461
+ const realMcpRepoDir = path.resolve(publicPaths.mcpRepoDir);
462
+ const realUserMcpRepoDir = path.resolve(userPaths.mcpRepoDir);
463
+ if (!realServicePath.startsWith(realMcpRepoDir) && !realServicePath.startsWith(realUserMcpRepoDir)) {
464
+ return res.status(403).json({ error: 'Invalid service path' });
465
+ }
466
+
467
+ // Read mcp.json
468
+ const mcpJsonPath = path.join(servicePath, 'mcp.json');
469
+ let mcpJson;
470
+ try {
471
+ const content = await fs.readFile(mcpJsonPath, 'utf-8');
472
+ mcpJson = JSON.parse(content);
473
+ } catch (err) {
474
+ return res.status(400).json({ error: 'Failed to read mcp.json', details: err.message });
475
+ }
476
+ const installedServers = [];
477
+ const errors = [];
478
+
479
+ // Install each server entry using claude mcp add-json
480
+ for (const [serverName, serverConfig] of Object.entries(mcpJson)) {
481
+ try {
482
+ await addMcpServerViaJson(serverName, serverConfig, scope, userPaths.claudeDir);
483
+ installedServers.push(serverName);
484
+ } catch (err) {
485
+ console.error(`[MCP Repos] CLI install failed for "${serverName}", using fallback:`, err.message);
486
+ // Fallback: directly write to .claude.json
487
+ try {
488
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
489
+ let claudeConfig = {};
490
+ try {
491
+ claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
492
+ } catch {
493
+ claudeConfig = { hasCompletedOnboarding: true };
494
+ }
495
+ claudeConfig.mcpServers = { ...(claudeConfig.mcpServers || {}), [serverName]: serverConfig };
496
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
497
+ installedServers.push(serverName);
498
+ } catch (fallbackErr) {
499
+ errors.push(`${serverName}: ${fallbackErr.message}`);
500
+ }
501
+ }
502
+ }
503
+
504
+ if (errors.length > 0 && installedServers.length === 0) {
505
+ return res.status(500).json({ error: 'Failed to install MCP service', details: errors.join('; ') });
506
+ }
507
+
508
+ // Sync to Codex config.toml
509
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
510
+ try {
511
+ await addToCodexConfig(codexConfigPath, mcpJson);
512
+ } catch (err) {
513
+ console.error('[MCP Repos] Failed to sync to Codex config.toml:', err.message);
514
+ }
515
+
516
+ res.json({ success: true, servers: installedServers });
517
+ } catch (error) {
518
+ console.error('Error installing MCP service:', error);
519
+ res.status(500).json({ error: 'Failed to install MCP service', details: error.message });
520
+ }
521
+ });
522
+
523
+ /**
524
+ * DELETE /api/mcp-repos/uninstall/:name
525
+ * Uninstall an MCP server (remove from .claude.json via claude mcp remove)
526
+ */
527
+ router.delete('/uninstall/:name', async (req, res) => {
528
+ try {
529
+ const userUuid = req.user?.uuid;
530
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
531
+
532
+ const { name } = req.params;
533
+ const { scope = 'user' } = req.query;
534
+
535
+ const userPaths = getUserPaths(userUuid);
536
+
537
+ await new Promise((resolve, reject) => {
538
+ const proc = spawn('claude', ['mcp', 'remove', '--scope', scope, name], {
539
+ stdio: ['ignore', 'pipe', 'pipe'],
540
+ env: { ...process.env, CLAUDE_CONFIG_DIR: userPaths.claudeDir }
541
+ });
542
+
543
+ let stderr = '';
544
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
545
+ proc.on('close', (code) => {
546
+ if (code === 0) resolve();
547
+ else reject(new Error(stderr || `claude mcp remove failed with code ${code}`));
548
+ });
549
+ proc.on('error', (err) => reject(err));
550
+ });
551
+
552
+ // Sync removal to Codex config.toml
553
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
554
+ try {
555
+ await removeFromCodexConfig(codexConfigPath, name);
556
+ } catch (err) {
557
+ console.error('[MCP Repos] Failed to sync removal to Codex config.toml:', err.message);
558
+ }
559
+
560
+ res.json({ success: true });
561
+ } catch (error) {
562
+ console.error('Error uninstalling MCP server:', error);
563
+
564
+ // Fallback: directly remove from .claude.json
565
+ try {
566
+ const { name } = req.params;
567
+ const userPaths = getUserPaths(req.user.uuid);
568
+ const claudeJsonPath = path.join(userPaths.claudeDir, '.claude.json');
569
+ let claudeConfig = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
570
+ if (claudeConfig.mcpServers) {
571
+ delete claudeConfig.mcpServers[name];
572
+ }
573
+ await fs.writeFile(claudeJsonPath, JSON.stringify(claudeConfig, null, 2), 'utf-8');
574
+
575
+ // Sync removal to Codex config.toml
576
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
577
+ try {
578
+ await removeFromCodexConfig(codexConfigPath, name);
579
+ } catch (err) {
580
+ console.error('[MCP Repos] Failed to sync removal to Codex config.toml:', err.message);
581
+ }
582
+
583
+ return res.json({ success: true });
584
+ } catch (fallbackError) {
585
+ return res.status(500).json({ error: 'Failed to uninstall MCP server', details: error.message });
586
+ }
587
+ }
588
+ });
589
+
590
+ /**
591
+ * DELETE /api/mcp-repos/:owner/:repo
592
+ * Remove a repository (delete user's symlink)
593
+ */
594
+ router.delete('/:owner/:repo', async (req, res) => {
595
+ try {
596
+ const userUuid = req.user?.uuid;
597
+ if (!userUuid) return res.status(401).json({ error: 'User authentication required' });
598
+
599
+ const { owner, repo } = req.params;
600
+
601
+ // Validate names to prevent path traversal
602
+ if (!/^[a-zA-Z0-9_.-]+$/.test(owner) || !/^[a-zA-Z0-9_.-]+$/.test(repo)) {
603
+ return res.status(400).json({ error: 'Invalid owner or repo name' });
604
+ }
605
+
606
+ // System repo cannot be removed
607
+ if (owner === SYSTEM_MCP_REPO_OWNER && repo === SYSTEM_MCP_REPO_NAME) {
608
+ return res.status(403).json({ error: '内置仓库不能删除' });
609
+ }
610
+
611
+ const userPaths = getUserPaths(userUuid);
612
+ const userRepoLink = path.join(userPaths.mcpRepoDir, owner, repo);
613
+
614
+ try {
615
+ const stat = await fs.lstat(userRepoLink);
616
+ if (stat.isSymbolicLink() || stat.isDirectory()) {
617
+ await fs.rm(userRepoLink, { recursive: true, force: true });
618
+ }
619
+ } catch {
620
+ // Already gone
621
+ }
622
+
623
+ res.json({ success: true });
624
+ } catch (error) {
625
+ console.error('Error removing MCP repo:', error);
626
+ res.status(500).json({ error: 'Failed to remove repo', details: error.message });
627
+ }
628
+ });
629
+
630
+ export default router;
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { dirname } from 'path';
6
6
  import { getUserPaths } from '../services/user-directories.js';
7
+ import { addToCodexConfig, removeFromCodexConfig } from '../services/codex-mcp.js';
7
8
 
8
9
  const router = express.Router();
9
10
  const __filename = fileURLToPath(import.meta.url);
@@ -233,6 +234,11 @@ router.post('/cli/add-json', async (req, res) => {
233
234
  cliProcess.on('close', (code) => {
234
235
  if (code === 0) {
235
236
  res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
237
+ // Sync to Codex config.toml (fire-and-forget)
238
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
239
+ addToCodexConfig(codexConfigPath, { [name]: parsedConfig }).catch(err =>
240
+ console.error('[MCP] Failed to sync to Codex config.toml:', err.message)
241
+ );
236
242
  } else {
237
243
  console.error('Claude CLI error:', stderr);
238
244
  res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
@@ -310,6 +316,11 @@ router.delete('/cli/remove/:name', async (req, res) => {
310
316
  cliProcess.on('close', (code) => {
311
317
  if (code === 0) {
312
318
  res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
319
+ // Sync removal to Codex config.toml (fire-and-forget)
320
+ const codexConfigPath = path.join(userPaths.codexHomeDir, '.codex', 'config.toml');
321
+ removeFromCodexConfig(codexConfigPath, actualName).catch(err =>
322
+ console.error('[MCP] Failed to sync removal to Codex config.toml:', err.message)
323
+ );
313
324
  } else {
314
325
  console.error('Claude CLI error:', stderr);
315
326
  res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
@@ -316,7 +316,16 @@ router.get('/', async (req, res) => {
316
316
  isSymlink = stat.isSymbolicLink();
317
317
 
318
318
  if (isSymlink) {
319
- realPath = await fs.realpath(skillPath);
319
+ try {
320
+ realPath = await fs.realpath(skillPath);
321
+ } catch (realpathErr) {
322
+ if (realpathErr.code === 'ENOENT') {
323
+ // Dangling symlink - target no longer exists, remove it
324
+ await fs.unlink(skillPath).catch(() => {});
325
+ console.log(`[Skills] Removed dangling symlink: ${entry.name}`);
326
+ }
327
+ continue;
328
+ }
320
329
 
321
330
  // Determine source based on realPath
322
331
  if (realPath.includes('/skills-import/')) {