UNPKG

mcp-product-manager

Version:

MCP Orchestrator for task and project management with web interface

431 lines (362 loc) โ€ข 14 kB
#!/usr/bin/env node /** * Comprehensive Documentation Manifest Generator * Inspired by sweet_potato's documentation system * * This script generates a detailed manifest that includes: * - All API endpoints with full metadata * - Test coverage information * - Documentation coverage * - MCP tools mapping * - Category organization */ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { glob } from 'glob'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.join(__dirname, '..'); const DOCS_DIR = path.join(ROOT_DIR, 'docs'); class ManifestGenerator { constructor() { this.endpoints = []; this.tools = []; this.tests = {}; this.docs = {}; this.mountMap = {}; // file path -> mount prefix } async discoverEndpoints() { console.log('๐Ÿ” Discovering API endpoints...'); // Build mount map from api/index.js (router.use('/prefix', var)) await this.buildMountMap(); // Scan all route files const routeFiles = await glob('api/routes/**/*.js', { cwd: ROOT_DIR }); for (const file of routeFiles) { try { const content = await fs.readFile(path.join(ROOT_DIR, file), 'utf8'); const routes = this.extractRoutes(content, file); this.endpoints.push(...routes); // Extract tool definition if exists const tool = this.extractTool(content, file); if (tool) { this.tools.push(tool); } } catch (err) { console.warn(`โš ๏ธ Could not parse ${file}: ${err.message}`); } } console.log(` Found ${this.endpoints.length} endpoints`); console.log(` Found ${this.tools.length} MCP tools`); } async buildMountMap() { try { const indexPath = path.join(ROOT_DIR, 'api', 'index.js'); const content = await fs.readFile(indexPath, 'utf8'); // Extract imports: import X from './routes/...' const importPattern = /import\s+([a-zA-Z0-9_]+)\s+from\s+['"]\.\/routes\/([^'";]+)['"];?/g; const varToFile = {}; let m; while ((m = importPattern.exec(content)) !== null) { varToFile[m[1]] = `api/routes/${m[2]}`; } // Extract mounts: router.use('/prefix', X) const mountPattern = /router\.use\(\s*['"]([^'"\)]+)['"]\s*,\s*([a-zA-Z0-9_]+)/g; let mm; while ((mm = mountPattern.exec(content)) !== null) { const mountPath = mm[1]; const varName = mm[2]; const file = varToFile[varName]; if (file) { // Normalize: ensure mounted under '/api' for consistency const normalized = mountPath.startsWith('/api') ? mountPath : `/api${mountPath}`; this.mountMap[file] = normalized; } } } catch (err) { // Non-fatal } } extractRoutes(content, file) { const routes = []; const routePattern = /router\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi; const matches = [...content.matchAll(routePattern)]; const category = this.getCategoryFromPath(file); for (const match of matches) { const method = (match[1] || '').toUpperCase(); let routePath = match[2]; if (!method || !routePath) continue; // Apply mount prefix if this file is mounted with a base path const mountPrefix = this.mountMap[file]; if (mountPrefix) { const suffix = routePath === '/' ? '' : routePath; routePath = `${mountPrefix}${suffix}`; } routes.push({ method: method, path: routePath, file: file, category: category, tags: this.generateTags(method, routePath, file), authentication: this.detectAuthentication(content, routePath), description: this.extractDescription(content, routePath), docs: [], tests: [] }); } return routes; } extractTool(content, file) { const toolMatch = content.match(/export\s+const\s+tool\s*=\s*{([^}]+(?:{[^}]*}[^}]*)*[^}]*)}/s); if (!toolMatch) return null; const toolContent = toolMatch[1]; const nameMatch = toolContent.match(/name:\s*['"`]([^'"`]+)['"`]/); const descMatch = toolContent.match(/description:\s*['"`]([^'"`]+)['"`]/); if (!nameMatch) return null; return { name: nameMatch[1], description: descMatch ? descMatch[1] : '', file: file, category: this.getCategoryFromPath(file), hasDirectExecute: content.includes('execute:'), hasInputSchema: content.includes('inputSchema:') }; } getCategoryFromPath(file) { const parts = file.split('/'); if (parts[0] === 'api' && parts[1] === 'routes' && parts[2]) { return parts[2]; } return 'general'; } generateTags(method, path, file) { const tags = []; const category = this.getCategoryFromPath(file); if (category) tags.push(category); // Method-based tags if (method === 'GET') tags.push('read'); if (method === 'POST') tags.push('create', 'write'); if (method === 'PUT' || method === 'PATCH') tags.push('update', 'write'); if (method === 'DELETE') tags.push('delete', 'write'); // Path-based tags if (path.includes('status')) tags.push('status'); if (path.includes('health')) tags.push('health'); if (path.includes('list')) tags.push('list'); if (path.includes('search')) tags.push('search'); if (path.includes('webhook')) tags.push('webhook'); return [...new Set(tags)]; } detectAuthentication(content, path) { if (path === '/api/health' || path === '/health') return 'none'; if (content.includes('X-API-Key')) return 'api-key'; if (content.includes('jwt')) return 'jwt'; if (content.includes('X-Role')) return 'rbac'; return 'required'; } extractDescription(content, routePath) { if (!routePath) { return 'endpoint'; } // Try to find a comment above the route const routePattern = new RegExp(`\\/\\*\\*([^*]|\\*(?!\\/))*\\*\\/\\s*router\\.[a-z]+\\s*\\(['"\`]${routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'); const match = content.match(routePattern); if (match) { const comment = match[0]; const descMatch = comment.match(/@description\s+(.+?)(?:\n|\*)/); if (descMatch) return descMatch[1].trim(); } // Generate a default description const parts = routePath.split('/').filter(Boolean); const lastPart = parts[parts.length - 1]; return `${lastPart.replace(/[-_]/g, ' ')} endpoint`; } async discoverTests() { console.log('๐Ÿงช Discovering test coverage...'); const testFiles = await glob('__tests__/**/*.test.js', { cwd: ROOT_DIR }); for (const file of testFiles) { try { const content = await fs.readFile(path.join(ROOT_DIR, file), 'utf8'); this.extractTestCoverage(content, file); } catch (err) { console.warn(`โš ๏ธ Could not parse test ${file}: ${err.message}`); } } console.log(` Found ${Object.keys(this.tests).length} tested endpoints`); } extractTestCoverage(content, file) { // Look for API endpoint tests const patterns = [ /request\(app\)\s*\.(get|post|put|delete|patch)\s*\(\s*['"`]([^'"`]+)['"`]/gi, /fetch\s*\(\s*['"`]([^'"`]+)['"`].*method:\s*['"`](GET|POST|PUT|DELETE|PATCH)['"`]/gi ]; for (const pattern of patterns) { const matches = [...content.matchAll(pattern)]; for (const match of matches) { const method = (match[1] || match[2]).toUpperCase(); const path = match[2] || match[1]; const key = `${method} ${path}`; if (!this.tests[key]) { this.tests[key] = []; } if (!this.tests[key].includes(file)) { this.tests[key].push(file); } } } } async discoverDocumentation() { console.log('๐Ÿ“š Discovering documentation...'); const docFiles = await glob('docs/**/*.md', { cwd: ROOT_DIR }); for (const file of docFiles) { try { const content = await fs.readFile(path.join(ROOT_DIR, file), 'utf8'); this.extractDocReferences(content, file); } catch (err) { console.warn(`โš ๏ธ Could not parse doc ${file}: ${err.message}`); } } console.log(` Found ${Object.keys(this.docs).length} documented endpoints`); } extractDocReferences(content, file) { // Look for endpoint references in documentation const patterns = [ /`(GET|POST|PUT|DELETE|PATCH)\s+([^`]+)`/gi, /### (GET|POST|PUT|DELETE|PATCH)\s+(.+)/gi, /## (GET|POST|PUT|DELETE|PATCH)\s+(.+)/gi ]; for (const pattern of patterns) { const matches = [...content.matchAll(pattern)]; for (const match of matches) { const method = match[1].toUpperCase(); const path = match[2].trim(); const key = `${method} ${path}`; if (!this.docs[key]) { this.docs[key] = []; } if (!this.docs[key].includes(file)) { this.docs[key].push(file); } } } } matchTestsAndDocs() { console.log('๐Ÿ”— Matching tests and docs to endpoints...'); for (const endpoint of this.endpoints) { const key = `${endpoint.method} ${endpoint.path}`; // Direct match if (this.tests[key]) { endpoint.tests = this.tests[key]; } if (this.docs[key]) { endpoint.docs = this.docs[key]; } // Pattern matching for dynamic routes if (endpoint.path.includes(':')) { const pattern = endpoint.path.replace(/:[^/]+/g, '[^/]+'); const regex = new RegExp(`^${endpoint.method} ${pattern}$`); for (const testKey in this.tests) { if (regex.test(testKey)) { endpoint.tests = [...new Set([...endpoint.tests, ...this.tests[testKey]])]; } } for (const docKey in this.docs) { if (regex.test(docKey)) { endpoint.docs = [...new Set([...endpoint.docs, ...this.docs[docKey]])]; } } } } } generateCategories() { const categories = {}; for (const endpoint of this.endpoints) { if (!categories[endpoint.category]) { categories[endpoint.category] = { name: endpoint.category.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), description: `${endpoint.category.replace(/-/g, ' ')} management endpoints`, endpoints: 0, documented: 0, tested: 0 }; } categories[endpoint.category].endpoints++; if (endpoint.docs.length > 0) categories[endpoint.category].documented++; if (endpoint.tests.length > 0) categories[endpoint.category].tested++; } return categories; } generateStatistics() { const total = this.endpoints.length; const documented = this.endpoints.filter(e => e.docs.length > 0).length; const tested = this.endpoints.filter(e => e.tests.length > 0).length; return { totalEndpoints: total, documentedEndpoints: documented, testedEndpoints: tested, coverageRate: { documentation: total > 0 ? `${Math.round((documented / total) * 100)}%` : '0%', tests: total > 0 ? `${Math.round((tested / total) * 100)}%` : '0%' }, totalTools: this.tools.length, toolsWithDirectExecute: this.tools.filter(t => t.hasDirectExecute).length }; } async generateManifest() { await this.discoverEndpoints(); await this.discoverTests(); await this.discoverDocumentation(); this.matchTestsAndDocs(); const categories = this.generateCategories(); const stats = this.generateStatistics(); const manifest = { version: '2.0.0', lastUpdated: new Date().toISOString().split('T')[0], baseUrls: { development: 'http://localhost:1234', production: 'https://api.product-manager.dev' }, metadata: stats, endpoints: this.endpoints.sort((a, b) => { if (a.category !== b.category) return a.category.localeCompare(b.category); return a.path.localeCompare(b.path); }), endpointCategories: categories, tools: this.tools.sort((a, b) => a.name.localeCompare(b.name)), documentation: { guides: await glob('docs/guides/**/*.md', { cwd: ROOT_DIR }), api: await glob('docs/api/**/*.md', { cwd: ROOT_DIR }), gettingStarted: await glob('docs/getting-started/**/*.md', { cwd: ROOT_DIR }) } }; return manifest; } async save(manifest) { const outputPath = path.join(DOCS_DIR, 'manifest.json'); await fs.mkdir(DOCS_DIR, { recursive: true }); await fs.writeFile(outputPath, JSON.stringify(manifest, null, 2)); console.log('\nโœ… Manifest generated successfully!'); console.log(`๐Ÿ“Š Statistics:`); console.log(` Total endpoints: ${manifest.metadata.totalEndpoints}`); console.log(` Documented: ${manifest.metadata.documentedEndpoints} (${manifest.metadata.coverageRate.documentation})`); console.log(` Tested: ${manifest.metadata.testedEndpoints} (${manifest.metadata.coverageRate.tests})`); console.log(` MCP Tools: ${manifest.metadata.totalTools}`); console.log(` Tools with direct execute: ${manifest.metadata.toolsWithDirectExecute}`); console.log(`\n๐Ÿ“ Saved to: ${outputPath}`); } async run() { try { const manifest = await this.generateManifest(); await this.save(manifest); return manifest; } catch (err) { console.error('โŒ Error generating manifest:', err); process.exit(1); } } } // Run if executed directly if (process.argv[1] === fileURLToPath(import.meta.url)) { const generator = new ManifestGenerator(); generator.run(); } export { ManifestGenerator };