MCP Documentation Service

import fs from "fs/promises"; import path from "path"; import { validatePath } from "../utils/path.js"; import { DocumentEntry, DocumentMetadata, NavigationItem, NavigationSection, TreeEntry, HealthIssue, HealthCheckResult, } from "../types/docs.js"; import { ToolResponse } from "../types/tools.js"; import matter from "gray-matter"; /** * Reads a markdown document and extracts its content and metadata */ export async function readDocument( docPath: string, allowedDirectories: string[] ): Promise<ToolResponse> { try { const normalizedPath = await validatePath(docPath, allowedDirectories); // Read the file const content = await fs.readFile(normalizedPath, "utf-8"); // Parse frontmatter const { data: metadata, content: markdownContent } = matter(content); return { content: [{ type: "text", text: "Document read successfully" }], metadata: { path: docPath, content: markdownContent, metadata, }, }; } catch (error: any) { return { content: [ { type: "text", text: `Error reading document: ${error.message}` }, ], isError: true, }; } } /** * Lists all markdown documents in a directory */ export async function listDocuments( basePath: string, allowedDirectories: string[] ): Promise<ToolResponse> { try { const normalizedBasePath = basePath ? await validatePath(basePath, allowedDirectories) : allowedDirectories[0]; const documents: DocumentEntry[] = []; async function processDirectory(dirPath: string) { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { await processDirectory(entryPath); } else if (entry.name.endsWith(".md")) { try { const content = await fs.readFile(entryPath, "utf-8"); const { data: metadata } = matter(content); documents.push({ path: entryPath, name: entry.name, metadata: metadata as DocumentMetadata, }); } catch (error: any) { console.error(`Error processing ${entryPath}: ${error.message}`); } } } } await processDirectory(normalizedBasePath); return { content: [{ type: "text", text: `Found ${documents.length} documents` }], metadata: { documents, }, }; } catch (error: any) { return { content: [ { type: "text", text: `Error listing documents: ${error.message}` }, ], isError: true, }; } } /** * Gets the structure of the documentation directory */ export async function getStructure( basePath: string, allowedDirectories: string[] ): Promise<ToolResponse> { try { const normalizedBasePath = basePath ? await validatePath(basePath, allowedDirectories) : allowedDirectories[0]; async function buildStructure( dirPath: string, relativePath = "" ): Promise<TreeEntry> { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const children: TreeEntry[] = []; let metadata: DocumentMetadata | undefined; // Check if there's an index.md file to get directory metadata const indexPath = path.join(dirPath, "index.md"); try { const indexStat = await fs.stat(indexPath); if (indexStat.isFile()) { const content = await fs.readFile(indexPath, "utf-8"); const { data } = matter(content); metadata = data as DocumentMetadata; } } catch (error) { // No index.md file, that's fine } // Process all entries for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); const entryRelativePath = path.join(relativePath, entry.name); if (entry.isDirectory()) { const subDir = await buildStructure(entryPath, entryRelativePath); children.push(subDir); } else if (entry.name.endsWith(".md") && entry.name !== "index.md") { try { const content = await fs.readFile(entryPath, "utf-8"); const { data } = matter(content); children.push({ name: entry.name, path: entryRelativePath, type: "file", metadata: data as DocumentMetadata, children: [], }); } catch (error: any) { children.push({ name: entry.name, path: entryRelativePath, type: "file", error: error.message, children: [], }); } } } // Sort children by order metadata if available, then by name children.sort((a, b) => { const orderA = a.metadata?.order ?? Infinity; const orderB = b.metadata?.order ?? Infinity; if (orderA !== orderB) { return orderA - orderB; } return a.name.localeCompare(b.name); }); return { name: path.basename(dirPath), path: relativePath, type: "directory", metadata, children, }; } catch (error: any) { return { name: path.basename(dirPath), path: relativePath, type: "directory", error: error.message, children: [], }; } } const structure = await buildStructure(normalizedBasePath); return { content: [ { type: "text", text: "Documentation structure retrieved successfully", }, ], metadata: { structure, }, }; } catch (error: any) { return { content: [ { type: "text", text: `Error getting structure: ${error.message}` }, ], isError: true, }; } } /** * Gets the navigation structure for the documentation */ export async function getNavigation( basePath: string, allowedDirectories: string[] ): Promise<ToolResponse> { try { const normalizedBasePath = basePath ? await validatePath(basePath, allowedDirectories) : allowedDirectories[0]; // First try to load navigation from .navigation file const navigationFilePath = path.join(normalizedBasePath, ".navigation"); const navigationJsonPath = path.join( normalizedBasePath, "_navigation.json" ); const navigationYmlPath = path.join(normalizedBasePath, "_navigation.yml"); let navigation: NavigationSection[] = []; // Try to load from .navigation file try { const navigationContent = await fs.readFile(navigationFilePath, "utf-8"); navigation = JSON.parse(navigationContent); console.log("Loaded navigation from .navigation file"); return { content: [ { type: "text", text: "Navigation structure loaded from .navigation file", }, ], metadata: { navigation, }, }; } catch (error: any) { console.log( "No .navigation file found or error loading it:", error.message ); } // Try to load from _navigation.json file try { const navigationContent = await fs.readFile(navigationJsonPath, "utf-8"); navigation = JSON.parse(navigationContent); console.log("Loaded navigation from _navigation.json file"); return { content: [ { type: "text", text: "Navigation structure loaded from _navigation.json file", }, ], metadata: { navigation, }, }; } catch (error: any) { console.log( "No _navigation.json file found or error loading it:", error.message ); } // If no navigation file found, build from structure console.log("Building navigation from directory structure"); // Get the structure const structureResponse = await getStructure(basePath, allowedDirectories); if (structureResponse.isError) { return structureResponse; } const structure = structureResponse.metadata?.structure as TreeEntry; // Build navigation from structure function buildNavigation(structure: TreeEntry): NavigationSection[] { const sections: NavigationSection[] = []; function processNode(node: TreeEntry, parentPath: string[] = []) { // Skip nodes with errors if (node.error) { return; } if (node.type === "directory") { // Create a section for this directory const section: NavigationSection = { title: node.metadata?.title || node.name, path: node.path ? `/${node.path}` : null, items: [], order: node.metadata?.order ?? Infinity, }; // Process children for (const child of node.children) { if (child.type === "file") { // Add file as an item section.items.push({ title: child.metadata?.title || child.name.replace(/\.md$/, ""), path: `/${child.path}`, order: child.metadata?.order ?? Infinity, }); } else if (child.type === "directory") { // Process subdirectory const childSections = processNode(child, [ ...parentPath, node.name, ]); if (childSections) { sections.push(...childSections); } } } // Sort items by order section.items.sort((a: NavigationItem, b: NavigationItem) => { if (a.order !== b.order) { return a.order - b.order; } return a.title.localeCompare(b.title); }); // Only add section if it has items if (section.items.length > 0) { sections.push(section); } return sections; } return null; } processNode(structure); // Sort sections by order sections.sort((a, b) => { if (a.order !== b.order) { return a.order - b.order; } return a.title.localeCompare(b.title); }); return sections; } navigation = buildNavigation(structure); // Add debug logging console.log("Navigation structure:", JSON.stringify(navigation, null, 2)); return { content: [ { type: "text", text: "Navigation structure retrieved successfully" }, ], metadata: { navigation, }, }; } catch (error: any) { return { content: [ { type: "text", text: `Error getting navigation: ${error.message}` }, ], isError: true, }; } } /** * Checks the health of documentation */ export async function checkDocumentationHealth( basePath: string, options: { checkLinks?: boolean; checkMetadata?: boolean; checkOrphans?: boolean; requiredMetadataFields?: string[]; }, allowedDirectories: string[] ): Promise<ToolResponse> { try { // Set default options const checkLinks = options.checkLinks !== false; const checkMetadata = options.checkMetadata !== false; const checkOrphans = options.checkOrphans !== false; const requiredMetadataFields = options.requiredMetadataFields || [ "title", "description", "status", ]; // Use the first allowed directory if basePath is empty const effectiveBasePath = basePath || allowedDirectories[0]; // Get all documents const docsResult = await listDocuments( effectiveBasePath, allowedDirectories ); if (docsResult.isError) { return docsResult; } const documents = docsResult.metadata?.documents || []; // Get navigation if checking for orphans let navigation: NavigationSection[] = []; if (checkOrphans) { const navResult = await getNavigation( effectiveBasePath, allowedDirectories ); if (!navResult.isError && navResult.metadata?.navigation) { navigation = navResult.metadata.navigation; } } // Initialize health check result const healthResult: HealthCheckResult = { score: 0, totalDocuments: documents.length, issues: [], metadataCompleteness: 0, brokenLinks: 0, orphanedDocuments: 0, missingReferences: 0, documentsByStatus: {}, documentsByTag: {}, }; // Track documents by status and tags documents.forEach((doc: DocumentEntry) => { // Track by status if (doc.metadata?.status) { const status = doc.metadata.status; healthResult.documentsByStatus![status] = (healthResult.documentsByStatus![status] || 0) + 1; } // Track by tags if (doc.metadata?.tags && Array.isArray(doc.metadata.tags)) { doc.metadata.tags.forEach((tag) => { healthResult.documentsByTag![tag] = (healthResult.documentsByTag![tag] || 0) + 1; }); } }); // Check metadata completeness if (checkMetadata) { let totalFields = 0; let missingFields = 0; for (const doc of documents) { const metadata = doc.metadata || {}; for (const field of requiredMetadataFields) { totalFields++; if (!metadata[field]) { missingFields++; healthResult.issues.push({ path: doc.path, type: "missing_metadata", severity: "error", message: `Missing required metadata field: ${field}`, details: { field }, }); } } } // Calculate metadata completeness percentage healthResult.metadataCompleteness = totalFields > 0 ? Math.round(((totalFields - missingFields) / totalFields) * 100) : 100; } // Check for orphaned documents (not in navigation) if (checkOrphans) { // Completely disable orphaned documents check console.log("Orphaned documents check is disabled"); healthResult.orphanedDocuments = 0; // Ensure we don't have any orphaned document issues in the result healthResult.issues = healthResult.issues.filter( (issue) => issue.type !== "orphaned" ); } // Check for broken links if (checkLinks) { // Create a set of all valid document paths const validPaths = new Set<string>(); for (const doc of documents) { validPaths.add(doc.path); // Also add without .md extension validPaths.add(doc.path.replace(/\.md$/, "")); } // Check each document for links for (const doc of documents) { try { const content = await fs.readFile(doc.path, "utf-8"); // Find markdown links [text](link) const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; let match; while ((match = linkRegex.exec(content)) !== null) { const link = match[2]; // Only check internal links (not external URLs) if (!link.startsWith("http://") && !link.startsWith("https://")) { // Resolve the link relative to the document const docDir = path.dirname(doc.path); const resolvedPath = path.resolve(docDir, link); // Check if the link target exists if ( !validPaths.has(resolvedPath) && !validPaths.has(resolvedPath + ".md") ) { healthResult.brokenLinks++; healthResult.issues.push({ path: doc.path, type: "broken_link", severity: "error", message: `Broken link: ${link}`, details: { link, linkText: match[1] }, }); } } } } catch (error) { // Skip files that can't be read console.error(`Error reading file ${doc.path}:`, error); } } } // Calculate health score // The score is based on: // - Metadata completeness (70%) // - No broken links (30%) // - Orphaned documents check is disabled const metadataScore = healthResult.metadataCompleteness * 0.7; const brokenLinksScore = healthResult.brokenLinks === 0 ? 30 : Math.max( 0, 30 - (healthResult.brokenLinks / healthResult.totalDocuments) * 100 ); // Calculate the final score healthResult.score = Math.round(metadataScore + brokenLinksScore); // Create a clean result object to ensure proper JSON formatting const finalResult = { score: healthResult.score, totalDocuments: healthResult.totalDocuments, issues: healthResult.issues, metadataCompleteness: healthResult.metadataCompleteness, brokenLinks: healthResult.brokenLinks, orphanedDocuments: 0, missingReferences: healthResult.missingReferences, documentsByStatus: healthResult.documentsByStatus, documentsByTag: healthResult.documentsByTag, }; return { content: [ { type: "text", text: `Documentation health check completed. Overall health score: ${finalResult.score}%`, }, ], metadata: finalResult, }; } catch (error: any) { return { content: [ { type: "text", text: `Error checking documentation health: ${error.message}`, }, ], isError: true, }; } }