@apideck/docs-mcp
Version:
MCP server that lets agents search and retrieve your documentation at query time instead of relying on stale training data.
125 lines • 5.25 kB
JavaScript
// SPDX-License-Identifier: MIT
const DAY_MS = 86_400_000;
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
const daysBetween = (a, b) => Math.floor((a.getTime() - b.getTime()) / DAY_MS);
const auditPage = (page, store, opts) => {
const issues = [];
const add = (severity, rule, message) => {
issues.push({ severity, rule, path: page.path, message });
};
const fmTitle = page.frontmatter['title'];
const h1s = page.sections.filter((s) => s.level === 1);
if ((typeof fmTitle !== 'string' || fmTitle.trim() === '') && h1s.length === 0) {
add('warning', 'missing-title', `No frontmatter title or H1; using "${page.title}" from the filename.`);
}
if (h1s.length > 1) {
add('warning', 'multiple-h1', `${h1s.length} H1 headings; search results will carry ambiguous titles.`);
}
if (page.description === undefined) {
add('info', 'missing-description', 'No frontmatter description; list_docs and resources show only the title.');
}
if (page.words < opts.minWords) {
add(page.words === 0 ? 'error' : 'warning', 'thin-content', page.words === 0 ? 'Page is empty.' : `Only ${page.words} words (threshold ${opts.minWords}).`);
}
const age = daysBetween(opts.now, page.lastModified);
if (age > opts.staleDays) {
add('warning', 'stale', `Last modified ${age} days ago (threshold ${opts.staleDays}).`);
}
let previousLevel = 0;
for (const section of page.sections) {
if (section.level === 0)
continue;
if (previousLevel > 0 && section.level > previousLevel + 1) {
add('info', 'heading-skip', `"${section.heading}" jumps from H${previousLevel} to H${section.level} (line ${section.startLine}).`);
}
previousLevel = section.level;
}
for (let i = 0; i < page.sections.length; i += 1) {
const section = page.sections[i];
if (!section)
continue;
const next = page.sections[i + 1];
const hasChildren = next !== undefined && next.level > section.level;
const words = section.content === '' ? 0 : section.content.split(/\s+/).length;
if (!hasChildren && words > opts.maxSectionWords) {
add('info', 'long-section', `Section "${section.heading || '(preamble)'}" has ${words} words with no sub-headings; split it so search returns a focused chunk.`);
}
}
for (const href of page.links) {
if (!store.resolveLink(page.path, href)) {
add('error', 'broken-link', `Link target not found: ${href}`);
}
}
return issues;
};
export const auditDocs = (store, options = {}) => {
const now = options.now ?? new Date();
const opts = {
staleDays: options.staleDays ?? 180,
minWords: options.minWords ?? 50,
maxSectionWords: options.maxSectionWords ?? 1500,
now,
};
const pages = store.listPages();
const issues = pages.flatMap((page) => auditPage(page, store, opts));
const byTitle = new Map();
for (const page of pages) {
const key = page.title.toLowerCase();
byTitle.set(key, [...(byTitle.get(key) ?? []), page.path]);
}
for (const [, paths] of byTitle) {
if (paths.length < 2)
continue;
for (const p of paths) {
issues.push({
severity: 'warning',
rule: 'duplicate-title',
path: p,
message: `Title shared with ${paths.filter((x) => x !== p).join(', ')}; agents cannot tell them apart in results.`,
});
}
}
issues.sort((a, b) => a.path.localeCompare(b.path) ||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
a.rule.localeCompare(b.rule));
const stats = store.stats();
const count = (s) => issues.filter((i) => i.severity === s).length;
return {
root: store.root,
generatedAt: now.toISOString(),
options: { staleDays: opts.staleDays, minWords: opts.minWords, maxSectionWords: opts.maxSectionWords },
summary: {
pages: stats.pages,
sections: stats.sections,
words: stats.words,
errors: count('error'),
warnings: count('warning'),
info: count('info'),
pagesWithIssues: new Set(issues.map((i) => i.path)).size,
},
issues,
};
};
export const formatAuditReport = (report) => {
const { summary } = report;
const lines = [
`Docs audit: ${report.root}`,
`${summary.pages} pages, ${summary.sections} sections, ${summary.words} words`,
`${summary.errors} errors, ${summary.warnings} warnings, ${summary.info} info (${summary.pagesWithIssues} pages affected)`,
'',
];
if (report.issues.length === 0) {
lines.push('No issues found.');
return lines.join('\n');
}
let currentPath = '';
for (const issue of report.issues) {
if (issue.path !== currentPath) {
currentPath = issue.path;
lines.push(currentPath);
}
lines.push(` ${issue.severity.padEnd(7)} ${issue.rule.padEnd(19)} ${issue.message}`);
}
return lines.join('\n');
};
//# sourceMappingURL=audit.js.map