@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.
- package/dist/assets/index-BFl_Dhvn.css +32 -0
- package/dist/assets/index-IiiL0NTz.js +151 -0
- package/dist/assets/{vendor-icons-CX_nKP5H.js → vendor-icons-CDkQcAKw.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/cli.js +44 -5
- package/server/index.js +4 -0
- package/server/middleware/auth.js +14 -1
- package/server/routes/mcp-repos.js +630 -0
- package/server/routes/mcp.js +11 -0
- package/server/routes/skills.js +10 -1
- package/server/services/codex-mcp.js +148 -0
- package/server/services/feishu/command-handler.js +2 -2
- package/server/services/feishu/feishu-engine.js +2 -2
- package/server/services/system-mcp-repo.js +105 -0
- package/server/services/user-directories.js +22 -3
- package/dist/assets/index-Cuc7jDbP.css +0 -32
- package/dist/assets/index-SA2TCeJ4.js +0 -151
|
@@ -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;
|
package/server/routes/mcp.js
CHANGED
|
@@ -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 });
|
package/server/routes/skills.js
CHANGED
|
@@ -316,7 +316,16 @@ router.get('/', async (req, res) => {
|
|
|
316
316
|
isSymlink = stat.isSymbolicLink();
|
|
317
317
|
|
|
318
318
|
if (isSymlink) {
|
|
319
|
-
|
|
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/')) {
|