feat(mcp): add official Anthropic docs tracker — v1.1.0
New tools (4): - init_official_docs() — fetch + store baseline + current snapshots locally - refresh_official_docs() — update current without touching baseline - diff_official_docs() — compare baseline vs current, 0 network, section-level - search_official_docs(query) — search official docs, loads only matching sections Architecture: - 4 local cache files in ~/.cache/claude-code-guide/ (index + content, baseline + current) - Diff reads index files only (~50KB each), never the full 1.2MB content - search loads only matched sections from content file (not entire doc) - Atomic writes (.tmp + rename) prevent snapshot corruption - schemaVersion: 1 for future-proof migrations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4dd479161d
commit
5b713db6fd
6 changed files with 750 additions and 3 deletions
419
mcp-server/src/tools/official-docs.ts
Normal file
419
mcp-server/src/tools/official-docs.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
fetchOfficialDocs,
|
||||
parseIntoSections,
|
||||
saveSnapshot,
|
||||
loadIndex,
|
||||
loadSections,
|
||||
snapshotAgeDays,
|
||||
getSnapshotDir,
|
||||
} from '../lib/docs-cache.js';
|
||||
import { diffSnapshots, findFirstChangedLine } from '../lib/docs-diff.js';
|
||||
|
||||
const STALENESS_WARN_DAYS = 30;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return iso.slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
/** Extract a ~300-char excerpt from content around the first match of query. */
|
||||
function extractExcerpt(content: string, query: string, maxLen = 300): string {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (idx === -1) {
|
||||
// No match in body — return opening
|
||||
const excerpt = content.slice(0, maxLen);
|
||||
return excerpt + (content.length > maxLen ? '…' : '');
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 100);
|
||||
const end = Math.min(content.length, idx + query.length + 200);
|
||||
const prefix = start > 0 ? '…' : '';
|
||||
const suffix = end < content.length ? '…' : '';
|
||||
return prefix + content.slice(start, end) + suffix;
|
||||
}
|
||||
|
||||
/** Score a section for a query. Title matches count ×3, body match ×1. */
|
||||
function scoreSection(title: string, content: string, query: string): number {
|
||||
const q = query.toLowerCase();
|
||||
const titleScore = title.toLowerCase().includes(q) ? 3 : 0;
|
||||
const bodyScore = content.toLowerCase().includes(q) ? 1 : 0;
|
||||
return titleScore + bodyScore;
|
||||
}
|
||||
|
||||
// ── Tool registration ──────────────────────────────────────────────────────
|
||||
|
||||
export function registerOfficialDocs(server: McpServer): void {
|
||||
|
||||
// ── init_official_docs ──────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'init_official_docs',
|
||||
'Fetch the official Anthropic Claude Code docs (llms-full.txt) and store a local snapshot as the diff baseline. Run this first. Safe to re-run — overwrites previous baseline AND current. Takes ~5s (fetches ~1.2MB from Anthropic).',
|
||||
{},
|
||||
{ readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
||||
async () => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fetchOfficialDocs();
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to fetch official docs: ${err instanceof Error ? err.message : String(err)}\n\nCheck network connectivity and retry.`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: ReturnType<typeof parseIntoSections>;
|
||||
try {
|
||||
parsed = parseIntoSections(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Parsing failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// init sets BOTH baseline and current
|
||||
saveSnapshot('baseline', parsed.index, parsed.content);
|
||||
saveSnapshot('current', parsed.index, parsed.content);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to save snapshot to disk: ${err instanceof Error ? err.message : String(err)}\n\nCheck that ${getSnapshotDir()} is writable.`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { index } = parsed;
|
||||
const totalChars = Object.values(index.sections).reduce((s, sec) => s + sec.charCount, 0);
|
||||
|
||||
const lines = [
|
||||
'# Official Docs Snapshot Created',
|
||||
'',
|
||||
`Fetched: ${index.fetchedAt}`,
|
||||
`Sections: ${index.sectionCount}`,
|
||||
`Total size: ${formatSize(totalChars)}`,
|
||||
`Snapshot dir: ${getSnapshotDir()}`,
|
||||
'',
|
||||
'Both baseline and current snapshots have been set.',
|
||||
'',
|
||||
'Next steps:',
|
||||
' - Run refresh_official_docs() to update current without touching the baseline',
|
||||
' - Run diff_official_docs() to see changes between baseline and current',
|
||||
' - Run search_official_docs(query) to search the official docs',
|
||||
];
|
||||
|
||||
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── refresh_official_docs ───────────────────────────────────────────────
|
||||
server.tool(
|
||||
'refresh_official_docs',
|
||||
'Re-fetch the official Anthropic Claude Code docs and update the "current" snapshot without touching the baseline. Run this to update the comparison target before diffing. Takes ~5s (fetches ~1.2MB from Anthropic).',
|
||||
{},
|
||||
{ readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
||||
async () => {
|
||||
const baseline = loadIndex('baseline');
|
||||
if (!baseline) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No baseline snapshot found. Run init_official_docs() first to create both the baseline and the initial current snapshot.',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fetchOfficialDocs();
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to fetch official docs: ${err instanceof Error ? err.message : String(err)}\n\nYour existing snapshots are intact.`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: ReturnType<typeof parseIntoSections>;
|
||||
try {
|
||||
parsed = parseIntoSections(raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Parsing failed: ${err instanceof Error ? err.message : String(err)}\n\nYour existing snapshots are intact.`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
saveSnapshot('current', parsed.index, parsed.content);
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Failed to save current snapshot: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Quick preview: run the diff to show a summary
|
||||
const diff = diffSnapshots(baseline, parsed.index);
|
||||
const total = diff.added.length + diff.removed.length + diff.modified.length;
|
||||
|
||||
const lines = [
|
||||
'# Official Docs — Current Snapshot Updated',
|
||||
'',
|
||||
`Fetched: ${parsed.index.fetchedAt}`,
|
||||
`Sections: ${parsed.index.sectionCount}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (total === 0) {
|
||||
lines.push(`No changes vs baseline (${formatDate(baseline.fetchedAt)}). All ${diff.unchanged} sections unchanged.`);
|
||||
} else {
|
||||
lines.push(`vs baseline (${formatDate(baseline.fetchedAt)}): ${diff.added.length} added, ${diff.removed.length} removed, ${diff.modified.length} modified, ${diff.unchanged} unchanged`);
|
||||
lines.push('');
|
||||
lines.push('Run diff_official_docs() for the full diff.');
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── diff_official_docs ──────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'diff_official_docs',
|
||||
'Compare the baseline and current official Anthropic Claude Code docs snapshots. Shows added, removed, and modified pages. No network call — reads local files only. Run init_official_docs() first, then refresh_official_docs() when you want to update the current snapshot.',
|
||||
{},
|
||||
{ readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
||||
async () => {
|
||||
const baseline = loadIndex('baseline');
|
||||
if (!baseline) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No baseline found. Run init_official_docs() first to create a baseline snapshot.',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const current = loadIndex('current');
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No current snapshot found. Run refresh_official_docs() to fetch the latest docs.',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fast path: identical content hashes
|
||||
if (baseline.contentHash === current.contentHash) {
|
||||
const ageDays = snapshotAgeDays(current);
|
||||
const staleWarn = ageDays > STALENESS_WARN_DAYS
|
||||
? `\n⚠️ Current snapshot is ${ageDays} days old — consider running refresh_official_docs()`
|
||||
: '';
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'# Official Docs Diff',
|
||||
'',
|
||||
`Baseline: ${formatDate(baseline.fetchedAt)} | Current: ${formatDate(current.fetchedAt)}`,
|
||||
'',
|
||||
`No changes detected. All ${baseline.sectionCount} sections unchanged.${staleWarn}`,
|
||||
'',
|
||||
'Run refresh_official_docs() to fetch latest docs and update current.',
|
||||
].join('\n'),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const diff = diffSnapshots(baseline, current);
|
||||
|
||||
// Enrich modified sections with firstChangedLine (load only modified slugs)
|
||||
if (diff.modified.length > 0) {
|
||||
const modifiedSlugs = diff.modified.map((m) => m.slug);
|
||||
const baselineContents = loadSections('baseline', modifiedSlugs);
|
||||
const currentContents = loadSections('current', modifiedSlugs);
|
||||
|
||||
for (const entry of diff.modified) {
|
||||
const baseContent = baselineContents[entry.slug];
|
||||
const currContent = currentContents[entry.slug];
|
||||
if (baseContent && currContent) {
|
||||
const line = findFirstChangedLine(baseContent, currContent);
|
||||
if (line) entry.firstChangedLine = line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'# Official Docs Diff',
|
||||
'',
|
||||
`Baseline: ${formatDate(baseline.fetchedAt)} | Current: ${formatDate(current.fetchedAt)}`,
|
||||
'',
|
||||
];
|
||||
|
||||
const ageDays = snapshotAgeDays(current);
|
||||
if (ageDays > STALENESS_WARN_DAYS) {
|
||||
lines.push(`⚠️ Current snapshot is ${ageDays} days old — consider running refresh_official_docs()`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.added.length > 0) {
|
||||
lines.push(`## Added (${diff.added.length})`);
|
||||
for (const s of diff.added) {
|
||||
lines.push(`+ ${s.slug} — "${s.title}"`);
|
||||
lines.push(` ${s.sourceUrl}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.removed.length > 0) {
|
||||
lines.push(`## Removed (${diff.removed.length})`);
|
||||
for (const s of diff.removed) {
|
||||
lines.push(`- ${s.slug} — "${s.title}"`);
|
||||
lines.push(` ${s.sourceUrl}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (diff.modified.length > 0) {
|
||||
lines.push(`## Modified (${diff.modified.length})`);
|
||||
for (const s of diff.modified) {
|
||||
const delta = s.lineDelta !== undefined
|
||||
? (s.lineDelta >= 0 ? `+${s.lineDelta}` : `${s.lineDelta}`)
|
||||
: '';
|
||||
const charDelta = s.charsAfter !== undefined && s.charsBefore !== undefined
|
||||
? (s.charsAfter - s.charsBefore >= 0
|
||||
? `+${s.charsAfter - s.charsBefore}`
|
||||
: `${s.charsAfter - s.charsBefore}`)
|
||||
: '';
|
||||
lines.push(`~ ${s.slug} (L${s.linesBefore} → L${s.linesAfter}, ${delta} lines, ${charDelta} chars)`);
|
||||
if (s.firstChangedLine) {
|
||||
lines.push(` First change: "${s.firstChangedLine}"`);
|
||||
}
|
||||
lines.push(` ${s.sourceUrl}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const total = diff.added.length + diff.removed.length + diff.modified.length;
|
||||
lines.push('---');
|
||||
lines.push(`${diff.added.length} added, ${diff.removed.length} removed, ${diff.modified.length} modified, ${diff.unchanged} unchanged (${total} total changes)`);
|
||||
lines.push('Run refresh_official_docs() to update current to latest.');
|
||||
|
||||
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||||
},
|
||||
);
|
||||
|
||||
// ── search_official_docs ────────────────────────────────────────────────
|
||||
server.tool(
|
||||
'search_official_docs',
|
||||
'Search the official Anthropic Claude Code documentation by keyword or topic. Uses the local current snapshot — no network call. Run init_official_docs() first.',
|
||||
{
|
||||
query: z.string().describe('Search term or topic (e.g. "hooks", "MCP authentication", "cost limits")'),
|
||||
limit: z.number().min(1).max(10).optional().default(5).describe('Max sections to return (default 5)'),
|
||||
},
|
||||
{ readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
||||
async ({ query, limit }) => {
|
||||
const current = loadIndex('current');
|
||||
if (!current) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'No snapshot found. Run init_official_docs() first to create a local cache of the official docs.',
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Score all sections against the query (index only — no content loaded yet)
|
||||
const scored = Object.values(current.sections)
|
||||
.map((sec) => ({ meta: sec, titleScore: sec.title.toLowerCase().includes(query.toLowerCase()) ? 3 : 0 }))
|
||||
.filter((item) => item.titleScore > 0); // title-only pass first for speed
|
||||
|
||||
// If fewer than limit title matches, also check content (load lazily)
|
||||
let finalSlugs: string[];
|
||||
if (scored.length >= (limit ?? 5)) {
|
||||
finalSlugs = scored
|
||||
.sort((a, b) => b.titleScore - a.titleScore)
|
||||
.slice(0, limit ?? 5)
|
||||
.map((item) => item.meta.slug);
|
||||
} else {
|
||||
// Load all content and score body matches too
|
||||
const allSlugs = Object.keys(current.sections);
|
||||
const allContents = loadSections('current', allSlugs);
|
||||
const fullScored = Object.values(current.sections)
|
||||
.map((sec) => ({
|
||||
meta: sec,
|
||||
score: scoreSection(sec.title, allContents[sec.slug] ?? '', query),
|
||||
}))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit ?? 5);
|
||||
finalSlugs = fullScored.map((item) => item.meta.slug);
|
||||
}
|
||||
|
||||
if (finalSlugs.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `No results for "${query}" in ${current.sectionCount} sections (snapshot: ${formatDate(current.fetchedAt)}).\n\nTry broader terms or run refresh_official_docs() to update the cache.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Load only the matching sections
|
||||
const matchingContents = loadSections('current', finalSlugs);
|
||||
|
||||
const lines: string[] = [
|
||||
`# Search: "${query}" — ${finalSlugs.length} result${finalSlugs.length !== 1 ? 's' : ''} (snapshot: ${formatDate(current.fetchedAt)})`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const slug of finalSlugs) {
|
||||
const meta = current.sections[slug];
|
||||
const content = matchingContents[slug] ?? '';
|
||||
const excerpt = extractExcerpt(content, query);
|
||||
lines.push(`## ${meta.title}`);
|
||||
lines.push(meta.sourceUrl);
|
||||
lines.push('');
|
||||
lines.push(excerpt);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
lines.push(`Showing ${finalSlugs.length} of ${current.sectionCount} sections. Run refresh_official_docs() to update the cache.`);
|
||||
|
||||
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue