From 5b713db6fd108f9692b938d52e19d078ff60e532 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Wed, 11 Mar 2026 17:28:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20add=20official=20Anthropic=20docs?= =?UTF-8?q?=20tracker=20=E2=80=94=20v1.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mcp-server/package-lock.json | 4 +- mcp-server/package.json | 2 +- mcp-server/src/lib/docs-cache.ts | 221 ++++++++++++++ mcp-server/src/lib/docs-diff.ts | 105 +++++++ mcp-server/src/server.ts | 2 + mcp-server/src/tools/official-docs.ts | 419 ++++++++++++++++++++++++++ 6 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 mcp-server/src/lib/docs-cache.ts create mode 100644 mcp-server/src/lib/docs-diff.ts create mode 100644 mcp-server/src/tools/official-docs.ts diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 823ba13..d9734f4 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-ultimate-guide-mcp", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-ultimate-guide-mcp", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.6.0", diff --git a/mcp-server/package.json b/mcp-server/package.json index fdf18b5..0a7b036 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-ultimate-guide-mcp", - "version": "1.0.4", + "version": "1.1.0", "description": "MCP server for the Claude Code Ultimate Guide — search, read, and explore 20K+ lines of documentation directly from Claude Code", "keywords": [ "mcp", diff --git a/mcp-server/src/lib/docs-cache.ts b/mcp-server/src/lib/docs-cache.ts new file mode 100644 index 0000000..a1bd74e --- /dev/null +++ b/mcp-server/src/lib/docs-cache.ts @@ -0,0 +1,221 @@ +import { createHash } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { resolve } from 'path'; + +const ANTHROPIC_DOCS_URL = 'https://code.claude.com/docs/llms-full.txt'; +// Stable path — NOT versioned, survives package upgrades +const SNAPSHOT_DIR = resolve(homedir(), '.cache', 'claude-code-guide'); + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface SectionMeta { + slug: string; + title: string; + sourceUrl: string; + hash: string; + lineCount: number; + charCount: number; +} + +export interface IndexFile { + schemaVersion: 1; + fetchedAt: string; + contentHash: string; + sectionCount: number; + sections: Record; +} + +export interface ContentFile { + schemaVersion: 1; + sections: Record; +} + +export type SnapshotType = 'baseline' | 'current'; + +// ── Paths ────────────────────────────────────────────────────────────────── + +export function getSnapshotDir(): string { + return SNAPSHOT_DIR; +} + +function indexPath(type: SnapshotType): string { + return resolve(SNAPSHOT_DIR, `anthropic-docs-${type}-index.json`); +} + +function contentPath(type: SnapshotType): string { + return resolve(SNAPSHOT_DIR, `anthropic-docs-${type}-content.json`); +} + +// ── Fetch ────────────────────────────────────────────────────────────────── + +export async function fetchOfficialDocs(): Promise { + const response = await fetch(ANTHROPIC_DOCS_URL, { + headers: { 'User-Agent': 'claude-code-ultimate-guide-mcp/1.1.0' }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} fetching ${ANTHROPIC_DOCS_URL}`); + } + + const text = await response.text(); + + if (text.length < 500_000) { + throw new Error( + `Response looks malformed (got ${(text.length / 1024).toFixed(0)}KB, expected >500KB). The URL format may have changed.`, + ); + } + + return text; +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +export function parseIntoSections(raw: string): { index: IndexFile; content: ContentFile } { + const lines = raw.split('\n'); + const sections: Record = {}; + const contents: Record = {}; + + // Tracking state for the current section being built + let currentSlug: string | null = null; + let currentTitle: string | null = null; + let currentSourceUrl: string | null = null; + let currentStartLine = 0; + + // Title candidate (# H1) waiting to be confirmed by a Source: line + let titleCandidate: string | null = null; + let titleCandidateLine = -1; + + const flushSection = (endLine: number) => { + if (!currentSlug || !currentTitle || !currentSourceUrl) return; + const sectionLines = lines.slice(currentStartLine, endLine); + const content = sectionLines.join('\n').trimEnd(); + if (content.length === 0) return; + const hash = createHash('sha256').update(content).digest('hex').slice(0, 16); + sections[currentSlug] = { + slug: currentSlug, + title: currentTitle, + sourceUrl: currentSourceUrl, + hash, + lineCount: sectionLines.length, + charCount: content.length, + }; + contents[currentSlug] = { content }; + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect H1 title (# Title, but not ##+ headings) + if (line.startsWith('# ') && !line.startsWith('## ')) { + titleCandidate = line; + titleCandidateLine = i; + continue; + } + + // Detect Source: URL immediately after H1 (line N+1 rule) + const sourceMatch = line.match( + /^Source:\s+(https:\/\/code\.claude\.com\/docs\/(?:en\/)?([a-zA-Z0-9-]+))\s*$/, + ); + if (sourceMatch && titleCandidate !== null && i === titleCandidateLine + 1) { + const sourceUrl = sourceMatch[1]; + const slug = sourceMatch[2]; + + // Flush the previous section up to the start of this new one + flushSection(titleCandidateLine); + + currentSlug = slug; + currentTitle = titleCandidate; + currentSourceUrl = sourceUrl; + currentStartLine = titleCandidateLine; + titleCandidate = null; + titleCandidateLine = -1; + continue; + } + + // Reset title candidate if not immediately followed by Source: + if (titleCandidate !== null && i > titleCandidateLine + 1) { + titleCandidate = null; + titleCandidateLine = -1; + } + } + + // Flush the last section + flushSection(lines.length); + + if (Object.keys(sections).length === 0) { + throw new Error( + 'Section parser found 0 sections — llms-full.txt format may have changed. ' + + 'Please open an issue at https://github.com/FlorianBruniaux/claude-code-ultimate-guide', + ); + } + + const contentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16); + + const index: IndexFile = { + schemaVersion: 1, + fetchedAt: new Date().toISOString(), + contentHash, + sectionCount: Object.keys(sections).length, + sections, + }; + + const content: ContentFile = { + schemaVersion: 1, + sections: contents, + }; + + return { index, content }; +} + +// ── Save / Load ──────────────────────────────────────────────────────────── + +export function saveSnapshot(type: SnapshotType, index: IndexFile, content: ContentFile): void { + mkdirSync(SNAPSHOT_DIR, { recursive: true }); + + // Atomic write: write to .tmp then rename to final path + const iPath = indexPath(type); + writeFileSync(`${iPath}.tmp`, JSON.stringify(index, null, 2), 'utf8'); + renameSync(`${iPath}.tmp`, iPath); + + const cPath = contentPath(type); + writeFileSync(`${cPath}.tmp`, JSON.stringify(content, null, 2), 'utf8'); + renameSync(`${cPath}.tmp`, cPath); +} + +export function loadIndex(type: SnapshotType): IndexFile | null { + const path = indexPath(type); + if (!existsSync(path)) return null; + try { + const data = JSON.parse(readFileSync(path, 'utf8')) as IndexFile; + if (data.schemaVersion !== 1) return null; + return data; + } catch { + return null; + } +} + +/** Load only the specified sections from the content file — never loads everything into memory */ +export function loadSections( + type: SnapshotType, + slugs: string[], +): Record { + const path = contentPath(type); + if (!existsSync(path)) return {}; + try { + const data = JSON.parse(readFileSync(path, 'utf8')) as ContentFile; + const result: Record = {}; + for (const slug of slugs) { + const section = data.sections[slug]; + if (section) result[slug] = section.content; + } + return result; + } catch { + return {}; + } +} + +export function snapshotAgeDays(index: IndexFile): number { + return Math.floor((Date.now() - new Date(index.fetchedAt).getTime()) / (1000 * 60 * 60 * 24)); +} diff --git a/mcp-server/src/lib/docs-diff.ts b/mcp-server/src/lib/docs-diff.ts new file mode 100644 index 0000000..a32ad9b --- /dev/null +++ b/mcp-server/src/lib/docs-diff.ts @@ -0,0 +1,105 @@ +import type { IndexFile } from './docs-cache.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface SectionDiff { + slug: string; + title: string; + sourceUrl: string; + kind: 'added' | 'removed' | 'modified'; + // Only for 'modified' + linesBefore?: number; + linesAfter?: number; + charsBefore?: number; + charsAfter?: number; + lineDelta?: number; + firstChangedLine?: string; +} + +export interface DiffResult { + unchanged: number; + added: SectionDiff[]; + removed: SectionDiff[]; + modified: SectionDiff[]; + baselineDate: string; + currentDate: string; +} + +// ── Diff engine (pure — no I/O, no network) ──────────────────────────────── + +export function diffSnapshots(baseline: IndexFile, current: IndexFile): DiffResult { + const baselineSlugs = new Set(Object.keys(baseline.sections)); + const currentSlugs = new Set(Object.keys(current.sections)); + + const added: SectionDiff[] = []; + const removed: SectionDiff[] = []; + const modified: SectionDiff[] = []; + let unchanged = 0; + + for (const slug of currentSlugs) { + if (!baselineSlugs.has(slug)) { + const s = current.sections[slug]; + added.push({ slug, title: s.title, sourceUrl: s.sourceUrl, kind: 'added' }); + } + } + + for (const slug of baselineSlugs) { + if (!currentSlugs.has(slug)) { + const s = baseline.sections[slug]; + removed.push({ slug, title: s.title, sourceUrl: s.sourceUrl, kind: 'removed' }); + } + } + + for (const slug of baselineSlugs) { + if (!currentSlugs.has(slug)) continue; + const base = baseline.sections[slug]; + const curr = current.sections[slug]; + + if (base.hash === curr.hash) { + unchanged++; + continue; + } + + modified.push({ + slug, + title: curr.title, + sourceUrl: curr.sourceUrl, + kind: 'modified', + linesBefore: base.lineCount, + linesAfter: curr.lineCount, + charsBefore: base.charCount, + charsAfter: curr.charCount, + lineDelta: curr.lineCount - base.lineCount, + // firstChangedLine is enriched by the tool layer after loading content + }); + } + + return { + unchanged, + added, + removed, + modified, + baselineDate: baseline.fetchedAt, + currentDate: current.fetchedAt, + }; +} + +/** Find the first line that differs between two content strings. Returns truncated to 120 chars. */ +export function findFirstChangedLine(baseContent: string, currentContent: string): string { + const baseLines = baseContent.split('\n'); + const currLines = currentContent.split('\n'); + const len = Math.min(baseLines.length, currLines.length); + + for (let i = 0; i < len; i++) { + if (baseLines[i] !== currLines[i]) { + return currLines[i].slice(0, 120); + } + } + + // One side is longer — the first extra line is the change + if (currLines.length > baseLines.length) { + return (currLines[baseLines.length] ?? '').slice(0, 120); + } + + return ''; +} diff --git a/mcp-server/src/server.ts b/mcp-server/src/server.ts index 3a3b7a1..dc8769d 100644 --- a/mcp-server/src/server.ts +++ b/mcp-server/src/server.ts @@ -10,6 +10,7 @@ import { registerReleases } from './tools/releases.js'; import { registerCompareVersions } from './tools/compare-versions.js'; import { registerGetThreat, registerListThreats } from './tools/threats.js'; import { registerSearchExamples } from './tools/search-examples.js'; +import { registerOfficialDocs } from './tools/official-docs.js'; import { registerResources } from './resources/index.js'; import { registerPrompts } from './prompts/index.js'; import { loadReference, loadReleases, isDevMode } from './lib/content.js'; @@ -45,6 +46,7 @@ export function createServer(): McpServer { registerGetThreat(server); registerListThreats(server); registerSearchExamples(server); + registerOfficialDocs(server); // Register resources registerResources(server); diff --git a/mcp-server/src/tools/official-docs.ts b/mcp-server/src/tools/official-docs.ts new file mode 100644 index 0000000..00a67a4 --- /dev/null +++ b/mcp-server/src/tools/official-docs.ts @@ -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; + 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; + 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') }] }; + }, + ); +}