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
4
mcp-server/package-lock.json
generated
4
mcp-server/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
221
mcp-server/src/lib/docs-cache.ts
Normal file
221
mcp-server/src/lib/docs-cache.ts
Normal file
|
|
@ -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<string, SectionMeta>;
|
||||
}
|
||||
|
||||
export interface ContentFile {
|
||||
schemaVersion: 1;
|
||||
sections: Record<string, { content: string }>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<string, SectionMeta> = {};
|
||||
const contents: Record<string, { content: string }> = {};
|
||||
|
||||
// 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<string, string> {
|
||||
const path = contentPath(type);
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf8')) as ContentFile;
|
||||
const result: Record<string, string> = {};
|
||||
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));
|
||||
}
|
||||
105
mcp-server/src/lib/docs-diff.ts
Normal file
105
mcp-server/src/lib/docs-diff.ts
Normal file
|
|
@ -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 '';
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
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