mcp-config-manager 2.1.1 → 2.3.0

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,268 @@
1
+ import * as fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { COMMANDS_PATH } from './skills-scopes.js';
4
+
5
+ // Mock support for testing
6
+ const USE_MOCK_COMMANDS = process.env.MCP_USE_MOCK_SKILLS === 'true';
7
+
8
+ /**
9
+ * Parse YAML frontmatter from a command file
10
+ * Format:
11
+ * ---
12
+ * description: Command description
13
+ * tools: tool1, tool2
14
+ * ...other metadata
15
+ * ---
16
+ * Prompt content here
17
+ */
18
+ function parseFrontmatter(content) {
19
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
20
+
21
+ if (!frontmatterMatch) {
22
+ return { metadata: {}, content: content.trim() };
23
+ }
24
+
25
+ const [, yamlPart, bodyPart] = frontmatterMatch;
26
+ const metadata = {};
27
+
28
+ // Simple YAML parsing for key: value pairs
29
+ const lines = yamlPart.split('\n');
30
+ for (const line of lines) {
31
+ const match = line.match(/^(\w+):\s*(.*)$/);
32
+ if (match) {
33
+ const [, key, value] = match;
34
+ // Handle arrays (comma-separated values)
35
+ if (value.includes(',')) {
36
+ metadata[key] = value.split(',').map(v => v.trim());
37
+ } else if (value.startsWith('"') && value.endsWith('"')) {
38
+ metadata[key] = value.slice(1, -1);
39
+ } else if (value.startsWith("'") && value.endsWith("'")) {
40
+ metadata[key] = value.slice(1, -1);
41
+ } else {
42
+ metadata[key] = value;
43
+ }
44
+ }
45
+ }
46
+
47
+ return { metadata, content: bodyPart.trim() };
48
+ }
49
+
50
+ /**
51
+ * Generate YAML frontmatter string from metadata object
52
+ */
53
+ function generateFrontmatter(metadata) {
54
+ if (!metadata || Object.keys(metadata).length === 0) {
55
+ return '';
56
+ }
57
+
58
+ const lines = ['---'];
59
+ for (const [key, value] of Object.entries(metadata)) {
60
+ if (Array.isArray(value)) {
61
+ lines.push(`${key}: ${value.join(', ')}`);
62
+ } else if (typeof value === 'string' && (value.includes(':') || value.includes('#') || value.includes('\n'))) {
63
+ lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
64
+ } else {
65
+ lines.push(`${key}: ${value}`);
66
+ }
67
+ }
68
+ lines.push('---');
69
+ lines.push('');
70
+
71
+ return lines.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Validate command name to prevent path traversal attacks
76
+ */
77
+ function validateCommandName(name) {
78
+ if (!name || name.includes('..') || name.includes('/') || name.includes('\\') || name.includes('\0')) {
79
+ throw new Error(`Invalid command name: '${name}'`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * CommandsManager handles CRUD operations for Claude Code slash commands
85
+ */
86
+ export class CommandsManager {
87
+ constructor() {
88
+ this.mockCommandsInitialized = false;
89
+ this.commandsPath = COMMANDS_PATH;
90
+ }
91
+
92
+ async initializeMockCommands() {
93
+ if (USE_MOCK_COMMANDS && !this.mockCommandsInitialized) {
94
+ try {
95
+ const mockModule = await import('../test/mock-skills.js');
96
+ this.commandsPath = mockModule.MOCK_COMMANDS_PATH;
97
+ this.mockCommandsInitialized = true;
98
+ } catch (error) {
99
+ console.warn('Mock commands not available, using production path');
100
+ }
101
+ }
102
+ }
103
+
104
+ async getCommandsPath() {
105
+ await this.initializeMockCommands();
106
+ return this.commandsPath;
107
+ }
108
+
109
+ /**
110
+ * List all commands
111
+ */
112
+ async listCommands() {
113
+ const commandsPath = await this.getCommandsPath();
114
+ const commands = [];
115
+
116
+ try {
117
+ await fs.access(commandsPath);
118
+ } catch {
119
+ return commands; // Commands directory doesn't exist
120
+ }
121
+
122
+ const entries = await fs.readdir(commandsPath, { withFileTypes: true });
123
+
124
+ for (const entry of entries) {
125
+ if (entry.name.startsWith('.')) continue;
126
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
127
+
128
+ try {
129
+ const filePath = path.join(commandsPath, entry.name);
130
+ const content = await fs.readFile(filePath, 'utf-8');
131
+ const { metadata } = parseFrontmatter(content);
132
+
133
+ // Command name is the filename without .md extension
134
+ const name = entry.name.replace(/\.md$/, '');
135
+
136
+ commands.push({
137
+ name,
138
+ path: filePath,
139
+ description: metadata.description || '',
140
+ tools: Array.isArray(metadata.tools) ? metadata.tools : (metadata.tools ? [metadata.tools] : []),
141
+ metadata
142
+ });
143
+ } catch (error) {
144
+ console.warn(`Error reading command ${entry.name}:`, error.message);
145
+ }
146
+ }
147
+
148
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
149
+ }
150
+
151
+ /**
152
+ * Read a single command
153
+ */
154
+ async readCommand(commandName) {
155
+ validateCommandName(commandName);
156
+ const commandsPath = await this.getCommandsPath();
157
+ const filePath = path.join(commandsPath, `${commandName}.md`);
158
+
159
+ try {
160
+ const content = await fs.readFile(filePath, 'utf-8');
161
+ const { metadata, content: promptContent } = parseFrontmatter(content);
162
+
163
+ return {
164
+ name: commandName,
165
+ path: filePath,
166
+ description: metadata.description || '',
167
+ tools: Array.isArray(metadata.tools) ? metadata.tools : (metadata.tools ? [metadata.tools] : []),
168
+ metadata,
169
+ content: promptContent,
170
+ fullContent: content
171
+ };
172
+ } catch (error) {
173
+ if (error.code === 'ENOENT') {
174
+ throw new Error(`Command '${commandName}' not found`);
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Create or update a command
182
+ */
183
+ async writeCommand(commandName, data) {
184
+ validateCommandName(commandName);
185
+ const commandsPath = await this.getCommandsPath();
186
+
187
+ // Ensure commands directory exists
188
+ await fs.mkdir(commandsPath, { recursive: true });
189
+
190
+ const { metadata = {}, content = '' } = data;
191
+
192
+ // Generate full content with frontmatter
193
+ const frontmatter = generateFrontmatter(metadata);
194
+ const fullContent = frontmatter + content;
195
+
196
+ const filePath = path.join(commandsPath, `${commandName}.md`);
197
+ await fs.writeFile(filePath, fullContent, 'utf-8');
198
+
199
+ return { path: filePath, written: true };
200
+ }
201
+
202
+ /**
203
+ * Delete a command
204
+ */
205
+ async deleteCommand(commandName) {
206
+ validateCommandName(commandName);
207
+ const commandsPath = await this.getCommandsPath();
208
+ const filePath = path.join(commandsPath, `${commandName}.md`);
209
+
210
+ try {
211
+ await fs.unlink(filePath);
212
+ return { deleted: true, path: filePath };
213
+ } catch (error) {
214
+ if (error.code === 'ENOENT') {
215
+ throw new Error(`Command '${commandName}' not found`);
216
+ }
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Rename a command
223
+ */
224
+ async renameCommand(oldName, newName) {
225
+ validateCommandName(oldName);
226
+ validateCommandName(newName);
227
+ const commandsPath = await this.getCommandsPath();
228
+ const oldPath = path.join(commandsPath, `${oldName}.md`);
229
+ const newPath = path.join(commandsPath, `${newName}.md`);
230
+
231
+ try {
232
+ await fs.access(oldPath);
233
+ } catch {
234
+ throw new Error(`Command '${oldName}' not found`);
235
+ }
236
+
237
+ try {
238
+ await fs.access(newPath);
239
+ throw new Error(`Command '${newName}' already exists`);
240
+ } catch (error) {
241
+ if (error.code !== 'ENOENT' && !error.message.includes('already exists')) {
242
+ throw error;
243
+ }
244
+ if (error.message.includes('already exists')) {
245
+ throw error;
246
+ }
247
+ }
248
+
249
+ await fs.rename(oldPath, newPath);
250
+ return { renamed: true, from: oldName, to: newName };
251
+ }
252
+
253
+ /**
254
+ * Duplicate a command
255
+ */
256
+ async duplicateCommand(commandName, newName) {
257
+ validateCommandName(commandName);
258
+ validateCommandName(newName);
259
+ const command = await this.readCommand(commandName);
260
+
261
+ await this.writeCommand(newName, {
262
+ metadata: command.metadata,
263
+ content: command.content
264
+ });
265
+
266
+ return { duplicated: true, from: commandName, to: newName };
267
+ }
268
+ }
package/src/server.js CHANGED
@@ -3,11 +3,15 @@ import cors from 'cors';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { MCPConfigManager } from './config-manager.js';
6
+ import { SkillsManager } from './skills-manager.js';
7
+ import { CommandsManager } from './commands-manager.js';
6
8
 
7
9
  const __filename = fileURLToPath(import.meta.url);
8
10
  const __dirname = path.dirname(__filename);
9
11
 
10
12
  const manager = new MCPConfigManager();
13
+ const skillsManager = new SkillsManager();
14
+ const commandsManager = new CommandsManager();
11
15
 
12
16
  export function startServer(port = 3456) {
13
17
  const app = express();
@@ -193,6 +197,302 @@ export function startServer(port = 3456) {
193
197
  }
194
198
  });
195
199
 
200
+ // Skills API Routes - Per-Client Architecture
201
+
202
+ // Get skill tabs for a specific client
203
+ app.get('/api/clients/:client/tabs', async (req, res) => {
204
+ try {
205
+ const tabs = await skillsManager.getClientTabs(req.params.client);
206
+ res.json({ tabs });
207
+ } catch (error) {
208
+ if (error.message.includes('not found')) {
209
+ return res.status(404).json({ error: error.message });
210
+ }
211
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
212
+ return res.status(400).json({ error: error.message });
213
+ }
214
+ res.status(500).json({ error: error.message });
215
+ }
216
+ });
217
+
218
+ // Check if client has skills support
219
+ app.get('/api/clients/:client/has-skills', async (req, res) => {
220
+ try {
221
+ const hasSkills = await skillsManager.clientHasSkills(req.params.client);
222
+ res.json({ hasSkills });
223
+ } catch (error) {
224
+ if (error.message.includes('not found')) {
225
+ return res.status(404).json({ error: error.message });
226
+ }
227
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
228
+ return res.status(400).json({ error: error.message });
229
+ }
230
+ res.status(500).json({ error: error.message });
231
+ }
232
+ });
233
+
234
+ // List items in a client's tab
235
+ app.get('/api/clients/:client/tabs/:tab/items', async (req, res) => {
236
+ try {
237
+ const items = await skillsManager.listItems(req.params.client, req.params.tab);
238
+ res.json({ items });
239
+ } catch (error) {
240
+ if (error.message.includes('not found')) {
241
+ return res.status(404).json({ error: error.message });
242
+ }
243
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
244
+ return res.status(400).json({ error: error.message });
245
+ }
246
+ res.status(500).json({ error: error.message });
247
+ }
248
+ });
249
+
250
+ // Get single item
251
+ app.get('/api/clients/:client/tabs/:tab/items/:name', async (req, res) => {
252
+ try {
253
+ const item = await skillsManager.readItem(req.params.client, req.params.tab, req.params.name);
254
+ res.json(item);
255
+ } catch (error) {
256
+ if (error.message.includes('not found')) {
257
+ return res.status(404).json({ error: error.message });
258
+ }
259
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
260
+ return res.status(400).json({ error: error.message });
261
+ }
262
+ res.status(500).json({ error: error.message });
263
+ }
264
+ });
265
+
266
+ // Create item
267
+ app.post('/api/clients/:client/tabs/:tab/items/:name', async (req, res) => {
268
+ try {
269
+ const result = await skillsManager.writeItem(req.params.client, req.params.tab, req.params.name, req.body);
270
+ res.json({ success: true, ...result });
271
+ } catch (error) {
272
+ if (error.message.includes('not found')) {
273
+ return res.status(404).json({ error: error.message });
274
+ }
275
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
276
+ return res.status(400).json({ error: error.message });
277
+ }
278
+ res.status(500).json({ error: error.message });
279
+ }
280
+ });
281
+
282
+ // Update item
283
+ app.put('/api/clients/:client/tabs/:tab/items/:name', async (req, res) => {
284
+ try {
285
+ const result = await skillsManager.writeItem(req.params.client, req.params.tab, req.params.name, req.body);
286
+ res.json({ success: true, ...result });
287
+ } catch (error) {
288
+ if (error.message.includes('not found')) {
289
+ return res.status(404).json({ error: error.message });
290
+ }
291
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
292
+ return res.status(400).json({ error: error.message });
293
+ }
294
+ res.status(500).json({ error: error.message });
295
+ }
296
+ });
297
+
298
+ // Delete item
299
+ app.delete('/api/clients/:client/tabs/:tab/items/:name', async (req, res) => {
300
+ try {
301
+ const result = await skillsManager.deleteItem(req.params.client, req.params.tab, req.params.name);
302
+ res.json({ success: true, ...result });
303
+ } catch (error) {
304
+ if (error.message.includes('not found')) {
305
+ return res.status(404).json({ error: error.message });
306
+ }
307
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
308
+ return res.status(400).json({ error: error.message });
309
+ }
310
+ res.status(500).json({ error: error.message });
311
+ }
312
+ });
313
+
314
+ // List files in a complex item
315
+ app.get('/api/clients/:client/tabs/:tab/items/:name/files', async (req, res) => {
316
+ try {
317
+ const files = await skillsManager.listItemFiles(req.params.client, req.params.tab, req.params.name);
318
+ res.json({ files });
319
+ } catch (error) {
320
+ if (error.message.includes('not found')) {
321
+ return res.status(404).json({ error: error.message });
322
+ }
323
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
324
+ return res.status(400).json({ error: error.message });
325
+ }
326
+ res.status(500).json({ error: error.message });
327
+ }
328
+ });
329
+
330
+ // Read file from complex item - Express 5 syntax: /*paramName for wildcard
331
+ app.get('/api/clients/:client/tabs/:tab/items/:name/files/*filePath', async (req, res) => {
332
+ try {
333
+ // filePath is an array of path segments in Express 5, join them
334
+ const filePath = Array.isArray(req.params.filePath)
335
+ ? req.params.filePath.join('/')
336
+ : req.params.filePath;
337
+ const file = await skillsManager.readItemFile(req.params.client, req.params.tab, req.params.name, filePath);
338
+ res.json(file);
339
+ } catch (error) {
340
+ if (error.message.includes('not found')) {
341
+ return res.status(404).json({ error: error.message });
342
+ }
343
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
344
+ return res.status(400).json({ error: error.message });
345
+ }
346
+ res.status(500).json({ error: error.message });
347
+ }
348
+ });
349
+
350
+ // Write file in complex item - Express 5 syntax: /*paramName for wildcard
351
+ app.put('/api/clients/:client/tabs/:tab/items/:name/files/*filePath', async (req, res) => {
352
+ try {
353
+ // filePath will be an array of path segments, join them
354
+ const filePath = Array.isArray(req.params.filePath)
355
+ ? req.params.filePath.join('/')
356
+ : req.params.filePath;
357
+ const { content } = req.body;
358
+ const result = await skillsManager.writeItemFile(req.params.client, req.params.tab, req.params.name, filePath, content);
359
+ res.json({ success: true, ...result });
360
+ } catch (error) {
361
+ if (error.message.includes('not found')) {
362
+ return res.status(404).json({ error: error.message });
363
+ }
364
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
365
+ return res.status(400).json({ error: error.message });
366
+ }
367
+ res.status(500).json({ error: error.message });
368
+ }
369
+ });
370
+
371
+ // Copy item within same tab
372
+ app.post('/api/clients/:client/tabs/:tab/items/:name/copy', async (req, res) => {
373
+ try {
374
+ const { newName } = req.body;
375
+ const result = await skillsManager.copyItem(req.params.client, req.params.tab, req.params.name, newName);
376
+ res.json({ success: true, ...result });
377
+ } catch (error) {
378
+ if (error.message.includes('not found')) {
379
+ return res.status(404).json({ error: error.message });
380
+ }
381
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
382
+ return res.status(400).json({ error: error.message });
383
+ }
384
+ res.status(500).json({ error: error.message });
385
+ }
386
+ });
387
+
388
+ // Commands API Routes
389
+ app.get('/api/commands', async (req, res) => {
390
+ try {
391
+ const commands = await commandsManager.listCommands();
392
+ res.json({ commands });
393
+ } catch (error) {
394
+ if (error.message.includes('not found')) {
395
+ return res.status(404).json({ error: error.message });
396
+ }
397
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
398
+ return res.status(400).json({ error: error.message });
399
+ }
400
+ res.status(500).json({ error: error.message });
401
+ }
402
+ });
403
+
404
+ app.get('/api/commands/:name', async (req, res) => {
405
+ try {
406
+ const command = await commandsManager.readCommand(req.params.name);
407
+ res.json(command);
408
+ } catch (error) {
409
+ if (error.message.includes('not found')) {
410
+ return res.status(404).json({ error: error.message });
411
+ }
412
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
413
+ return res.status(400).json({ error: error.message });
414
+ }
415
+ res.status(500).json({ error: error.message });
416
+ }
417
+ });
418
+
419
+ app.post('/api/commands/:name', async (req, res) => {
420
+ try {
421
+ await commandsManager.writeCommand(req.params.name, req.body);
422
+ res.json({ success: true });
423
+ } catch (error) {
424
+ if (error.message.includes('not found')) {
425
+ return res.status(404).json({ error: error.message });
426
+ }
427
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
428
+ return res.status(400).json({ error: error.message });
429
+ }
430
+ res.status(500).json({ error: error.message });
431
+ }
432
+ });
433
+
434
+ app.put('/api/commands/:name', async (req, res) => {
435
+ try {
436
+ await commandsManager.writeCommand(req.params.name, req.body);
437
+ res.json({ success: true });
438
+ } catch (error) {
439
+ if (error.message.includes('not found')) {
440
+ return res.status(404).json({ error: error.message });
441
+ }
442
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
443
+ return res.status(400).json({ error: error.message });
444
+ }
445
+ res.status(500).json({ error: error.message });
446
+ }
447
+ });
448
+
449
+ app.delete('/api/commands/:name', async (req, res) => {
450
+ try {
451
+ await commandsManager.deleteCommand(req.params.name);
452
+ res.json({ success: true });
453
+ } catch (error) {
454
+ if (error.message.includes('not found')) {
455
+ return res.status(404).json({ error: error.message });
456
+ }
457
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
458
+ return res.status(400).json({ error: error.message });
459
+ }
460
+ res.status(500).json({ error: error.message });
461
+ }
462
+ });
463
+
464
+ app.post('/api/commands/:name/rename', async (req, res) => {
465
+ try {
466
+ const { newName } = req.body;
467
+ await commandsManager.renameCommand(req.params.name, newName);
468
+ res.json({ success: true });
469
+ } catch (error) {
470
+ if (error.message.includes('not found')) {
471
+ return res.status(404).json({ error: error.message });
472
+ }
473
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
474
+ return res.status(400).json({ error: error.message });
475
+ }
476
+ res.status(500).json({ error: error.message });
477
+ }
478
+ });
479
+
480
+ app.post('/api/commands/:name/duplicate', async (req, res) => {
481
+ try {
482
+ const { newName } = req.body;
483
+ await commandsManager.duplicateCommand(req.params.name, newName);
484
+ res.json({ success: true });
485
+ } catch (error) {
486
+ if (error.message.includes('not found')) {
487
+ return res.status(404).json({ error: error.message });
488
+ }
489
+ if (error.message.includes('Invalid') || error.message.includes('already exists')) {
490
+ return res.status(400).json({ error: error.message });
491
+ }
492
+ res.status(500).json({ error: error.message });
493
+ }
494
+ });
495
+
196
496
  app.listen(port, () => {
197
497
  console.log(`MCP Config Manager server running on http://localhost:${port}`);
198
498
  });