mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
431 lines (362 loc) โข 14 kB
JavaScript
/**
* 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 };