/** * MCP Scanner - Scan MCPs and generate skill files from their tool schemas * * This connects to each MCP, extracts tool definitions, and generates * skill documentation so Claude knows what's available even when MCPs are disabled. */ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { spawn, ChildProcess } from 'node:child_process'; import chalk from 'chalk'; interface McpServerConfig { type?: string; command: string; args: string[]; env?: Record; } interface McpTool { name: string; description?: string; inputSchema?: { type: string; properties?: Record; required?: string[]; }; } interface ProjectConfig { mcpServers?: Record; disabledMcpServers?: string[]; } interface ClaudeConfig { [projectPath: string]: ProjectConfig; } const CLAUDE_CONFIG_PATH = path.join(os.homedir(), '.claude.json'); /** * Load Claude Code config */ async function loadClaudeConfig(): Promise { try { const content = await fs.readFile(CLAUDE_CONFIG_PATH, 'utf8'); return JSON.parse(content); } catch { return {}; } } /** * Scan an MCP server and get its tools */ async function scanMcpTools( mcpId: string, config: McpServerConfig ): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { console.log(chalk.yellow(` ā±ļø ${mcpId}: Timeout (5s)`)); resolve(null); }, 5000); try { // Spawn the MCP server const env = { ...process.env, ...config.env }; const child: ChildProcess = spawn(config.command, config.args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let responded = false; child.stdout?.on('data', (data) => { stdout += data.toString(); // Look for tools list response try { const lines = stdout.split('\n'); for (const line of lines) { if (line.includes('"tools"') && line.includes('[')) { const json = JSON.parse(line); if (json.result?.tools) { clearTimeout(timeout); responded = true; child.kill(); resolve(json.result.tools); return; } } } } catch { // Not valid JSON yet, keep reading } }); child.on('error', (err) => { clearTimeout(timeout); if (!responded) { console.log(chalk.red(` āŒ ${mcpId}: ${err.message}`)); resolve(null); } }); child.on('exit', () => { clearTimeout(timeout); if (!responded) { resolve(null); } }); // Send ListTools request const request = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + '\n'; child.stdin?.write(request); } catch (err) { clearTimeout(timeout); console.log(chalk.red(` āŒ ${mcpId}: Failed to spawn`)); resolve(null); } }); } /** * Generate a skill markdown file from MCP tools */ function generateSkillFromTools(mcpId: string, config: McpServerConfig, tools: McpTool[]): string { const toolDocs = tools.map(tool => { const props = tool.inputSchema?.properties || {}; const required = new Set(tool.inputSchema?.required || []); const argsDoc = Object.entries(props).map(([name, schema]) => { const req = required.has(name) ? '(required)' : '(optional)'; return ` - \`${name}\`: ${schema.type} ${req} - ${schema.description || 'No description'}`; }).join('\n'); return `### \`${tool.name}\` ${tool.description || 'No description available.'} **Arguments:** ${argsDoc || ' None'} `; }).join('\n'); const commandStr = `${config.command} ${config.args.join(' ')}`; const envVars = Object.keys(config.env || {}); return `--- skill_id: ${mcpId} mcp_server: ${mcpId} category: scanned tags: [mcp, auto-generated] generated_at: ${new Date().toISOString()} tool_count: ${tools.length} --- # ${mcpId} MCP Skill > Auto-generated from MCP tool scan. ${tools.length} tool(s) available. ## MCP Server \`\`\`bash ${commandStr} \`\`\` ${envVars.length > 0 ? `**Environment Variables:** ${envVars.map(v => `\`${v}\``).join(', ')}` : ''} ## Available Tools ${toolDocs} ## Usage When this MCP is **enabled**, Claude can call these tools directly. When this MCP is **disabled**, ask Iris to invoke the tool: > "Iris, call ${mcpId}'s ${tools[0]?.name || 'tool_name'} with {...args}" Iris will temporarily enable the MCP, call the tool, and return results. --- *Generated by \`npx iris mcp scan\`* `; } /** * Main scan command */ export async function runMcpScan(options: { mcpIds?: string[]; output?: string; skipDisabled?: boolean; } = {}): Promise { const config = await loadClaudeConfig(); const projectPath = process.cwd(); const projectConfig = config[projectPath]; console.log(chalk.blue('\nšŸ” MCP Tool Scanner\n')); if (!projectConfig?.mcpServers) { console.log(chalk.yellow('No MCPs configured for this project.')); console.log(chalk.gray(`Project: ${projectPath}\n`)); return; } const mcpServers = projectConfig.mcpServers; const disabled = new Set(projectConfig.disabledMcpServers || []); // Filter MCPs to scan let mcpIds = Object.keys(mcpServers); if (options.mcpIds && options.mcpIds.length > 0) { mcpIds = mcpIds.filter(id => options.mcpIds!.includes(id)); } if (options.skipDisabled) { mcpIds = mcpIds.filter(id => !disabled.has(id)); } console.log(chalk.gray(`Scanning ${mcpIds.length} MCP(s)...\n`)); // Output directory const outputDir = options.output || path.join(projectPath, '.iris', 'mcp', 'skills'); await fs.mkdir(outputDir, { recursive: true }); const results: { id: string; tools: number; status: string }[] = []; for (const mcpId of mcpIds) { const serverConfig = mcpServers[mcpId]; const isDisabled = disabled.has(mcpId); console.log(`šŸ“” ${chalk.cyan(mcpId)}${isDisabled ? chalk.gray(' (disabled)') : ''}`); // For disabled MCPs, we need to try anyway (they might work) const tools = await scanMcpTools(mcpId, serverConfig); if (tools && tools.length > 0) { // Generate skill file const skillContent = generateSkillFromTools(mcpId, serverConfig, tools); const skillPath = path.join(outputDir, `${mcpId}.md`); await fs.writeFile(skillPath, skillContent); console.log(chalk.green(` āœ… ${tools.length} tool(s) → ${mcpId}.md`)); results.push({ id: mcpId, tools: tools.length, status: 'success' }); } else { console.log(chalk.yellow(` āš ļø Could not scan (MCP may need to be enabled first)`)); results.push({ id: mcpId, tools: 0, status: 'failed' }); } } // Summary console.log('\n' + '─'.repeat(50)); const successful = results.filter(r => r.status === 'success'); const totalTools = successful.reduce((sum, r) => sum + r.tools, 0); console.log(chalk.green(`\nāœ… Scanned ${successful.length}/${mcpIds.length} MCP(s)`)); console.log(chalk.gray(` Total tools documented: ${totalTools}`)); console.log(chalk.gray(` Skills saved to: ${outputDir}\n`)); if (successful.length > 0) { console.log(chalk.blue('šŸ’” Next steps:')); console.log(` • Skills are now documented in ${chalk.cyan('.iris/mcp/skills/')}`); console.log(` • Claude can read these even when MCPs are disabled`); console.log(` • Run ${chalk.cyan('npx iris mcp context optimize')} to disable unused MCPs`); console.log(); } } /** * Quick scan that just lists tools without generating files */ export async function runMcpQuickScan(): Promise { const config = await loadClaudeConfig(); const projectPath = process.cwd(); const projectConfig = config[projectPath]; console.log(chalk.blue('\nšŸ” Quick MCP Scan\n')); if (!projectConfig?.mcpServers) { console.log(chalk.yellow('No MCPs configured.\n')); return; } const mcpServers = projectConfig.mcpServers; const disabled = new Set(projectConfig.disabledMcpServers || []); for (const [mcpId, serverConfig] of Object.entries(mcpServers)) { const isDisabled = disabled.has(mcpId); const status = isDisabled ? chalk.gray('ā—‹') : chalk.green('ā—'); console.log(`${status} ${chalk.cyan(mcpId)}`); console.log(chalk.gray(` ${serverConfig.command} ${serverConfig.args.slice(0, 2).join(' ')}`)); if (!isDisabled) { const tools = await scanMcpTools(mcpId, serverConfig); if (tools) { console.log(chalk.gray(` Tools: ${tools.map(t => t.name).join(', ')}`)); } } console.log(); } }