Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
skills.ts17.9 kB
/** * Skills Management Internal MCP * * Provides tools for managing Anthropic Agent Skills: * - find: Search and discover installed skills with progressive disclosure (depth 1-3) * - add: Install skill from marketplace * - list: List installed skills * - remove: Remove installed skill * - read_resource: Read additional files from skill (resources/, scripts/, etc.) */ import { InternalMCP, InternalTool, InternalToolResult } from './types.js'; import { SkillsMarketplaceClient } from '../services/skills-marketplace-client.js'; import { SkillsManager } from '../services/skills-manager.js'; import { PersistentRAGEngine } from '../discovery/rag-engine.js'; import { logger } from '../utils/logger.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; export class SkillsManagementMCP implements InternalMCP { name = 'skills'; description = 'Anthropic Agent Skills management and discovery (built-in)'; private marketplaceClient: SkillsMarketplaceClient | null = null; private skillsManager: SkillsManager | null = null; private ragEngine: PersistentRAGEngine | null = null; private skillEmbeddings: Map<string, Float32Array> = new Map(); tools: InternalTool[] = [ { name: 'find', description: 'Search and discover installed Anthropic Agent Skills with progressive detail levels. Use depth parameter for progressive disclosure: 1=metadata only, 2=+SKILL.md content (AI learns skill), 3=+file listings.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Optional search query to filter skills. If omitted, returns all skills.' }, depth: { type: 'number', enum: [1, 2, 3], default: 1, description: 'Progressive disclosure level:\n- 1: Metadata only (name + description)\n- 2: + Full SKILL.md content (AI learns the skill)\n- 3: + File tree listing (scripts/, resources/, templates/)' }, page: { type: 'number', default: 1, description: 'Page number for pagination (starts at 1)' }, limit: { type: 'number', default: 10, description: 'Number of results per page' } } } }, { name: 'read_resource', description: 'Read additional files from an installed skill (resources/, scripts/, templates/, etc.). Use after skills:find with depth=3 to see available files.', inputSchema: { type: 'object', properties: { skill_name: { type: 'string', description: 'Name of the installed skill' }, file_path: { type: 'string', description: 'Relative path within skill directory (e.g., "resources/forms.md", "scripts/process.py")' } }, required: ['skill_name', 'file_path'] } }, { name: 'add', description: 'Install an Anthropic Agent Skill from marketplace. Downloads SKILL.md to ~/.ncp/skills/ for auto-loading.', inputSchema: { type: 'object', properties: { skill_name: { type: 'string', description: 'REQUIRED. Name of the skill to install (e.g., "canvas-design", "pdf", "docx").' } }, required: ['skill_name'] } }, { name: 'list', description: 'List installed Anthropic Agent Skills from ~/.ncp/skills/. Shows skills currently installed and ready for use.', inputSchema: { type: 'object', properties: {} } }, { name: 'remove', description: 'Remove an installed Anthropic Agent Skill.', inputSchema: { type: 'object', properties: { skill_name: { type: 'string', description: 'REQUIRED. Name of the skill to remove.' } }, required: ['skill_name'] } } ]; /** * Initialize marketplace client */ private async ensureClient(): Promise<SkillsMarketplaceClient> { if (!this.marketplaceClient) { this.marketplaceClient = new SkillsMarketplaceClient(); await this.marketplaceClient.initialize(); } return this.marketplaceClient; } /** * Initialize skills manager */ private async ensureSkillsManager(): Promise<SkillsManager> { if (!this.skillsManager) { this.skillsManager = new SkillsManager(); await this.skillsManager.initialize(); await this.skillsManager.loadAllSkills(); } return this.skillsManager; } /** * Initialize RAG engine for vector search on skills */ private async ensureRAGEngine(): Promise<PersistentRAGEngine> { if (!this.ragEngine) { this.ragEngine = new PersistentRAGEngine(); // Initialize with skills data - use empty config as skills are separate from MCPs await this.ragEngine.initialize({}); } return this.ragEngine; } async executeTool(toolName: string, params: any): Promise<InternalToolResult> { try { switch (toolName) { case 'find': return await this.handleFind(params); case 'read_resource': return await this.handleReadResource(params); case 'add': const client = await this.ensureClient(); return await this.handleAdd(client, params); case 'list': // skills:list is an alias for skills:find with no parameters return await this.handleFind({}); case 'remove': const removeClient = await this.ensureClient(); return await this.handleRemove(removeClient, params); default: return { success: false, content: `Unknown skill tool: ${toolName}. Available: find, read_resource, add, list, remove` }; } } catch (error: any) { logger.error(`Skills tool execution failed: ${toolName} - ${error.message}`); return { success: false, content: `Failed to execute ${toolName}: ${error.message}` }; } } /** * Handle skills:find - Progressive skill discovery with vector search * Level 1: Metadata only (name + description) * Level 2: + Full SKILL.md content (AI learns the skill) * Level 3: + File tree listing * * Uses semantic search via RAG engine when query is provided */ private async handleFind(params: any): Promise<InternalToolResult> { const manager = await this.ensureSkillsManager(); const query = params?.query?.trim() || ''; const depth = params?.depth || 1; const page = params?.page || 1; const limit = params?.limit || 10; // Get all loaded skills const allSkills = manager.getLoadedSkills(); // Filter by query using vector search if provided let filteredSkills = allSkills; if (query && allSkills.length > 0) { try { // Use RAG engine for semantic search const ragEngine = await this.ensureRAGEngine(); // Build skill descriptions for semantic search const skillDescriptions: Record<string, { name: string; description: string; index: number }> = {}; allSkills.forEach((skill, idx) => { const fullDescription = `${skill.metadata.name}: ${skill.metadata.description || ''}. ` + `Tags: ${skill.metadata.tags?.join(', ') || 'none'}`; skillDescriptions[`skill_${idx}`] = { name: skill.metadata.name, description: fullDescription, index: idx }; }); // Simple vector similarity search using description matching // For now, use keyword + semantic combination until we have proper embeddings for skills const scoredSkills = allSkills.map((skill, idx) => { let score = 0; const queryLower = query.toLowerCase(); const skillName = skill.metadata.name.toLowerCase(); const skillDesc = (skill.metadata.description || '').toLowerCase(); const skillTags = (skill.metadata.tags || []).map(t => t.toLowerCase()).join(' '); // Exact name match - highest priority if (skillName === queryLower) { score = 1.0; } // Name contains query else if (skillName.includes(queryLower)) { score = 0.9; } // Description contains query else if (skillDesc.includes(queryLower)) { score = 0.7; } // Tags contain query else if (skillTags.includes(queryLower)) { score = 0.6; } // Word-level similarity in description else { const queryWords = queryLower.split(/\s+/); const descWords = (skillDesc + ' ' + skillTags).split(/\s+/); const matches = queryWords.filter((w: string) => descWords.some(d => d.includes(w))); score = matches.length > 0 ? (matches.length / queryWords.length) * 0.5 : 0; } return { skill, score, index: idx }; }); // Sort by score and filter filteredSkills = scoredSkills .filter(s => s.score > 0) .sort((a, b) => b.score - a.score) .map(s => s.skill); } catch (error: any) { logger.debug(`Vector search failed, falling back to basic filtering: ${error.message}`); // Fallback to simple text matching if vector search fails filteredSkills = allSkills.filter(skill => skill.metadata.name.toLowerCase().includes(query.toLowerCase()) || skill.metadata.description?.toLowerCase().includes(query.toLowerCase()) || skill.metadata.tags?.some(tag => tag.toLowerCase().includes(query.toLowerCase())) ); } } // Handle empty results if (filteredSkills.length === 0) { return { success: true, content: query ? `No skills found matching "${query}". Try skills:add to install new skills.` : 'No skills installed yet. Use skills:add to install skills from the marketplace.' }; } // Pagination const totalResults = filteredSkills.length; const totalPages = Math.ceil(totalResults / limit); const startIndex = (page - 1) * limit; const endIndex = Math.min(startIndex + limit, totalResults); const paginatedSkills = filteredSkills.slice(startIndex, endIndex); // Build output based on depth let output = `## Skills Search Results (${totalResults} total)\n\n`; if (totalPages > 1) { output += `**Page ${page} of ${totalPages}** (showing ${startIndex + 1}-${endIndex})\n\n`; } for (const skill of paginatedSkills) { output += `### 📚 ${skill.metadata.name}\n`; output += `**Description:** ${skill.metadata.description || '(no description)'}\n`; // Level 1: Metadata only (always included) if (skill.metadata.version) { output += `**Version:** ${skill.metadata.version}\n`; } if (skill.metadata.author) { output += `**Author:** ${skill.metadata.author}\n`; } if (skill.metadata.tools && skill.metadata.tools.length > 0) { output += `**Tools:** ${skill.metadata.tools.join(', ')}\n`; } // Level 2: Full SKILL.md content (AI learns the skill!) if (depth >= 2) { output += `\n**Full Content:**\n\n`; output += '```markdown\n'; output += skill.content; output += '\n```\n'; } // Level 3: File tree listing if (depth >= 3) { const fileTree = await this.getSkillFileTree(skill.directory); if (fileTree.length > 0) { output += `\n**Available Files:**\n`; for (const file of fileTree) { output += `- ${file}\n`; } output += `\n💡 Use \`skills:read_resource\` to read specific files.\n`; } } output += `\n---\n\n`; } // Pagination hint if (page < totalPages) { output += `\n💡 More results available. Use \`page: ${page + 1}\` to see next page.\n`; } return { success: true, content: output }; } /** * Get file tree for a skill directory */ private async getSkillFileTree(skillDir: string): Promise<string[]> { const files: string[] = []; try { const entries = await fs.readdir(skillDir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === 'SKILL.md') continue; // Already shown in content const relativePath = entry.name; if (entry.isDirectory()) { // Recursively list directory contents const subFiles = await this.listDirectoryRecursive(path.join(skillDir, entry.name), entry.name); files.push(...subFiles); } else { files.push(relativePath); } } } catch (error: any) { logger.debug(`Failed to list skill files: ${error.message}`); } return files.sort(); } /** * Recursively list directory contents */ private async listDirectoryRecursive(dirPath: string, prefix: string): Promise<string[]> { const files: string[] = []; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const relativePath = `${prefix}/${entry.name}`; if (entry.isDirectory()) { const subFiles = await this.listDirectoryRecursive(path.join(dirPath, entry.name), relativePath); files.push(...subFiles); } else { files.push(relativePath); } } } catch (error: any) { logger.debug(`Failed to list directory: ${error.message}`); } return files; } /** * Handle skills:read_resource - Read additional files from skill */ private async handleReadResource(params: any): Promise<InternalToolResult> { const skillName = params?.skill_name; const filePath = params?.file_path; if (!skillName || !filePath) { return { success: false, content: 'Missing required parameters: skill_name and file_path' }; } // Security: Prevent path traversal if (filePath.includes('..') || path.isAbsolute(filePath)) { return { success: false, content: 'Invalid file_path: must be relative path without ".."' }; } const manager = await this.ensureSkillsManager(); const skill = manager.getLoadedSkills().find(s => s.metadata.name === skillName); if (!skill) { return { success: false, content: `Skill not found: ${skillName}. Use skills:find to see available skills.` }; } const fullPath = path.join(skill.directory, filePath); try { // Check if file exists and is within skill directory (path traversal protection) const realPath = await fs.realpath(fullPath); const realSkillDir = await fs.realpath(skill.directory); // Use path.relative to check if file is within skill directory // If relative path starts with '..', it's outside the directory const relativePath = path.relative(realSkillDir, realPath); if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return { success: false, content: 'Invalid file_path: must be within skill directory' }; } // Read file content const content = await fs.readFile(fullPath, 'utf-8'); const fileExtension = path.extname(filePath).toLowerCase(); // Determine syntax highlighting const syntaxMap: Record<string, string> = { '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.sh': 'bash', '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.md': 'markdown', '.html': 'html', '.css': 'css' }; const syntax = syntaxMap[fileExtension] || ''; return { success: true, content: `## ${filePath}\n\n\`\`\`${syntax}\n${content}\n\`\`\`` }; } catch (error: any) { if (error.code === 'ENOENT') { return { success: false, content: `File not found: ${filePath}. Use skills:find with depth=3 to see available files.` }; } return { success: false, content: `Failed to read file: ${error.message}` }; } } private async handleAdd(client: SkillsMarketplaceClient, params: any): Promise<InternalToolResult> { const skillName = params?.skill_name; if (!skillName) { return { success: false, content: 'Missing required parameter: skill_name' }; } logger.info(`Installing skill: ${skillName}`); const result = await client.install(skillName); if (result.success) { return { success: true, content: `✅ ${result.message}\n\n**Installed to:** ${result.skillPath}\n\n💡 **Note:** Skill will be auto-loaded on next NCP restart. To use it now, restart NCP or use Claude Code's \`/plugin install\` command.` }; } else { return { success: false, content: `❌ ${result.message}` }; } } private async handleRemove(client: SkillsMarketplaceClient, params: any): Promise<InternalToolResult> { const skillName = params?.skill_name; if (!skillName) { return { success: false, content: 'Missing required parameter: skill_name' }; } logger.info(`Removing skill: ${skillName}`); const result = await client.remove(skillName); if (result.success) { return { success: true, content: `✅ ${result.message}\n\n💡 **Note:** Changes take effect after NCP restart.` }; } else { return { success: false, content: `❌ ${result.message}` }; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server