@macroforge/mcp-server
Version:
MCP server for Macroforge documentation and code analysis
176 lines (175 loc) • 7.01 kB
JavaScript
/**
* @module docs-loader
*
* Documentation Loading and Search Utilities
*
* This module provides functionality for loading, searching, and retrieving
* Macroforge documentation sections. Documentation is stored as markdown files
* with metadata in `docs/sections.json`.
*
* Key features:
* - Eagerly loads documentation sections (metadata and content) from disk at startup
* - Supports multiple search strategies (exact, partial, fuzzy, category)
* - Handles chunked documentation for large sections
* - Provides type-safe interfaces for documentation structure
*
* @example
* ```typescript
* import { loadSections, getSection, searchSections } from './docs-loader.ts';
*
* const sections = loadSections();
* const debug = getSection(sections, 'debug');
* const matches = searchSections(sections, 'encoding');
* ```
*/
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Current file path for ESM module resolution */
const __filename = fileURLToPath(import.meta.url);
/** Current directory path for ESM module resolution */
const __dirname = dirname(__filename);
/** Path to the docs directory containing sections.json and markdown files */
const docsDir = join(__dirname, '..', '..', 'docs');
/**
* Loads all documentation sections from the docs directory.
*
* This function reads `docs/sections.json` for metadata and loads the markdown
* content for each section from disk. Chunked parent sections do not have their
* content loaded directly - their content is accessed through child chunks.
*
* @returns Array of Section objects with content loaded. Returns empty array if
* sections.json is not found (with a warning logged to stderr).
* @throws When sections.json lists a markdown file that does not exist.
*
* @example
* ```typescript
* const sections = loadSections();
* console.log(`Loaded ${sections.length} sections`);
* ```
*/
export function loadSections() {
const sectionsPath = join(docsDir, 'sections.json');
if (!existsSync(sectionsPath)) {
console.error('Warning: sections.json not found. Run "pixi run docs:mcp" to generate documentation.');
return [];
}
const sectionsData = JSON.parse(readFileSync(sectionsPath, 'utf-8'));
// Load content for each non-chunked section from its markdown file
for (const section of sectionsData) {
// Chunked parent sections don't have direct content - content is in child chunks
if (section.is_chunked) {
continue;
}
section.content = readFileSync(join(docsDir, section.path), 'utf-8');
}
return sectionsData;
}
/**
* Finds a section by ID or title using progressive matching strategies.
*
* The function attempts to match in the following order (stopping at first match):
* 1. Exact ID match (case-insensitive)
* 2. Exact title match (case-insensitive)
* 3. Partial ID match (query is substring of ID)
* 4. Partial title match (query is substring of title)
*
* @param sections - Array of sections to search through
* @param query - Search query (ID or title to find)
* @returns The first matching Section, or undefined if no match found
*
* @example
* ```typescript
* const debug = getSection(sections, 'debug'); // Exact ID match
* const endec = getSection(sections, 'Encode'); // Exact title match
* const partial = getSection(sections, 'valid'); // Partial match on "validators"
* ```
*/
export function getSection(sections, query) {
const normalizedQuery = query.toLowerCase().trim();
// Priority 1: Exact ID match (most specific)
let match = sections.find((s) => s.id.toLowerCase() === normalizedQuery);
if (match)
return match;
// Priority 2: Exact title match
match = sections.find((s) => s.title.toLowerCase() === normalizedQuery);
if (match)
return match;
// Priority 3: Partial ID match (query is substring of ID)
match = sections.find((s) => s.id.toLowerCase().includes(normalizedQuery));
if (match)
return match;
// Priority 4: Partial title match (query is substring of title)
match = sections.find((s) => s.title.toLowerCase().includes(normalizedQuery));
if (match)
return match;
return undefined;
}
/**
* Performs a fuzzy search across sections using a weighted scoring algorithm.
*
* The search splits the query into keywords and scores each section based on
* where matches are found. Results are sorted by relevance score (highest first).
*
* ## Scoring Algorithm
*
* Each section is scored based on matches in different fields:
* - **ID match** (+10): Full query found in section ID
* - **Title match** (+10): Full query found in section title
* - **Use cases match** (+5 per keyword): Each query keyword found in use_cases
* - **Content match** (+1 per keyword): Each query keyword found in content
*
* The higher weights for ID/title ensure exact matches rank above content matches.
* Use cases are weighted moderately as they indicate intended use.
*
* @param sections - Array of sections to search through
* @param query - Search query (can contain multiple space-separated keywords)
* @returns Array of matching Sections sorted by relevance score (highest first)
*
* @example
* ```typescript
* // Single keyword search
* const results = searchSections(sections, 'validation');
*
* // Multi-keyword search (scores sections matching more keywords higher)
* const results = searchSections(sections, 'email url length');
* ```
*/
export function searchSections(sections, query) {
const normalizedQuery = query.toLowerCase().trim();
const keywords = normalizedQuery.split(/\s+/);
// Score each section based on where query/keywords match
const scored = sections.map((section) => {
let score = 0;
// High priority: ID contains full query (+10)
if (section.id.toLowerCase().includes(normalizedQuery)) {
score += 10;
}
// High priority: Title contains full query (+10)
if (section.title.toLowerCase().includes(normalizedQuery)) {
score += 10;
}
// Medium priority: Each keyword in use_cases (+5 each)
const useCases = section.use_cases.toLowerCase();
for (const keyword of keywords) {
if (useCases.includes(keyword)) {
score += 5;
}
}
// Low priority: Each keyword in content (+1 each)
if (section.content) {
const content = section.content.toLowerCase();
for (const keyword of keywords) {
if (content.includes(keyword)) {
score += 1;
}
}
}
return { section, score };
});
// Return matching sections sorted by score (highest first)
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.map((s) => s.section);
}