@open-skills-hub/mcp-server 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1490 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SkillsHub MCP Server
4
+ *
5
+ * Model Context Protocol (MCP) server for AI Agent Skills management.
6
+ *
7
+ * This server enables AI agents to discover, retrieve, publish, and manage
8
+ * reusable skills through a standardized protocol. Think of it as "npm for AI agents"
9
+ * - a centralized hub for sharing and discovering agent capabilities.
10
+ *
11
+ * Features:
12
+ * - 🔍 Search skills by keywords, category, or author
13
+ * - 📥 Retrieve complete skill content with all attachments
14
+ * - 📤 Publish new skills or update existing ones
15
+ * - 🔒 Security scanning with built-in SkillsGuard rules
16
+ * - 💬 Feedback system for skill quality improvement
17
+ * - 💾 Local caching for offline usage
18
+ * - 📦 Install skills directly into projects
19
+ *
20
+ * Architecture:
21
+ * - Local-first: All data stored in SQLite, no cloud dependency
22
+ * - Secure: Automatic security scanning on publish
23
+ * - Versioned: Full semantic versioning support
24
+ * - Async feedback: Offline feedback queue with auto-retry
25
+ *
26
+ * @see https://github.com/OpenSkillsHub/open-skills-hub
27
+ * @see https://modelcontextprotocol.io
28
+ */
29
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
30
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
31
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
32
+ import * as fs from 'fs';
33
+ import * as path from 'path';
34
+ import { parse as parseYaml } from 'yaml';
35
+ import { initConfig, getStorage, initScanner, getScanner, parseSkillFullName, buildSkillFullName, generateUUID, now, sha256, isValidSkillName, isValidSemver, logger, } from '@open-skills-hub/core';
36
+ import { FeedbackQueueProcessor } from './queue.js';
37
+ // Server info
38
+ const SERVER_NAME = 'open-skills-hub';
39
+ const SERVER_VERSION = '1.0.0';
40
+ // HTTP client for API reload
41
+ async function reloadApi(apiUrl) {
42
+ try {
43
+ // Remove trailing slash and ensure http:// prefix
44
+ const url = apiUrl.replace(/\/$/, '');
45
+ logger.info(`Attempting to reload API at ${url}/reload`);
46
+ const response = await fetch(`${url}/reload`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ },
51
+ });
52
+ if (response.ok) {
53
+ logger.info('API reloaded successfully');
54
+ }
55
+ else {
56
+ logger.warn(`Failed to reload API: ${response.status} ${response.statusText}`);
57
+ }
58
+ }
59
+ catch (error) {
60
+ // Don't fail the publish operation if reload fails
61
+ logger.warn('Failed to call API reload', { error });
62
+ }
63
+ }
64
+ /**
65
+ * Create and configure the MCP server
66
+ */
67
+ async function createMcpServer() {
68
+ // Initialize config
69
+ const config = initConfig();
70
+ await config.load();
71
+ // Initialize storage
72
+ const storage = await getStorage();
73
+ await storage.initialize();
74
+ // Initialize scanner
75
+ initScanner({
76
+ enabled: config.get().scanner.enabled,
77
+ timeout: config.get().scanner.timeout,
78
+ maxFileSize: config.get().scanner.maxFileSize,
79
+ });
80
+ // Start feedback queue processor
81
+ const queueProcessor = new FeedbackQueueProcessor(storage, config.get().retry);
82
+ queueProcessor.start();
83
+ // Create MCP server
84
+ const server = new Server({
85
+ name: SERVER_NAME,
86
+ version: SERVER_VERSION,
87
+ }, {
88
+ capabilities: {
89
+ tools: {},
90
+ },
91
+ });
92
+ // ============================================================================
93
+ // MCP Tools Registration
94
+ // ============================================================================
95
+ //
96
+ // SkillsHub provides 7 core tools for managing AI Agent Skills:
97
+ //
98
+ // Discovery & Retrieval:
99
+ // - skills_search: Find skills by keywords, category, or author
100
+ // - skills_get: Retrieve complete skill content with attachments
101
+ // - skills_list_cached: View locally cached skills
102
+ //
103
+ // Publishing & Installation:
104
+ // - skills_publish: Upload skills to the hub
105
+ // - skills_install: Download and install skills to projects
106
+ //
107
+ // Quality & Safety:
108
+ // - skills_scan: Security scanning with SkillsGuard
109
+ // - skills_feedback: Submit ratings and feedback
110
+ //
111
+ // All tools use clear, explicit parameter names and provide detailed
112
+ // error messages to help AI agents use them correctly.
113
+ //
114
+ // ============================================================================
115
+ // Register tool handlers
116
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
+ tools: [
118
+ {
119
+ name: 'skills_search',
120
+ description: 'Search Agent Skills: Find skills by keywords, category, or author. Returns a list where each skill has a "Name" field (e.g., "@official/xlsx") that should be used as the "name" parameter in skills_install or skills_get.',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ query: {
125
+ type: 'string',
126
+ description: 'Search query - can be skill name, description keywords, or feature characteristics. Leave EMPTY ("") to list all skills. DO NOT use wildcards like "*" or "%" - they will be treated as literal characters. Examples: "" (all skills), "calculator", "API", "design", "pdf"',
127
+ },
128
+ category: {
129
+ type: 'string',
130
+ description: 'Filter by category. Examples: "utility", "development", "design"',
131
+ },
132
+ author: {
133
+ type: 'string',
134
+ description: 'Filter by author or organization. Examples: "@official", "@user", "@example"',
135
+ },
136
+ limit: {
137
+ type: 'number',
138
+ description: 'Maximum number of results to return. Default: 10, Maximum: 100. Use higher limit (e.g., 50-100) when listing all skills.',
139
+ },
140
+ },
141
+ },
142
+ },
143
+ {
144
+ name: 'skills_get',
145
+ description: 'Get complete skill content: Retrieve SKILL.md documentation, all attachments, metadata, and version information. Specify a version or get the latest. Use this before using a skill to understand its functionality.',
146
+ inputSchema: {
147
+ type: 'object',
148
+ properties: {
149
+ name: {
150
+ type: 'string',
151
+ description: 'REQUIRED. Full skill name (use the "Name" field from skills_search results). Valid formats: "calculator", "@user/calculator", "@official/code-reviewer".',
152
+ },
153
+ version: {
154
+ type: 'string',
155
+ description: 'Optional. Specific version to retrieve in semver format. Examples: "1.0.0", "2.1.3". If omitted, returns latest version.',
156
+ },
157
+ },
158
+ required: ['name'],
159
+ },
160
+ },
161
+ {
162
+ name: 'skills_scan',
163
+ description: 'Security scan: Check skill content for potential security risks including malicious code, dangerous commands, and sensitive information leaks. Returns a security score (0-100) and detailed report.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ content: {
168
+ type: 'string',
169
+ description: 'REQUIRED. Text content to scan - typically SKILL.md content or script code.',
170
+ },
171
+ },
172
+ required: ['content'],
173
+ },
174
+ },
175
+ {
176
+ name: 'skills_feedback',
177
+ description: 'Submit skill feedback: Help improve skill quality by reporting success, failure, suggestions, or bugs. Feedback is recorded and affects skill ratings.',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: {
181
+ name: {
182
+ type: 'string',
183
+ description: 'REQUIRED. Full skill name with scope (same as from skills_search). Example: "@official/calculator"',
184
+ },
185
+ version: {
186
+ type: 'string',
187
+ description: 'Optional. Skill version. Example: "1.0.0". If omitted, feedback applies to latest version.',
188
+ },
189
+ type: {
190
+ type: 'string',
191
+ enum: ['success', 'failure', 'suggestion', 'bug'],
192
+ description: 'REQUIRED. Feedback type: "success" (worked well), "failure" (didn\'t work), "suggestion" (improvement idea), "bug" (found a bug)',
193
+ },
194
+ rating: {
195
+ type: 'number',
196
+ minimum: 1,
197
+ maximum: 5,
198
+ description: 'REQUIRED. Rating from 1-5 stars: 1=very poor, 2=poor, 3=average, 4=good, 5=excellent',
199
+ },
200
+ comment: {
201
+ type: 'string',
202
+ description: 'Optional. Detailed comment or suggestion to help the author understand specific issues or improvement directions.',
203
+ },
204
+ },
205
+ required: ['name', 'type', 'rating'],
206
+ },
207
+ },
208
+ {
209
+ name: 'skills_list_cached',
210
+ description: 'List locally cached skills: Show all skills downloaded to local cache with cache time, usage count, and other metadata. Useful for managing local storage.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {},
214
+ },
215
+ },
216
+ {
217
+ name: 'skills_publish',
218
+ description: `Publish skill to hub: Upload a locally developed skill to Open Skills Hub, making it searchable and usable by others.
219
+
220
+ ⚠️ RECOMMENDED METHOD: Use Shell tool to call CLI directly - more stable and faster:
221
+ cd /Users/george/Documents/AI项目/SkillsHub && node packages/cli/dist/index.js publish <path> -s <scope> -a <author> -v <version> -y
222
+
223
+ Example:
224
+ node packages/cli/dist/index.js publish "/Users/george/my-skills/calculator" -s @user -a "George" -v 1.0.0 -y
225
+
226
+ Arguments:
227
+ <path>: Skill directory path (required)
228
+ -s, --scope: Namespace (optional), e.g., @user, @official
229
+ -a, --author: Author name (required if not in frontmatter)
230
+ -v, --version: Version number (optional), e.g., 1.0.0
231
+ -y, --yes: Skip confirmation prompt
232
+
233
+ If you must use this MCP tool (not recommended), the directory must contain a SKILL.md file with "author" in frontmatter.`,
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ path: {
238
+ type: 'string',
239
+ description: 'REQUIRED. Absolute path to skill directory containing SKILL.md. Examples: "/Users/george/my-skills/calculator", "/tmp/my-new-skill"',
240
+ },
241
+ scope: {
242
+ type: 'string',
243
+ description: 'Optional. Namespace/scope for organizing skills and identifying ownership. Examples: "@user" (personal), "@official" (official), "@myorg" (organization). The @ prefix is optional.',
244
+ },
245
+ author: {
246
+ type: 'string',
247
+ description: 'REQUIRED (if not in frontmatter). Author name or organization. Examples: "George", "SkillsHub Team", "@mycompany". Will be saved as ownerId.',
248
+ },
249
+ version: {
250
+ type: 'string',
251
+ description: 'Optional. Semantic version number (e.g., "1.0.0"). If omitted, auto-increments patch version for updates, or defaults to "1.0.0" for new skills.',
252
+ },
253
+ },
254
+ required: ['path'],
255
+ },
256
+ },
257
+ {
258
+ name: 'skills_install',
259
+ description: 'Install skill to project: Download a skill from the hub and install it to a directory. Automatically detects the platform (Cursor, Codex, CodeBuddy, etc.) and uses appropriate default directory, or you can specify a custom path. Creates the skill folder with SKILL.md and all attachments (scripts, configs, docs, etc.). Directories are created automatically if they don\'t exist.',
260
+ inputSchema: {
261
+ type: 'object',
262
+ properties: {
263
+ name: {
264
+ type: 'string',
265
+ description: 'REQUIRED. Full skill name (use the "Name" field from skills_search results, NOT "skillId"). Valid formats: "calculator", "@user/calculator", "@official/xlsx".',
266
+ },
267
+ targetDir: {
268
+ type: 'string',
269
+ description: 'Optional. Absolute path to PARENT directory (NOT including skill name). System will create a subdirectory named after the skill. Example: use "/path/to/skills" NOT "/path/to/skills/xlsx". If omitted, uses platform defaults: Cursor (~/.cursor/skills), Codex (~/.codex/skills), CodeBuddy (~/.codebuddy/skills).',
270
+ },
271
+ platform: {
272
+ type: 'string',
273
+ enum: ['cursor', 'codex', 'codebuddy', 'claude', 'anthropic'],
274
+ description: 'Optional. Specify target platform to use its default directory. If omitted, platform is auto-detected. Examples: "cursor", "codex", "codebuddy"',
275
+ },
276
+ version: {
277
+ type: 'string',
278
+ description: 'Optional. Specific version to install in semver format. Examples: "1.0.0", "2.1.3". If omitted, installs latest version.',
279
+ },
280
+ },
281
+ required: ['name'],
282
+ },
283
+ },
284
+ ],
285
+ }));
286
+ // Handle tool calls
287
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
288
+ const { name: toolName, arguments: args } = request.params;
289
+ try {
290
+ switch (toolName) {
291
+ case 'skills_search':
292
+ return await handleSearch(storage, args);
293
+ case 'skills_get':
294
+ return await handleGet(storage, args);
295
+ case 'skills_scan':
296
+ return await handleScan(args);
297
+ case 'skills_feedback':
298
+ return await handleFeedback(storage, queueProcessor, args);
299
+ case 'skills_list_cached':
300
+ return await handleListCached(storage);
301
+ case 'skills_publish':
302
+ return await handlePublish(storage, config, args);
303
+ case 'skills_install':
304
+ return await handleInstall(storage, args);
305
+ default:
306
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
307
+ }
308
+ }
309
+ catch (error) {
310
+ if (error instanceof McpError)
311
+ throw error;
312
+ logger.error('Tool call error', { toolName, error });
313
+ throw new McpError(ErrorCode.InternalError, error instanceof Error ? error.message : 'Unknown error');
314
+ }
315
+ });
316
+ return server;
317
+ }
318
+ // ============================================================================
319
+ // Tool Handlers
320
+ // ============================================================================
321
+ //
322
+ // Each tool handler is responsible for:
323
+ // 1. Validating input parameters
324
+ // 2. Executing the requested operation
325
+ // 3. Formatting the response in MCP format
326
+ // 4. Providing clear error messages for AI agents
327
+ //
328
+ // All handlers throw McpError with appropriate error codes for failures.
329
+ // ============================================================================
330
+ // ============================================================================
331
+ // Platform Detection and Default Directories
332
+ // ============================================================================
333
+ //
334
+ // Different AI platforms store skills in different locations. This section
335
+ // provides platform detection and default directory resolution to improve
336
+ // the installation experience across platforms.
337
+ // ============================================================================
338
+ /**
339
+ * Platform-specific default skills directories
340
+ * Used when targetDir is not explicitly provided
341
+ */
342
+ const PLATFORM_SKILLS_DIRS = {
343
+ cursor: '~/.cursor/skills',
344
+ codex: '~/.codex/skills',
345
+ codebuddy: '~/.codebuddy/skills',
346
+ claude: '~/.claude/skills',
347
+ anthropic: '~/.anthropic/skills',
348
+ };
349
+ /**
350
+ * Detect current platform based on multiple signals
351
+ * Priority: 0. CLI argument, 1. Process info, 2. Environment variables, 3. Directory timestamps, 4. Existing directories
352
+ * @returns Platform name or 'unknown'
353
+ */
354
+ function detectPlatform() {
355
+ // PRIORITY 0: Use CLI-specified platform if provided
356
+ if (CLI_PLATFORM) {
357
+ logger.info(`Using CLI-specified platform: ${CLI_PLATFORM}`);
358
+ return CLI_PLATFORM;
359
+ }
360
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '~';
361
+ // PRIORITY 1: Check parent process and application path
362
+ try {
363
+ const { execSync } = require('child_process');
364
+ const ppid = process.ppid;
365
+ if (ppid) {
366
+ // Check process name
367
+ const parentCmd = execSync(`ps -p ${ppid} -o comm=`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim().toLowerCase();
368
+ if (parentCmd.includes('cursor'))
369
+ return 'cursor';
370
+ if (parentCmd.includes('codebuddy'))
371
+ return 'codebuddy';
372
+ if (parentCmd.includes('codex'))
373
+ return 'codex';
374
+ if (parentCmd.includes('claude'))
375
+ return 'claude';
376
+ // Check full command/path (for apps that don't show name in comm)
377
+ try {
378
+ const fullArgs = execSync(`ps -p ${ppid} -o args=`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim().toLowerCase();
379
+ if (fullArgs.includes('codebuddy'))
380
+ return 'codebuddy';
381
+ if (fullArgs.includes('cursor'))
382
+ return 'cursor';
383
+ if (fullArgs.includes('codex'))
384
+ return 'codex';
385
+ }
386
+ catch (e2) {
387
+ // Ignore
388
+ }
389
+ }
390
+ }
391
+ catch (e) {
392
+ // Process detection failed, continue with other methods
393
+ }
394
+ // PRIORITY 2: Check environment variables and working directory
395
+ if (process.env.CURSOR_USER_DATA || process.env.CURSOR_AGENT)
396
+ return 'cursor';
397
+ if (process.env.CODEX_HOME)
398
+ return 'codex';
399
+ if (process.env.CODEBUDDY_HOME)
400
+ return 'codebuddy';
401
+ // Check if process was launched from CodeBuddy's Application Support
402
+ try {
403
+ const cwd = process.cwd();
404
+ if (cwd.includes('CodeBuddy'))
405
+ return 'codebuddy';
406
+ if (cwd.includes('Cursor'))
407
+ return 'cursor';
408
+ }
409
+ catch (e) {
410
+ // Ignore
411
+ }
412
+ // PRIORITY 3: Check most recently modified skills directory (user is likely using this platform)
413
+ try {
414
+ let latestPlatform = '';
415
+ let latestTime = 0;
416
+ for (const [platform, dir] of Object.entries(PLATFORM_SKILLS_DIRS)) {
417
+ const expandedDir = dir.replace('~', homeDir);
418
+ if (fs.existsSync(expandedDir)) {
419
+ const stats = fs.statSync(expandedDir);
420
+ if (stats.mtimeMs > latestTime) {
421
+ latestTime = stats.mtimeMs;
422
+ latestPlatform = platform;
423
+ }
424
+ }
425
+ }
426
+ if (latestPlatform) {
427
+ return latestPlatform;
428
+ }
429
+ }
430
+ catch (e) {
431
+ // Timestamp check failed, continue with simple existence check
432
+ }
433
+ // PRIORITY 4: Check for platform-specific directories in preferred order (fallback)
434
+ const priorityOrder = ['cursor', 'codex', 'codebuddy', 'claude', 'anthropic'];
435
+ for (const platform of priorityOrder) {
436
+ const dir = PLATFORM_SKILLS_DIRS[platform];
437
+ if (dir) {
438
+ const expandedDir = dir.replace('~', homeDir);
439
+ if (fs.existsSync(expandedDir)) {
440
+ return platform;
441
+ }
442
+ }
443
+ }
444
+ return 'unknown';
445
+ }
446
+ /**
447
+ * Get default skills directory for a platform
448
+ * Priority: 1. Project directory, 2. User home directory
449
+ * @param platform Platform name (optional, auto-detected if not provided)
450
+ * @returns Expanded absolute path to skills directory
451
+ */
452
+ function getDefaultSkillsDir(platform) {
453
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '~';
454
+ const detectedPlatform = platform || detectPlatform();
455
+ // PRIORITY 1: Check if we should use project directory
456
+ try {
457
+ const cwd = process.cwd();
458
+ // Check if current directory is not the home directory or system directory
459
+ const isProjectDir = cwd !== homeDir &&
460
+ !cwd.startsWith('/usr') &&
461
+ !cwd.startsWith('/opt') &&
462
+ !cwd.startsWith('/System') &&
463
+ !cwd.includes('/Library/Application Support');
464
+ if (isProjectDir) {
465
+ // Use project-local skills directory based on platform
466
+ const platformDir = `.${detectedPlatform}/skills`;
467
+ const projectSkillsDir = path.join(cwd, platformDir);
468
+ logger.info(`Using project directory for skills: ${projectSkillsDir}`);
469
+ return projectSkillsDir;
470
+ }
471
+ }
472
+ catch (e) {
473
+ logger.warn('Failed to detect project directory, falling back to user home', { error: e });
474
+ }
475
+ // PRIORITY 2: Use user home directory (original behavior)
476
+ const defaultDir = PLATFORM_SKILLS_DIRS[detectedPlatform];
477
+ if (defaultDir) {
478
+ return defaultDir.replace('~', homeDir);
479
+ }
480
+ // Fallback to generic location
481
+ return path.join(homeDir, '.skills');
482
+ }
483
+ // ============================================================================
484
+ // File Processing Constants
485
+ // ============================================================================
486
+ //
487
+ // These constants define which file types are supported for skill publishing
488
+ // and installation. Text files are read and stored as strings, while binary
489
+ // files are stored as base64-encoded data.
490
+ // ============================================================================
491
+ // File extensions and constants for publish/install
492
+ const TEXT_EXTENSIONS = new Set([
493
+ '.md', '.txt', '.py', '.js', '.ts', '.sh', '.bash', '.zsh',
494
+ '.json', '.yaml', '.yml', '.xml', '.xsd', '.html', '.css',
495
+ '.sql', '.r', '.rb', '.pl', '.lua', '.go', '.rs', '.java',
496
+ '.c', '.cpp', '.h', '.hpp', '.swift', '.kt', '.scala',
497
+ '.template', '.tpl', '.cfg', '.conf', '.ini', '.env',
498
+ '.csv', '.tsv', '.log',
499
+ ]);
500
+ const BINARY_EXTENSIONS = new Set([
501
+ '.ttf', '.otf', '.woff', '.woff2',
502
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp',
503
+ '.pdf', '.zip', '.tar', '.gz',
504
+ ]);
505
+ const SKIP_DIRS = new Set([
506
+ 'node_modules', '.git', '.svn', '__pycache__', '.DS_Store',
507
+ 'dist', 'build', '.cache', '.vscode', '.idea',
508
+ ]);
509
+ const SKIP_FILES = new Set([
510
+ '.DS_Store', 'Thumbs.db', '.gitignore', '.npmignore',
511
+ ]);
512
+ function isTextFile(filePath) {
513
+ const ext = path.extname(filePath).toLowerCase();
514
+ return TEXT_EXTENSIONS.has(ext);
515
+ }
516
+ function isBinaryFile(filePath) {
517
+ const ext = path.extname(filePath).toLowerCase();
518
+ return BINARY_EXTENSIONS.has(ext);
519
+ }
520
+ function shouldIncludeFile(filePath) {
521
+ const basename = path.basename(filePath);
522
+ if (SKIP_FILES.has(basename))
523
+ return false;
524
+ return isTextFile(filePath) || isBinaryFile(filePath);
525
+ }
526
+ function getMimeType(filePath) {
527
+ const ext = path.extname(filePath).toLowerCase();
528
+ const mimeTypes = {
529
+ '.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2',
530
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
531
+ '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon',
532
+ '.pdf': 'application/pdf', '.zip': 'application/zip',
533
+ };
534
+ return mimeTypes[ext] || 'application/octet-stream';
535
+ }
536
+ function parseSkillMdFile(content) {
537
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
538
+ if (frontmatterMatch && frontmatterMatch[1] && frontmatterMatch[2] !== undefined) {
539
+ return {
540
+ frontmatter: parseYaml(frontmatterMatch[1]),
541
+ markdown: frontmatterMatch[2].trim(),
542
+ };
543
+ }
544
+ return { frontmatter: {}, markdown: content.trim() };
545
+ }
546
+ function collectFiles(dirPath, basePath = dirPath) {
547
+ const result = { skillMd: null, files: [], totalSize: 0 };
548
+ if (!dirPath || !fs.existsSync(dirPath))
549
+ return result;
550
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
551
+ for (const entry of entries) {
552
+ const fullPath = path.join(dirPath, entry.name);
553
+ const relativePath = path.relative(basePath, fullPath);
554
+ if (entry.isDirectory()) {
555
+ if (SKIP_DIRS.has(entry.name))
556
+ continue;
557
+ const subResult = collectFiles(fullPath, basePath);
558
+ result.files.push(...subResult.files);
559
+ result.totalSize += subResult.totalSize;
560
+ if (subResult.skillMd && !result.skillMd) {
561
+ result.skillMd = subResult.skillMd;
562
+ }
563
+ }
564
+ else if (entry.isFile()) {
565
+ if (entry.name.toUpperCase() === 'SKILL.MD') {
566
+ const content = fs.readFileSync(fullPath, 'utf-8');
567
+ result.skillMd = { path: relativePath, content };
568
+ result.totalSize += content.length;
569
+ continue;
570
+ }
571
+ if (!shouldIncludeFile(entry.name))
572
+ continue;
573
+ const stat = fs.statSync(fullPath);
574
+ if (stat.size > 10 * 1024 * 1024)
575
+ continue; // Skip >10MB
576
+ let content;
577
+ if (isTextFile(fullPath)) {
578
+ content = fs.readFileSync(fullPath, 'utf-8');
579
+ }
580
+ else if (isBinaryFile(fullPath)) {
581
+ const buffer = fs.readFileSync(fullPath);
582
+ content = `data:${getMimeType(fullPath)};base64,${buffer.toString('base64')}`;
583
+ }
584
+ else {
585
+ continue;
586
+ }
587
+ result.files.push({ path: relativePath, content, size: stat.size });
588
+ result.totalSize += stat.size;
589
+ }
590
+ }
591
+ return result;
592
+ }
593
+ /**
594
+ * Handle skills_search tool call
595
+ *
596
+ * Searches the Open Skills Hub by keywords, category, or author.
597
+ * Returns a formatted list of matching skills with key metadata.
598
+ *
599
+ * @param storage - Storage interface for database operations
600
+ * @param args - Search parameters (query, category, author, limit)
601
+ * @returns MCP response with formatted skill list
602
+ */
603
+ async function handleSearch(storage, args) {
604
+ const result = await storage.searchSkills({
605
+ query: args['query'],
606
+ category: args['category'],
607
+ author: args['author'],
608
+ limit: args['limit'] ?? 10,
609
+ });
610
+ const text = result.items.length === 0
611
+ ? 'No skills found matching your query.'
612
+ : `Found ${result.items.length} skill(s):\n\n` + result.items.map((skill, index) => `${index + 1}. **Name:** ${skill.fullName}\n **Author:** ${skill.ownerId ?? 'unknown'}\n **Version:** ${skill.latestVersion}\n **Description:** ${skill.description.substring(0, 120)}${skill.description.length > 120 ? '...' : ''}\n **Security:** ${skill.securityLevel ?? 'unknown'} (${skill.securityScore ?? 'N/A'}) | **Uses:** ${skill.stats.totalUses}`).join('\n\n') +
613
+ `\n\n💡 To install a skill, use: skills_install with the "name" parameter (e.g., {"name": "${result.items[0]?.fullName}"})`;
614
+ return {
615
+ content: [{ type: 'text', text }],
616
+ };
617
+ }
618
+ /**
619
+ * Handle skills_get tool call
620
+ *
621
+ * Retrieves complete skill content including SKILL.md, attachments, metadata,
622
+ * and version information. Results are cached locally for offline access.
623
+ *
624
+ * @param storage - Storage interface for database operations
625
+ * @param args - Parameters (name: required, version: optional)
626
+ * @returns MCP response with complete skill content
627
+ * @throws McpError if skill name is missing or skill not found
628
+ */
629
+ async function handleGet(storage, args) {
630
+ const name = args['name'];
631
+ const requestedVersion = args['version'];
632
+ // Parse name
633
+ const { scope, name: skillName } = parseSkillFullName(name);
634
+ const fullName = buildSkillFullName(skillName, scope);
635
+ // Get skill
636
+ const skill = await storage.getSkillByName(fullName);
637
+ if (!skill) {
638
+ throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
639
+ }
640
+ // Get version
641
+ let version;
642
+ if (requestedVersion) {
643
+ version = await storage.getVersion(skill.id, requestedVersion);
644
+ if (!version) {
645
+ throw new McpError(ErrorCode.InvalidParams, `Version '${requestedVersion}' not found for '${fullName}'`);
646
+ }
647
+ }
648
+ else {
649
+ version = await storage.getLatestVersion(skill.id);
650
+ if (!version) {
651
+ throw new McpError(ErrorCode.InvalidParams, `No versions found for '${fullName}'`);
652
+ }
653
+ }
654
+ // Record use
655
+ await storage.incrementSkillUses(skill.id);
656
+ await storage.incrementVersionUses(version.id);
657
+ const useRecord = {
658
+ id: generateUUID(),
659
+ skillId: skill.id,
660
+ versionId: version.id,
661
+ source: 'mcp',
662
+ cacheHit: false,
663
+ ip: '127.0.0.1',
664
+ createdAt: now(),
665
+ };
666
+ await storage.createUseRecord(useRecord);
667
+ // Build response
668
+ const content = version.content;
669
+ const frontmatterLines = Object.entries(content.frontmatter)
670
+ .filter(([_, v]) => v !== undefined)
671
+ .map(([k, v]) => {
672
+ if (Array.isArray(v)) {
673
+ return `${k}:\n${v.map(item => ` - ${item}`).join('\n')}`;
674
+ }
675
+ return `${k}: ${v}`;
676
+ });
677
+ const text = `# ${skill.displayName ?? skill.fullName}
678
+
679
+ ## Metadata
680
+ - **Name:** ${skill.fullName} (use this in skills_install)
681
+ - **Author:** ${skill.ownerId ?? 'unknown'}
682
+ - **Version:** ${version.version}
683
+ - **Security Level:** ${skill.securityLevel ?? 'unknown'} (Score: ${skill.securityScore ?? 'N/A'})
684
+ - **Uses:** ${skill.stats.totalUses}
685
+ - **Rating:** ${skill.rating.average}/5 (${skill.rating.count} reviews)
686
+
687
+ ## Frontmatter
688
+ \`\`\`yaml
689
+ ${frontmatterLines.join('\n')}
690
+ \`\`\`
691
+
692
+ ## Content
693
+ ${content.markdown}`;
694
+ return {
695
+ content: [{ type: 'text', text }],
696
+ };
697
+ }
698
+ /**
699
+ * Handle skills_scan tool call
700
+ *
701
+ * Performs security scanning on skill content using SkillsGuard with 16 built-in
702
+ * security rules. Returns a score (0-100) and detailed issue report.
703
+ *
704
+ * @param args - Parameters (content: required text to scan)
705
+ * @returns MCP response with security scan results
706
+ */
707
+ async function handleScan(args) {
708
+ const content = args['content'];
709
+ const scanner = getScanner();
710
+ const result = await scanner.scan(content);
711
+ const issuesText = result.issues.length === 0
712
+ ? 'No security issues found.'
713
+ : result.issues.map(issue => `[${issue.severity.toUpperCase()}] ${issue.ruleName} (${issue.ruleId})\n Line ${issue.line}: ${issue.content}\n ${issue.message}\n Suggestion: ${issue.suggestion ?? 'N/A'}`).join('\n\n');
714
+ const text = `## Security Scan Results
715
+
716
+ **Score:** ${result.summary.score}/100
717
+ **Level:** ${result.summary.level.toUpperCase()}
718
+
719
+ ### Issue Summary
720
+ - High: ${result.summary.issueCount.high}
721
+ - Medium: ${result.summary.issueCount.medium}
722
+ - Low: ${result.summary.issueCount.low}
723
+
724
+ ### Issues
725
+ ${issuesText}
726
+
727
+ ### Recommendations
728
+ ${result.summary.recommendations.map(r => `• ${r}`).join('\n')}`;
729
+ return {
730
+ content: [{ type: 'text', text }],
731
+ };
732
+ }
733
+ /**
734
+ * Handle skills_feedback tool call
735
+ *
736
+ * Submits feedback for a skill (success, failure, suggestion, or bug report).
737
+ * Feedback is queued locally and processed asynchronously with auto-retry.
738
+ *
739
+ * @param storage - Storage interface for database operations
740
+ * @param queueProcessor - Queue processor for async feedback handling
741
+ * @param args - Parameters (name, type, rating: required; version, comment: optional)
742
+ * @returns MCP response confirming feedback submission
743
+ * @throws McpError if required parameters are missing or invalid
744
+ */
745
+ async function handleFeedback(storage, queueProcessor, args) {
746
+ const name = args['name'];
747
+ const feedbackType = args['type'];
748
+ const rating = args['rating'];
749
+ const comment = args['comment'];
750
+ const requestedVersion = args['version'];
751
+ // Parse name
752
+ const { scope, name: skillName } = parseSkillFullName(name);
753
+ const fullName = buildSkillFullName(skillName, scope);
754
+ // Get skill
755
+ const skill = await storage.getSkillByName(fullName);
756
+ if (!skill) {
757
+ throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
758
+ }
759
+ // Get version
760
+ let version;
761
+ if (requestedVersion) {
762
+ version = await storage.getVersion(skill.id, requestedVersion);
763
+ }
764
+ else {
765
+ version = await storage.getLatestVersion(skill.id);
766
+ }
767
+ if (!version) {
768
+ throw new McpError(ErrorCode.InvalidParams, 'Skill version not found');
769
+ }
770
+ // Create feedback
771
+ const feedback = {
772
+ id: generateUUID(),
773
+ skillId: skill.id,
774
+ skillVersion: version.version,
775
+ feedbackType,
776
+ rating,
777
+ comment,
778
+ context: {},
779
+ status: 'pending',
780
+ createdAt: now(),
781
+ };
782
+ try {
783
+ await storage.createFeedback(feedback);
784
+ // Update skill rating
785
+ const feedbacks = await storage.getFeedbacks(skill.id, { limit: 1000 });
786
+ const ratings = feedbacks.items.map(f => f.rating).filter((r) => r !== undefined);
787
+ const averageRating = ratings.length > 0
788
+ ? ratings.reduce((sum, r) => sum + r, 0) / ratings.length
789
+ : 0;
790
+ await storage.updateSkill(skill.id, {
791
+ rating: {
792
+ average: Math.round(averageRating * 10) / 10,
793
+ count: ratings.length,
794
+ },
795
+ });
796
+ }
797
+ catch (error) {
798
+ // Queue for retry if failed
799
+ const queueItem = {
800
+ id: generateUUID(),
801
+ skillName: fullName,
802
+ skillVersion: version.version,
803
+ feedback: {
804
+ type: feedbackType,
805
+ rating,
806
+ comment,
807
+ context: {},
808
+ },
809
+ status: 'pending',
810
+ attempts: 0,
811
+ maxAttempts: 5,
812
+ baseDelay: 1000,
813
+ currentDelay: 1000,
814
+ maxDelay: 3600000,
815
+ createdAt: now(),
816
+ };
817
+ await queueProcessor.enqueue(queueItem);
818
+ return {
819
+ content: [{
820
+ type: 'text',
821
+ text: `Feedback queued for ${fullName}@${version.version}. Will be submitted when connection is restored.`,
822
+ }],
823
+ };
824
+ }
825
+ return {
826
+ content: [{
827
+ type: 'text',
828
+ text: `## ✅ Feedback Submitted Successfully!
829
+
830
+ **Name:** ${fullName}
831
+ **Version:** ${version.version}
832
+ **Type:** ${feedbackType}
833
+ **Rating:** ${'⭐'.repeat(rating)} (${rating}/5)${comment ? `\n**Comment:** ${comment}` : ''}
834
+
835
+ Thank you for helping improve this skill!`,
836
+ }],
837
+ };
838
+ }
839
+ /**
840
+ * Handle skills_list_cached tool call
841
+ *
842
+ * Lists all skills currently stored in the local cache with metadata
843
+ * including cache time, usage count, and size.
844
+ *
845
+ * @param storage - Storage interface for database operations
846
+ * @returns MCP response with formatted list of cached skills
847
+ */
848
+ async function handleListCached(storage) {
849
+ const cacheEntries = await storage.getAllCacheMetadata();
850
+ if (cacheEntries.length === 0) {
851
+ return {
852
+ content: [{ type: 'text', text: 'No skills currently cached locally.' }],
853
+ };
854
+ }
855
+ const text = `## Cached Skills (${cacheEntries.length})
856
+
857
+ ${cacheEntries.map((entry, index) => `${index + 1}. **Name:** ${entry.skillName}\n **Version:** ${entry.version}\n **Cached:** ${entry.cachedAt.split('T')[0]}\n **Hits:** ${entry.hitCount}`).join('\n\n')}
858
+
859
+ 💡 Use skills_install with the "name" parameter to reinstall or update a cached skill.`;
860
+ return {
861
+ content: [{ type: 'text', text }],
862
+ };
863
+ }
864
+ /**
865
+ * Handle skills_publish tool call
866
+ *
867
+ * Publishes a skill to the hub by spawning the CLI tool as a subprocess.
868
+ * This approach is more stable than the direct implementation. The skill directory
869
+ * must contain a SKILL.md file. Automatically performs security scanning.
870
+ *
871
+ * @param storage - Storage interface for database operations
872
+ * @param config - Configuration instance for API URL
873
+ * @param args - Parameters (path: required; scope, version: optional)
874
+ * @returns MCP response with publish confirmation
875
+ * @throws McpError if path is missing, invalid, or publish fails
876
+ */
877
+ async function handlePublish(storage, config, args) {
878
+ const inputPath = args['path'];
879
+ const scopeArg = args['scope'];
880
+ const authorArg = args['author'];
881
+ const versionArg = args['version'];
882
+ if (!inputPath) {
883
+ throw new McpError(ErrorCode.InvalidParams, 'Path is required');
884
+ }
885
+ const resolvedPath = path.resolve(inputPath);
886
+ if (!fs.existsSync(resolvedPath)) {
887
+ throw new McpError(ErrorCode.InvalidParams, `Path not found: ${resolvedPath}`);
888
+ }
889
+ const stat = fs.statSync(resolvedPath);
890
+ if (!stat.isDirectory()) {
891
+ throw new McpError(ErrorCode.InvalidParams, 'Path must be a directory containing SKILL.md');
892
+ }
893
+ // Check if SKILL.md exists
894
+ const skillMdPath = path.join(resolvedPath, 'SKILL.md');
895
+ if (!fs.existsSync(skillMdPath)) {
896
+ throw new McpError(ErrorCode.InvalidParams, `SKILL.md not found in ${resolvedPath}`);
897
+ }
898
+ // Check if author is provided (either as arg or in frontmatter)
899
+ if (!authorArg) {
900
+ const skillMdContent = fs.readFileSync(skillMdPath, 'utf-8');
901
+ const parsed = parseSkillMdFile(skillMdContent);
902
+ const frontmatterAuthor = parsed.frontmatter['author']
903
+ ?? parsed.frontmatter['metadata']?.['author'];
904
+ if (!frontmatterAuthor) {
905
+ throw new McpError(ErrorCode.InvalidParams, 'Author is required! Provide via --author argument or add "author: Your Name" to SKILL.md frontmatter.');
906
+ }
907
+ }
908
+ // Build CLI command options
909
+ const commands = buildPublishCliCommands(resolvedPath, scopeArg, authorArg, versionArg);
910
+ // Return instructions for the agent
911
+ const text = `## 📦 如何发布 Skill
912
+
913
+ 请使用 Shell 工具执行以下 CLI 命令来发布 skill(按优先级排序):
914
+
915
+ ### 方式 1:使用全局安装的 CLI(推荐)
916
+ 如果已通过 \`npm link\` 或 \`npm install -g\` 安装:
917
+
918
+ \`\`\`bash
919
+ ${commands.global}
920
+ \`\`\`
921
+
922
+ ### 方式 2:使用 npx(无需安装,发布到 npm 后可用)
923
+ \`\`\`bash
924
+ ${commands.npx}
925
+ \`\`\`
926
+
927
+ ### 方式 3:使用本地项目路径(开发模式)
928
+ 如果有 SkillsHub 项目:
929
+
930
+ \`\`\`bash
931
+ ${commands.local}
932
+ \`\`\`
933
+
934
+ ---
935
+
936
+ ### 命令说明:
937
+ - **路径**: ${resolvedPath}
938
+ ${scopeArg ? `- **作用域**: ${scopeArg}` : '- **作用域**: 未指定(将使用默认)'}
939
+ ${authorArg ? `- **作者**: ${authorArg}` : '- **作者**: 将从 SKILL.md frontmatter 读取'}
940
+ ${versionArg ? `- **版本**: ${versionArg}` : '- **版本**: 未指定(将自动递增)'}
941
+
942
+ ### 执行步骤:
943
+ 1. 尝试方式 1,如果失败则尝试方式 2 或 3
944
+ 2. 等待命令完成(通常需要 1-3 秒)
945
+ 3. 检查输出确认发布成功
946
+ 4. 如需在 Web UI 查看,执行: \`curl -X POST http://localhost:3001/reload\`
947
+
948
+ ### 注意事项:
949
+ - 确保目录包含有效的 SKILL.md 文件
950
+ - 如果版本已存在,命令会失败并提示
951
+ - 发布成功后会自动进行安全扫描(评分 0-100)`;
952
+ return {
953
+ content: [{ type: 'text', text }],
954
+ };
955
+ }
956
+ /**
957
+ * Build CLI commands for publishing a skill (multiple options)
958
+ */
959
+ function buildPublishCliCommands(skillPath, scope, author, version) {
960
+ // Build argument list
961
+ const args = ['publish', `"${skillPath}"`];
962
+ if (scope) {
963
+ args.push('-s', scope);
964
+ }
965
+ if (author) {
966
+ args.push('-a', `"${author}"`);
967
+ }
968
+ if (version) {
969
+ args.push('-v', version);
970
+ }
971
+ args.push('-y'); // Skip confirmation
972
+ const argsStr = args.join(' ');
973
+ // Calculate local path for development
974
+ const mcpServerDir = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
975
+ const projectRoot = path.resolve(mcpServerDir, '../../..'); // Go up to project root
976
+ const cliPath = path.join(projectRoot, 'packages/cli/dist/index.js');
977
+ return {
978
+ global: `skills ${argsStr}`,
979
+ npx: `npx open-skills-hub ${argsStr}`,
980
+ local: `node "${cliPath}" ${argsStr}`,
981
+ };
982
+ }
983
+ /**
984
+ * Publish skill via CLI tool to avoid database concurrency issues
985
+ */
986
+ async function handlePublishViaCli(skillPath, scope, version, config) {
987
+ const { spawn } = await import('child_process');
988
+ // Build CLI command - use absolute path from environment or relative to this file
989
+ // The MCP server is in packages/mcp-server/dist/index.js
990
+ // The CLI is in packages/cli/dist/index.js
991
+ const mcpServerDir = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
992
+ const projectRoot = path.resolve(mcpServerDir, '../../..'); // Go up to project root
993
+ const cliPath = path.join(projectRoot, 'packages/cli/dist/index.js');
994
+ // Verify CLI exists
995
+ if (!fs.existsSync(cliPath)) {
996
+ logger.error('CLI tool not found', { cliPath, projectRoot, mcpServerDir });
997
+ throw new McpError(ErrorCode.InternalError, `CLI tool not found at ${cliPath}. Please ensure the CLI is built.`);
998
+ }
999
+ logger.info('Using CLI tool for publish', { cliPath, skillPath, scope, version });
1000
+ const args = ['publish', skillPath];
1001
+ if (scope) {
1002
+ args.push('-s', scope);
1003
+ }
1004
+ if (version) {
1005
+ args.push('-v', version);
1006
+ }
1007
+ args.push('-y'); // Auto-confirm
1008
+ return new Promise((resolve, reject) => {
1009
+ const child = spawn('node', [cliPath, ...args], {
1010
+ env: {
1011
+ ...process.env,
1012
+ STORAGE_PATH: config.get().storagePath,
1013
+ STORAGE_MODE: config.get().mode,
1014
+ SKILLS_REGISTRY_URL: config.get().apiUrl || 'http://localhost:3001',
1015
+ },
1016
+ cwd: projectRoot, // Use project root as working directory
1017
+ });
1018
+ let stdout = '';
1019
+ let stderr = '';
1020
+ if (child.stdout) {
1021
+ child.stdout.on('data', (data) => {
1022
+ stdout += data.toString();
1023
+ });
1024
+ }
1025
+ if (child.stderr) {
1026
+ child.stderr.on('data', (data) => {
1027
+ stderr += data.toString();
1028
+ });
1029
+ }
1030
+ child.on('close', async (code) => {
1031
+ if (code === 0) {
1032
+ // Extract skill name and version from stdout
1033
+ const nameMatch = stdout.match(/Name:\s+(@?[\w-]+\/[\w-]+)/);
1034
+ const versionMatch = stdout.match(/Version:\s+([\d.]+)/);
1035
+ const actionMatch = stdout.match(/Action:\s+(.+)/);
1036
+ const skillName = nameMatch?.[1] || 'unknown';
1037
+ const skillVersion = versionMatch?.[1] || 'unknown';
1038
+ const action = actionMatch?.[1]?.trim() || 'Published';
1039
+ // Trigger API reload
1040
+ const apiUrl = config.get().apiUrl || 'http://localhost:3001';
1041
+ await reloadApi(apiUrl);
1042
+ const resultText = `## ✅ Published Successfully via MCP!
1043
+
1044
+ **Skill:** ${skillName}
1045
+ **Version:** ${skillVersion}
1046
+ **Action:** ${action}
1047
+
1048
+ The skill has been published and the API has been reloaded.
1049
+ You can now use it with \`skills_get\` or view it at http://localhost:8080
1050
+
1051
+ ---
1052
+ Use \`skills_get ${skillName}\` to retrieve this skill.`;
1053
+ resolve({
1054
+ content: [{ type: 'text', text: resultText }],
1055
+ });
1056
+ }
1057
+ else {
1058
+ // Extract error message from stderr or stdout
1059
+ const errorMsg = stderr || stdout || `CLI exited with code ${code}`;
1060
+ logger.error('CLI publish failed', { code, stderr, stdout });
1061
+ reject(new McpError(ErrorCode.InternalError, `Failed to publish skill: ${errorMsg}`));
1062
+ }
1063
+ });
1064
+ child.on('error', (error) => {
1065
+ logger.error('Failed to spawn CLI process', { error });
1066
+ reject(new McpError(ErrorCode.InternalError, `Failed to spawn CLI process: ${error.message}`));
1067
+ });
1068
+ });
1069
+ }
1070
+ async function handlePublishOld(storage, config, args) {
1071
+ const inputPath = args['path'];
1072
+ const scopeArg = args['scope'];
1073
+ const versionArg = args['version'];
1074
+ if (!inputPath) {
1075
+ throw new McpError(ErrorCode.InvalidParams, 'Path is required');
1076
+ }
1077
+ const resolvedPath = path.resolve(inputPath);
1078
+ if (!fs.existsSync(resolvedPath)) {
1079
+ throw new McpError(ErrorCode.InvalidParams, `Path not found: ${resolvedPath}`);
1080
+ }
1081
+ const stat = fs.statSync(resolvedPath);
1082
+ if (!stat.isDirectory()) {
1083
+ throw new McpError(ErrorCode.InvalidParams, 'Path must be a directory containing SKILL.md');
1084
+ }
1085
+ // Collect files
1086
+ const collected = collectFiles(resolvedPath);
1087
+ if (!collected.skillMd) {
1088
+ throw new McpError(ErrorCode.InvalidParams, 'SKILL.md not found in directory');
1089
+ }
1090
+ // Parse SKILL.md
1091
+ const parsed = parseSkillMdFile(collected.skillMd.content);
1092
+ const skillDirName = path.basename(resolvedPath);
1093
+ const skillName = parsed.frontmatter['name'] ?? skillDirName;
1094
+ if (!isValidSkillName(skillName)) {
1095
+ throw new McpError(ErrorCode.InvalidParams, `Invalid skill name: ${skillName}. Use lowercase letters, numbers, and hyphens.`);
1096
+ }
1097
+ const scope = scopeArg?.replace(/^@/, '');
1098
+ const fullName = buildSkillFullName(skillName, scope);
1099
+ // Check existing skill
1100
+ const existingSkill = await storage.getSkillByName(fullName);
1101
+ const isUpdate = !!existingSkill;
1102
+ // Determine version
1103
+ let version = versionArg ?? parsed.frontmatter['version'];
1104
+ if (!version) {
1105
+ if (isUpdate) {
1106
+ const [major, minor, patch] = existingSkill.latestVersion.split('.').map(Number);
1107
+ version = `${major}.${minor}.${(patch ?? 0) + 1}`;
1108
+ }
1109
+ else {
1110
+ version = '1.0.0';
1111
+ }
1112
+ }
1113
+ if (!isValidSemver(version)) {
1114
+ throw new McpError(ErrorCode.InvalidParams, `Invalid version: ${version}. Use semantic versioning (e.g., 1.0.0).`);
1115
+ }
1116
+ // Check version doesn't exist
1117
+ if (isUpdate) {
1118
+ const existingVersion = await storage.getVersion(existingSkill.id, version);
1119
+ if (existingVersion) {
1120
+ logger.warn(`Version ${version} already exists for ${fullName}`);
1121
+ throw new McpError(ErrorCode.InvalidParams, `Version ${version} already exists for ${fullName}.`);
1122
+ }
1123
+ logger.info(`Creating new version ${version} for existing skill ${fullName}`);
1124
+ }
1125
+ else {
1126
+ logger.info(`Creating new skill ${fullName} with version ${version}`);
1127
+ }
1128
+ // Security scan
1129
+ const scanner = getScanner();
1130
+ const scanResult = await scanner.scan(collected.skillMd.content);
1131
+ for (const file of collected.files) {
1132
+ if (file.path.startsWith('scripts/') && (file.path.endsWith('.py') || file.path.endsWith('.sh') || file.path.endsWith('.js'))) {
1133
+ const fileScanResult = await scanner.scan(file.content);
1134
+ if (fileScanResult && fileScanResult.issues) {
1135
+ scanResult.issues.push(...fileScanResult.issues);
1136
+ }
1137
+ if (fileScanResult && fileScanResult.summary) {
1138
+ scanResult.summary.score = Math.min(scanResult.summary.score, fileScanResult.summary.score);
1139
+ }
1140
+ }
1141
+ }
1142
+ // Build skill content
1143
+ const skillContent = {
1144
+ frontmatter: {
1145
+ name: skillName,
1146
+ description: parsed.frontmatter['description'] ?? '',
1147
+ allowedTools: parsed.frontmatter['allowedTools'],
1148
+ argumentHint: parsed.frontmatter['argumentHint'],
1149
+ disableModelInvocation: parsed.frontmatter['disableModelInvocation'],
1150
+ userInvocable: parsed.frontmatter['userInvocable'],
1151
+ model: parsed.frontmatter['model'],
1152
+ license: parsed.frontmatter['license'],
1153
+ compatibility: parsed.frontmatter['compatibility'],
1154
+ metadata: parsed.frontmatter['metadata'],
1155
+ },
1156
+ markdown: parsed.markdown,
1157
+ files: collected.files.length > 0 ? collected.files : undefined,
1158
+ };
1159
+ const contentString = JSON.stringify(skillContent);
1160
+ const contentHash = sha256(contentString);
1161
+ const timestamp = now();
1162
+ const versionId = generateUUID();
1163
+ if (isUpdate) {
1164
+ // Create new version
1165
+ const versionRecord = {
1166
+ id: versionId,
1167
+ skillId: existingSkill.id,
1168
+ version,
1169
+ tag: 'latest',
1170
+ content: skillContent,
1171
+ packageUrl: `local://${fullName}/${version}`,
1172
+ packageSize: contentString.length,
1173
+ packageHash: contentHash,
1174
+ status: 'published',
1175
+ uses: 0,
1176
+ createdAt: timestamp,
1177
+ publishedAt: timestamp,
1178
+ };
1179
+ await storage.createVersion(versionRecord);
1180
+ logger.info(`Version ${version} created for skill ${fullName}`);
1181
+ // Update previous latest tag
1182
+ const versions = await storage.getVersions(existingSkill.id, { limit: 100 });
1183
+ for (const v of versions.items) {
1184
+ if (v.id !== versionId && v.tag === 'latest') {
1185
+ await storage.updateVersion(v.id, { tag: undefined });
1186
+ }
1187
+ }
1188
+ // Update skill
1189
+ await storage.updateSkill(existingSkill.id, {
1190
+ latestVersion: version,
1191
+ latestVersionId: versionId,
1192
+ securityScore: scanResult.summary.score,
1193
+ securityLevel: scanResult.summary.level,
1194
+ stats: {
1195
+ ...existingSkill.stats,
1196
+ versionCount: existingSkill.stats.versionCount + 1,
1197
+ },
1198
+ });
1199
+ logger.info(`Skill ${fullName} updated to version ${version}`);
1200
+ }
1201
+ else {
1202
+ // Create new skill
1203
+ const skillId = generateUUID();
1204
+ const skillRecord = {
1205
+ id: skillId,
1206
+ name: skillName,
1207
+ scope,
1208
+ fullName,
1209
+ displayName: parsed.frontmatter['displayName'],
1210
+ description: parsed.frontmatter['description'] ?? '',
1211
+ keywords: [],
1212
+ visibility: 'public',
1213
+ status: 'active',
1214
+ latestVersion: version,
1215
+ latestVersionId: versionId,
1216
+ ownerType: 'user',
1217
+ securityScore: scanResult.summary.score,
1218
+ securityLevel: scanResult.summary.level,
1219
+ stats: { totalUses: 0, weeklyUses: 0, monthlyUses: 0, versionCount: 1, derivationCount: 0 },
1220
+ rating: { average: 0, count: 0 },
1221
+ createdAt: timestamp,
1222
+ updatedAt: timestamp,
1223
+ publishedAt: timestamp,
1224
+ };
1225
+ await storage.createSkill(skillRecord);
1226
+ // Create version
1227
+ const versionRecord = {
1228
+ id: versionId,
1229
+ skillId,
1230
+ version,
1231
+ tag: 'latest',
1232
+ content: skillContent,
1233
+ packageUrl: `local://${fullName}/${version}`,
1234
+ packageSize: contentString.length,
1235
+ packageHash: contentHash,
1236
+ status: 'published',
1237
+ uses: 0,
1238
+ createdAt: timestamp,
1239
+ publishedAt: timestamp,
1240
+ };
1241
+ await storage.createVersion(versionRecord);
1242
+ }
1243
+ // Create audit log
1244
+ const auditLog = {
1245
+ id: generateUUID(),
1246
+ timestamp,
1247
+ eventType: isUpdate ? 'version.published' : 'skill.published',
1248
+ actor: { type: 'user', id: 'mcp-user' },
1249
+ resource: { type: 'skill', name: fullName, version },
1250
+ action: 'publish',
1251
+ result: 'success',
1252
+ details: { filesCount: collected.files.length + 1, totalSize: collected.totalSize },
1253
+ };
1254
+ await storage.createAuditLog(auditLog);
1255
+ logger.info('Audit log created, syncing database...');
1256
+ // Force sync to disk (SQLite is in-memory, need to persist)
1257
+ await storage.sync();
1258
+ logger.info('Database synced to disk');
1259
+ // Reload API to pick up the changes
1260
+ const apiUrl = config.get().apiUrl;
1261
+ if (apiUrl && apiUrl !== 'https://api.open-skills-hub.io') {
1262
+ await reloadApi(apiUrl);
1263
+ }
1264
+ else {
1265
+ // Try local API at default port
1266
+ await reloadApi('http://localhost:3001');
1267
+ }
1268
+ const filesList = collected.files && collected.files.length > 0
1269
+ ? collected.files.map(f => `- ${f.path}`).join('\n')
1270
+ : 'No additional files';
1271
+ const resultText = `## ✅ Published Successfully!
1272
+
1273
+ **Skill:** ${fullName}
1274
+ **Version:** ${version}
1275
+ **Action:** ${isUpdate ? 'New version added' : 'New skill created'}
1276
+
1277
+ ### Files
1278
+ - SKILL.md
1279
+ ${filesList}
1280
+
1281
+ ### Security Scan
1282
+ - Score: ${scanResult.summary.score}/100
1283
+ - Level: ${scanResult.summary.level.toUpperCase()}
1284
+ ${scanResult.issues.length > 0 ? `- Issues: ${scanResult.issues.length}` : ''}
1285
+
1286
+ ---
1287
+ Use \`skills_get\` to retrieve this skill, or \`skills_install\` to download it to a project.`;
1288
+ return {
1289
+ content: [{ type: 'text', text: resultText }],
1290
+ };
1291
+ }
1292
+ /**
1293
+ * Handle skills_install tool call
1294
+ *
1295
+ * Downloads a skill from the hub and installs it to a local directory.
1296
+ * Creates the skill folder with SKILL.md and all attachments (scripts, configs, etc.).
1297
+ * The skill name must match exactly as returned from skills_search.
1298
+ *
1299
+ * Supports automatic platform detection and default directory resolution:
1300
+ * - If targetDir is not provided, uses platform-specific default
1301
+ * - If platform is specified, uses that platform's default directory
1302
+ * - Automatically creates directories if they don't exist
1303
+ *
1304
+ * @param storage - Storage interface for database operations
1305
+ * @param args - Parameters (name: required; targetDir, platform, version: optional)
1306
+ * @returns MCP response with installation confirmation
1307
+ * @throws McpError if required parameters are missing, skill not found, or install fails
1308
+ */
1309
+ async function handleInstall(storage, args) {
1310
+ const name = args['name'];
1311
+ let targetDir = args['targetDir'];
1312
+ const platform = args['platform'];
1313
+ const requestedVersion = args['version'];
1314
+ if (!name) {
1315
+ throw new McpError(ErrorCode.InvalidParams, 'Skill name is required');
1316
+ }
1317
+ // Auto-detect target directory if not provided
1318
+ let autoDetected = false;
1319
+ let detectedPlatformName = '';
1320
+ if (!targetDir) {
1321
+ detectedPlatformName = platform || detectPlatform();
1322
+ targetDir = getDefaultSkillsDir(platform);
1323
+ autoDetected = true;
1324
+ logger.info(`Auto-detected target directory: ${targetDir}`, { platform: detectedPlatformName });
1325
+ }
1326
+ // Parse name
1327
+ const { scope, name: skillName } = parseSkillFullName(name);
1328
+ const fullName = buildSkillFullName(skillName, scope);
1329
+ // Get skill
1330
+ const skill = await storage.getSkillByName(fullName);
1331
+ if (!skill) {
1332
+ throw new McpError(ErrorCode.InvalidParams, `Skill '${fullName}' not found`);
1333
+ }
1334
+ // Get version
1335
+ let version;
1336
+ if (requestedVersion) {
1337
+ version = await storage.getVersion(skill.id, requestedVersion);
1338
+ if (!version) {
1339
+ throw new McpError(ErrorCode.InvalidParams, `Version '${requestedVersion}' not found for '${fullName}'`);
1340
+ }
1341
+ }
1342
+ else {
1343
+ version = await storage.getLatestVersion(skill.id);
1344
+ if (!version) {
1345
+ throw new McpError(ErrorCode.InvalidParams, `No versions found for '${fullName}'`);
1346
+ }
1347
+ }
1348
+ // Resolve target directory
1349
+ const resolvedTargetDir = path.resolve(targetDir);
1350
+ const skillDir = path.join(resolvedTargetDir, skillName);
1351
+ // Create directories
1352
+ if (!fs.existsSync(resolvedTargetDir)) {
1353
+ fs.mkdirSync(resolvedTargetDir, { recursive: true });
1354
+ }
1355
+ if (!fs.existsSync(skillDir)) {
1356
+ fs.mkdirSync(skillDir, { recursive: true });
1357
+ }
1358
+ const content = version.content;
1359
+ const installedFiles = [];
1360
+ // Write SKILL.md
1361
+ const frontmatterLines = Object.entries(content.frontmatter)
1362
+ .filter(([_, v]) => v !== undefined && v !== null)
1363
+ .map(([k, v]) => {
1364
+ if (Array.isArray(v)) {
1365
+ return `${k}:\n${v.map(item => ` - ${item}`).join('\n')}`;
1366
+ }
1367
+ if (typeof v === 'object') {
1368
+ return `${k}:\n${Object.entries(v).map(([k2, v2]) => ` ${k2}: ${v2}`).join('\n')}`;
1369
+ }
1370
+ return `${k}: ${v}`;
1371
+ });
1372
+ const skillMdContent = `---
1373
+ ${frontmatterLines.join('\n')}
1374
+ ---
1375
+
1376
+ ${content.markdown}`;
1377
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
1378
+ fs.writeFileSync(skillMdPath, skillMdContent, 'utf-8');
1379
+ installedFiles.push('SKILL.md');
1380
+ // Write additional files
1381
+ if (content.files && content.files.length > 0) {
1382
+ for (const file of content.files) {
1383
+ const filePath = path.join(skillDir, file.path);
1384
+ const fileDir = path.dirname(filePath);
1385
+ // Create subdirectories if needed
1386
+ if (!fs.existsSync(fileDir)) {
1387
+ fs.mkdirSync(fileDir, { recursive: true });
1388
+ }
1389
+ // Check if it's a base64 encoded binary file
1390
+ if (file.content.startsWith('data:') && file.content.includes(';base64,')) {
1391
+ const base64Data = file.content.split(';base64,')[1] ?? '';
1392
+ const buffer = Buffer.from(base64Data, 'base64');
1393
+ fs.writeFileSync(filePath, buffer);
1394
+ }
1395
+ else {
1396
+ fs.writeFileSync(filePath, file.content, 'utf-8');
1397
+ }
1398
+ installedFiles.push(file.path);
1399
+ }
1400
+ }
1401
+ // Record use
1402
+ await storage.incrementSkillUses(skill.id);
1403
+ await storage.incrementVersionUses(version.id);
1404
+ const useRecord = {
1405
+ id: generateUUID(),
1406
+ skillId: skill.id,
1407
+ versionId: version.id,
1408
+ source: 'mcp',
1409
+ cacheHit: false,
1410
+ ip: '127.0.0.1',
1411
+ createdAt: now(),
1412
+ };
1413
+ await storage.createUseRecord(useRecord);
1414
+ const installedFilesList = installedFiles.length > 0
1415
+ ? installedFiles.map(f => `• ${f}`).join('\n')
1416
+ : 'No files installed';
1417
+ const platformInfo = autoDetected
1418
+ ? `\n**Platform:** ${detectedPlatformName} (auto-detected)\n**Target Directory:** ${resolvedTargetDir} (auto-created)`
1419
+ : `\n**Target Directory:** ${resolvedTargetDir}`;
1420
+ const resultText = `## ✅ Skill Installed Successfully!
1421
+
1422
+ **Name:** ${fullName}
1423
+ **Version:** ${version.version}${platformInfo}
1424
+ **Installation Path:** \`${skillDir}\`
1425
+
1426
+ ### Files Installed (${installedFiles.length})
1427
+ ${installedFilesList}
1428
+
1429
+ ---
1430
+ ${autoDetected ? '✨ Directory was automatically created for your platform.\n' : ''}The skill is ready to use at: \`${skillDir}\``;
1431
+ return {
1432
+ content: [{ type: 'text', text: resultText }],
1433
+ };
1434
+ }
1435
+ // ============================================================================
1436
+ // Command Line Arguments Parsing
1437
+ // ============================================================================
1438
+ /**
1439
+ * Parse command line arguments
1440
+ * Supports: --platform=<platform_name>
1441
+ */
1442
+ function parseCliArgs() {
1443
+ const args = process.argv.slice(2);
1444
+ const result = {};
1445
+ for (const arg of args) {
1446
+ if (arg.startsWith('--platform=')) {
1447
+ result.platform = arg.split('=')[1];
1448
+ }
1449
+ else if (arg === '--platform' && args[args.indexOf(arg) + 1]) {
1450
+ result.platform = args[args.indexOf(arg) + 1];
1451
+ }
1452
+ }
1453
+ return result;
1454
+ }
1455
+ // Global variable to store CLI-specified platform
1456
+ let CLI_PLATFORM;
1457
+ // ============================================================================
1458
+ // Main Entry Point
1459
+ // ============================================================================
1460
+ async function main() {
1461
+ // Parse command line arguments
1462
+ const cliArgs = parseCliArgs();
1463
+ CLI_PLATFORM = cliArgs.platform;
1464
+ if (CLI_PLATFORM) {
1465
+ logger.info(`Starting Open Skills Hub MCP Server with platform override: ${CLI_PLATFORM}`);
1466
+ }
1467
+ else {
1468
+ logger.info('Starting Open Skills Hub MCP Server');
1469
+ }
1470
+ const server = await createMcpServer();
1471
+ const transport = new StdioServerTransport();
1472
+ await server.connect(transport);
1473
+ logger.info('Open Skills Hub MCP Server started');
1474
+ // Handle shutdown
1475
+ process.on('SIGINT', async () => {
1476
+ logger.info('Shutting down MCP server...');
1477
+ await server.close();
1478
+ process.exit(0);
1479
+ });
1480
+ process.on('SIGTERM', async () => {
1481
+ logger.info('Shutting down MCP server...');
1482
+ await server.close();
1483
+ process.exit(0);
1484
+ });
1485
+ }
1486
+ main().catch((error) => {
1487
+ console.error('Fatal error:', error);
1488
+ process.exit(1);
1489
+ });
1490
+ //# sourceMappingURL=index.js.map