From 87d8a56717ec44be555b6ee1d982259b115c6d13 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:38 -0700 Subject: [PATCH 1/4] feat(mcp): GITHUB_TOKEN pass-through + hard timeout on the Anthropic call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITHUB_TOKEN (mcp/github-auth.js → ghOpts) is threaded as an optional opts.githubToken into fetcher.js (fetchGitHub) and deepdive.js (fetchSource/ghJson). With no token the path is byte-for-byte the old anonymous behavior, so the extension is untouched; with one, GitHub's 60 req/hr limit becomes 5000/hr — needed for blueprint_scene/deep_dive, which make 10+ GitHub calls per run. anthropic.js now wraps the fetch in an AbortController timeout (ANTHROPIC_TIMEOUT_MS, default 60s) so a stalled connection can't hang a tool call forever — mirroring the extension's per-provider timeout. --- deepdive.js | 16 ++++++++++------ fetcher.js | 24 ++++++++++++++++-------- mcp/anthropic.js | 43 ++++++++++++++++++++++++++++++------------- mcp/github-auth.js | 16 ++++++++++++++++ 4 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 mcp/github-auth.js diff --git a/deepdive.js b/deepdive.js index 69d9c32..75eb389 100644 --- a/deepdive.js +++ b/deepdive.js @@ -28,8 +28,12 @@ export function extractJsonObject(rawText) { // ─── Stage 0: fetch source (GitHub-first; others degrade to README only) ────── -async function ghJson(url) { - const r = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } }); +async function ghJson(url, opts = {}) { + const headers = { Accept: 'application/vnd.github+json' }; + // Optional auth (MCP only; the extension passes no token). Lifts the 60/hr + // anon GitHub limit that blueprint/deep-dive would otherwise trip mid-scan. + if (opts.githubToken) headers.Authorization = `Bearer ${opts.githubToken}`; + const r = await fetch(url, { headers }); if (!r.ok) throw new Error(`GitHub ${r.status} for ${url}`); return r.json(); } @@ -58,13 +62,13 @@ export function selectKeyFiles(paths) { * Returns { tree: string[], files: [{path, content}], degraded: boolean }. * Only GitHub fetches real source; other platforms return a degraded result. */ -export async function fetchSource(platform, repoId) { +export async function fetchSource(platform, repoId, opts = {}) { if (platform !== 'github') return { tree: [], files: [], degraded: true }; - const meta = await ghJson(`https://api.github.com/repos/${repoId}`); + const meta = await ghJson(`https://api.github.com/repos/${repoId}`, opts); const branch = meta.default_branch || 'main'; const treeRes = await ghJson( - `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1` + `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1`, opts ); const allPaths = (treeRes.tree || []).filter(e => e.type === 'blob').map(e => e.path); const tree = allPaths.slice(0, MAX_TREE_PATHS); @@ -73,7 +77,7 @@ export async function fetchSource(platform, repoId) { const files = []; for (const path of keyPaths) { try { - const data = await ghJson(`https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`); + const data = await ghJson(`https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`, opts); if (data.encoding === 'base64' && data.content) { const content = atob(data.content.replace(/\n/g, '')).slice(0, MAX_FILE_CHARS); files.push({ path, content }); diff --git a/fetcher.js b/fetcher.js index d3f9e85..78be1d5 100644 --- a/fetcher.js +++ b/fetcher.js @@ -1,11 +1,17 @@ -async function fetchJson(url) { - const r = await fetch(url); +async function fetchJson(url, headers) { + const r = await fetch(url, headers ? { headers } : undefined); if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`); return r.json(); } -export async function fetchRepoData(platform, repoId) { - if (platform === 'github') return fetchGitHub(repoId); +// Authorization header for GitHub when an MCP caller supplies a token; null +// otherwise, so the anonymous path stays byte-for-byte identical (extension use). +function ghHeaders(opts) { + return opts && opts.githubToken ? { Authorization: `Bearer ${opts.githubToken}` } : null; +} + +export async function fetchRepoData(platform, repoId, opts = {}) { + if (platform === 'github') return fetchGitHub(repoId, opts); if (platform === 'gitlab') return fetchGitLab(repoId); if (platform === 'npm') return fetchNpm(repoId); if (platform === 'pypi') return fetchPyPI(repoId); @@ -22,11 +28,13 @@ function bytesToComposition(langs) { .map(([name, bytes]) => ({ name, pct: Math.round((bytes / total) * 100) })); } -async function fetchGitHub(repoId) { +async function fetchGitHub(repoId, opts = {}) { + const headers = ghHeaders(opts); + const init = headers ? { headers } : undefined; const [meta, readmeRes, langRes] = await Promise.all([ - fetchJson(`https://api.github.com/repos/${repoId}`), - fetch(`https://api.github.com/repos/${repoId}/readme`).catch(() => ({ ok: false })), - fetch(`https://api.github.com/repos/${repoId}/languages`).catch(() => ({ ok: false })), + fetchJson(`https://api.github.com/repos/${repoId}`, headers), + fetch(`https://api.github.com/repos/${repoId}/readme`, init).catch(() => ({ ok: false })), + fetch(`https://api.github.com/repos/${repoId}/languages`, init).catch(() => ({ ok: false })), ]); let readme = ''; if (readmeRes.ok) { diff --git a/mcp/anthropic.js b/mcp/anthropic.js index 608bbb7..478152e 100644 --- a/mcp/anthropic.js +++ b/mcp/anthropic.js @@ -5,6 +5,7 @@ const ENDPOINT = 'https://api.anthropic.com/v1/messages'; const DEFAULT_MODEL = 'claude-sonnet-4-6'; +const DEFAULT_TIMEOUT_MS = 60_000; /** * @param {string} prompt - the fully-assembled analysis prompt. @@ -14,20 +15,36 @@ export async function callAnthropic(prompt) { const key = process.env.ANTHROPIC_API_KEY; if (!key) throw new Error('ANTHROPIC_API_KEY is not set in the environment'); const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL; + const timeoutMs = Number(process.env.ANTHROPIC_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS; - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'anthropic-version': '2023-06-01', - 'x-api-key': key, - }, - body: JSON.stringify({ - model, - max_tokens: 4096, - messages: [{ role: 'user', content: prompt }], - }), - }); + // Hard timeout so a stalled connection can't hang the tool call forever + // (mirrors the extension's per-provider timeout in background.js). + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res; + try { + res = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': key, + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }), + signal: controller.signal, + }); + } catch (err) { + if (err && err.name === 'AbortError') { + throw new Error(`Anthropic request timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } if (!res.ok) { const detail = await res.text().catch(() => ''); diff --git a/mcp/github-auth.js b/mcp/github-auth.js new file mode 100644 index 0000000..5b37edb --- /dev/null +++ b/mcp/github-auth.js @@ -0,0 +1,16 @@ +// Optional GitHub auth for MCP fetches. +// +// A GITHUB_TOKEN in the environment lifts GitHub's 60 req/hr unauthenticated +// limit to 5000/hr — important for blueprint_scene and deep_dive, which make +// many GitHub calls per run (1 tree + up to 8 contents, plus repo meta). With no +// token set, this returns {} and every fetch stays anonymous — identical to the +// extension, which never sends an Authorization header. +// +// The shape ({ githubToken }) is what fetcher.js and deepdive.js expect as their +// optional trailing `opts` argument. + +/** @returns {{ githubToken?: string }} auth opts for the fetch helpers. */ +export function ghOpts() { + const token = process.env.GITHUB_TOKEN; + return token ? { githubToken: token } : {}; +} From e16213112470a2e521f2c8a58caf51c2282b6f13 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:38 -0700 Subject: [PATCH 2/4] fix(mcp): derive fit in scan_repo + align blueprint_scene outputSchema scan_repo declared `fit` as a required output but never produced it: parseClaudeResponse has no fit field (the extension derives it separately via verdict.js deriveFit), so the headline verdict was always undefined. Now buildScanResult (pure, testable) calls deriveFit and the schema declares fit as the real {level,label,why} object; bottom_line added too. blueprint_scene's outputSchema advertised edges as {source,target,label} and flat nodes, but buildBlueprintScene returns engine-shaped edges {id,from,to,rel,note,userDrawn} and nodes carrying ref/layer/pinned. Any client trusting the declared schema would mis-handle the payload. Schema now matches the real scene. Both runners thread ghOpts() for the GITHUB_TOKEN pass-through. --- mcp/blueprint-scene.js | 41 +++++++++++++++++++++++++++++++++-------- mcp/scan-repo.js | 33 ++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/mcp/blueprint-scene.js b/mcp/blueprint-scene.js index 04e0faa..50e06f6 100644 --- a/mcp/blueprint-scene.js +++ b/mcp/blueprint-scene.js @@ -12,6 +12,7 @@ import { fetchSource, buildAtomsPrompt, parseAtoms, buildLineagePrompt, parseLin import { buildBlueprintScene } from '../blueprint-adapter.js'; import { parseRepoInput } from './repo-input.js'; import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; export const BLUEPRINT_TOOL = { name: 'blueprint_scene', @@ -25,6 +26,8 @@ export const BLUEPRINT_TOOL = { required: ['repo'], additionalProperties: false, }, + // Mirrors the real scene object returned by buildBlueprintScene (scene.js + + // repair-graph.js): engine-shaped nodes/edges, not a {source,target} graph. outputSchema: { type: 'object', properties: { @@ -39,10 +42,22 @@ export const BLUEPRINT_TOOL = { properties: { id: { type: 'string' }, label: { type: 'string' }, - kind: { type: 'string' }, + kind: { type: 'string', description: 'subsystem|module|concept|entrypoint|data' }, + layer: { type: ['string', 'null'] }, x: { type: 'number' }, y: { type: 'number' }, + pinned: { type: 'boolean' }, + ref: { + type: 'object', + description: 'root = lineage root (load-bearing); plus purpose + files', + properties: { + root: { type: 'boolean' }, + purpose: { type: ['string', 'null'] }, + files: { type: 'array', items: { type: 'string' } }, + }, + }, }, + required: ['id', 'label', 'kind', 'x', 'y'], }, }, edges: { @@ -50,22 +65,32 @@ export const BLUEPRINT_TOOL = { items: { type: 'object', properties: { - source: { type: 'string' }, - target: { type: 'string' }, - label: { type: 'string' }, + id: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + rel: { type: 'string', description: 'depends-on|enables|triggers|derives-from' }, + note: { type: ['string', 'null'] }, + userDrawn: { type: 'boolean' }, }, + required: ['id', 'from', 'to', 'rel'], }, }, - camera: { type: 'object' }, + annotations: { type: 'array' }, + camera: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' }, zoom: { type: 'number' } }, + }, + source: { type: 'object', description: 'lens + timestamps' }, }, - required: ['nodes', 'edges'], + required: ['id', 'nodes', 'edges'], }, }; export async function runBlueprintScene(args) { const { platform, repoId } = parseRepoInput(args?.repo); - const repoData = await fetchRepoData(platform, repoId); - const source = await fetchSource(platform, repoId); + const opts = ghOpts(); + const repoData = await fetchRepoData(platform, repoId, opts); + const source = await fetchSource(platform, repoId, opts); const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null))); const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms))); return buildBlueprintScene({ deepDive: { atoms, lineage }, repoId, title: repoId }); diff --git a/mcp/scan-repo.js b/mcp/scan-repo.js index 15f2d3f..881dee1 100644 --- a/mcp/scan-repo.js +++ b/mcp/scan-repo.js @@ -4,8 +4,10 @@ import { fetchRepoData } from '../fetcher.js'; import { buildPrompt } from '../prompt.js'; import { parseClaudeResponse } from '../parser.js'; +import { deriveFit } from '../verdict.js'; import { parseRepoInput } from './repo-input.js'; import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; export const SCAN_TOOL = { name: 'scan_repo', @@ -28,7 +30,17 @@ export const SCAN_TOOL = { license: { type: 'string' }, stars: { type: 'number' }, description: { type: 'string' }, - fit: { type: 'string', description: 'overall fit verdict' }, + fit: { + type: 'object', + description: 'Overall fit verdict, derived from health score, red flags, and pros/cons.', + properties: { + level: { type: 'string', enum: ['strong', 'solid', 'care', 'risky'] }, + label: { type: 'string' }, + why: { type: 'string' }, + }, + required: ['level', 'label'], + }, + bottom_line: { type: 'string', description: 'One-line takeaway.' }, health: { type: 'object', description: 'health score + signals' }, pros: { type: 'array', items: { type: 'string' } }, cons: { type: 'array', items: { type: 'string' } }, @@ -39,10 +51,13 @@ export const SCAN_TOOL = { }, }; -export async function runScanRepo(args) { - const { platform, repoId } = parseRepoInput(args?.repo); - const repoData = await fetchRepoData(platform, repoId); - const analysis = parseClaudeResponse(await callAnthropic(buildPrompt(repoData))); +/** + * Assemble the tool result from fetched repo data + parsed analysis. Pure (no + * network/model) so the fit derivation is unit-testable. `fit` is derived here + * because parseClaudeResponse does not produce it — the extension computes it + * separately via deriveFit at render time. + */ +export function buildScanResult(platform, repoData, analysis) { return { repoId: repoData.repoId, platform, @@ -51,5 +66,13 @@ export async function runScanRepo(args) { stars: repoData.stars, description: repoData.description, ...analysis, + fit: deriveFit(analysis), }; } + +export async function runScanRepo(args) { + const { platform, repoId } = parseRepoInput(args?.repo); + const repoData = await fetchRepoData(platform, repoId, ghOpts()); + const analysis = parseClaudeResponse(await callAnthropic(buildPrompt(repoData))); + return buildScanResult(platform, repoData, analysis); +} From 1d1e7a18a9ef45df541941da8ad953da7b5c89bc Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:38 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(mcp):=20add=20deep=5Fdive=20tool=20(at?= =?UTF-8?q?oms=20=E2=86=92=20lineage=20=E2=86=92=20Feynman)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third tool: plain-English explanation of how a repo works, with gaps, assumptions, self-test questions, and per-claim confidence, plus the underlying atoms + lineage. Reuses deepdive.js verbatim (same module the extension's Deep Dive uses); three sequential model calls, so it is the heaviest tool. Registered in the server's multi-tool dispatcher. --- mcp/deep-dive.js | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ mcp/server.js | 3 ++ 2 files changed, 95 insertions(+) create mode 100644 mcp/deep-dive.js diff --git a/mcp/deep-dive.js b/mcp/deep-dive.js new file mode 100644 index 0000000..d88712d --- /dev/null +++ b/mcp/deep-dive.js @@ -0,0 +1,92 @@ +// deep_dive tool: RepoLens's plain-English Deep Dive — explain how a repo really +// works, with the gaps and confidence made explicit. +// +// Pipeline (extension modules, verbatim): fetchRepoData + fetchSource -> +// atoms prompt/parse -> lineage prompt/parse -> Feynman prompt/parse. THREE +// model calls (atoms, lineage, Feynman), so this is the heaviest tool. `facts` +// is null — the extension's local "runner" (measured metrics) isn't part of a +// headless MCP, and factsBlock('') degrades cleanly. + +import { fetchRepoData } from '../fetcher.js'; +import { + fetchSource, + buildAtomsPrompt, parseAtoms, + buildLineagePrompt, parseLineage, + buildFeynmanPrompt, parseFeynman, +} from '../deepdive.js'; +import { parseRepoInput } from './repo-input.js'; +import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; + +export const DEEP_DIVE_TOOL = { + name: 'deep_dive', + description: + "Explain how a GitHub repo actually works, in plain language, with its weak spots named. " + + 'Returns a from-scratch explanation, the gaps/assumptions behind it, self-test questions, ' + + 'per-claim confidence, and the underlying atoms + causal lineage. Use this when the user ' + + 'wants to *understand* a codebase, not just judge it. Heaviest tool (reads source, three model calls).', + inputSchema: { + type: 'object', + properties: { repo: { type: 'string', description: 'A repo as owner/name or a GitHub URL' } }, + required: ['repo'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + repoId: { type: 'string' }, + degraded: { type: 'boolean', description: 'true when no source tree was available (README-only read)' }, + explanation: { type: 'string', description: 'Plain-language explanation from scratch.' }, + gaps: { type: 'array', items: { type: 'string' }, description: 'Where the explanation is weakest.' }, + assumptions: { type: 'array', items: { type: 'string' }, description: 'Inferred, not directly verified.' }, + questions: { + type: 'array', + items: { + type: 'object', + properties: { q: { type: 'string' }, a: { type: 'string' } }, + }, + description: 'Self-test questions with answers.', + }, + confidence: { + type: 'array', + items: { + type: 'object', + properties: { + claim: { type: 'string' }, + level: { type: 'string', enum: ['high', 'medium', 'low'] }, + note: { type: 'string' }, + }, + }, + }, + atoms: { type: 'array', description: 'The atomic units the explanation is built from.' }, + lineage: { type: 'object', description: 'Causal links + roots/leaves between atoms.' }, + }, + required: ['repoId', 'explanation'], + }, +}; + +/** Pure assembly of the tool result. Network/model-free so it is unit-testable. */ +export function buildDeepDiveResult(repoId, atoms, lineage, feynman, source) { + return { + repoId, + degraded: !!(source && source.degraded), + explanation: feynman.explanation, + gaps: feynman.gaps, + assumptions: feynman.assumptions, + questions: feynman.questions, + confidence: feynman.confidence, + atoms, + lineage, + }; +} + +export async function runDeepDive(args) { + const { platform, repoId } = parseRepoInput(args?.repo); + const opts = ghOpts(); + const repoData = await fetchRepoData(platform, repoId, opts); + const source = await fetchSource(platform, repoId, opts); + const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null))); + const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms))); + const feynman = parseFeynman(await callAnthropic(buildFeynmanPrompt(repoData, atoms, lineage))); + return buildDeepDiveResult(repoId, atoms, lineage, feynman, source); +} diff --git a/mcp/server.js b/mcp/server.js index a48a1ac..dbb182c 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -6,6 +6,7 @@ // Tools: // scan_repo — verdict-first analysis (fit/health/pros/cons/flags) // blueprint_scene — laid-out nodes/edges graph of how the repo is built +// deep_dive — plain-English explanation + gaps + confidence (heaviest) import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -13,10 +14,12 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot import { SCAN_TOOL, runScanRepo } from './scan-repo.js'; import { BLUEPRINT_TOOL, runBlueprintScene } from './blueprint-scene.js'; +import { DEEP_DIVE_TOOL, runDeepDive } from './deep-dive.js'; const TOOLS = { [SCAN_TOOL.name]: { def: SCAN_TOOL, run: runScanRepo }, [BLUEPRINT_TOOL.name]: { def: BLUEPRINT_TOOL, run: runBlueprintScene }, + [DEEP_DIVE_TOOL.name]: { def: DEEP_DIVE_TOOL, run: runDeepDive }, }; const server = new Server( From c65158b4a0ff379e382f979a4c2d6376d1ff8407 Mon Sep 17 00:00:00 2001 From: ares <285551516+New1Direction@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:38 -0700 Subject: [PATCH 4/4] test(mcp): offline coverage for v2 tools + update README Adds 8 offline tests (no key, no network) covering the previously untested runners: ghOpts env plumbing, buildScanResult fit derivation, buildDeepDiveResult assembly, and full runScanRepo/runDeepDive via a mocked global.fetch (asserting the GITHUB_TOKEN Authorization header and deep_dive's three model calls). README documents the three tools, the real output shapes, GITHUB_TOKEN, and the timeout knob. --- mcp/README.md | 73 +++++++++++++++++++++++++--------- tests/mcp-deep-dive.test.js | 61 ++++++++++++++++++++++++++++ tests/mcp-github-auth.test.js | 20 ++++++++++ tests/mcp-scan-repo.test.js | 75 +++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 18 deletions(-) create mode 100644 tests/mcp-deep-dive.test.js create mode 100644 tests/mcp-github-auth.test.js create mode 100644 tests/mcp-scan-repo.test.js diff --git a/mcp/README.md b/mcp/README.md index 1239027..f8b0e70 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,12 +1,13 @@ # RepoLens MCP server A local [MCP](https://modelcontextprotocol.io) server that exposes RepoLens's repo -analysis as a tool. An LLM client (Claude Desktop, Cursor, etc.) calls `scan_repo` -and gets RepoLens's verdict-first JSON back — ready to render as components. +analysis as tools. An LLM client (Claude Desktop, Cursor, etc.) calls a tool and +gets RepoLens's JSON back — ready to render as components. -This is a **thin proof**: one tool, GitHub-only, Anthropic-only. It reuses the -extension's own pipeline (`fetcher.js` → `prompt.js` → `parser.js`); only the -provider call is MCP-specific. +GitHub-only, Anthropic-only, **three tools** (`scan_repo`, `blueprint_scene`, +`deep_dive`). Each reuses the extension's own pipeline modules verbatim +(`fetcher.js`, `prompt.js`, `parser.js`, `deepdive.js`, `blueprint-adapter.js`); +only the provider call (`anthropic.js`) is MCP-specific. ## What stays true @@ -28,7 +29,8 @@ provider call is MCP-specific. "license": "MIT", "stars": 21000, "description": "Small, fast web framework for the edges.", - "fit": "strong", + "fit": { "level": "strong", "label": "Strong fit", "why": "Health 92 · 0 flags · 4 pros / 1 cons" }, + "bottom_line": "A lean, fast framework worth adopting for edge runtimes.", "health": { "score": 92 }, "pros": ["..."], "cons": ["..."], @@ -37,32 +39,65 @@ provider call is MCP-specific. } ``` +`fit` is derived deterministically from the health score, red-flag count, and +pros/cons balance — `level` is one of `strong | solid | care | risky`. + ### `blueprint_scene({ repo })` -Maps how the repo is built and returns a laid-out graph — `nodes` (key parts) and +Maps how the repo is built and returns a laid-out scene — `nodes` (key parts) and `edges` (how they relate), with positions — ready for a ``-style component. Heavier than `scan_repo`: it reads source and makes two model calls -(atoms, then lineage). +(atoms, then lineage). Edges are engine-shaped (`{ id, from, to, rel }`), not +`{ source, target }`. ```json { "id": "repo:...", "scope": "blueprint", "repoId": "honojs/hono", - "nodes": [{ "id": "app", "label": "Hono app", "kind": "entrypoint", "x": 120, "y": 40 }], - "edges": [{ "source": "app", "target": "router", "label": "depends-on" }], + "nodes": [{ "id": "app", "label": "Hono app", "kind": "entrypoint", "x": 120, "y": 40, + "layer": "entrypoint", "ref": { "root": true, "purpose": "...", "files": ["src/hono.ts"] } }], + "edges": [{ "id": "e123", "from": "app", "to": "router", "rel": "depends-on" }], "camera": { "x": 0, "y": 0, "zoom": 1 } } ``` +### `deep_dive({ repo })` + +Explains how the repo actually works in plain language, with the weak spots named. +Returns a from-scratch `explanation`, the `gaps` and `assumptions` behind it, +self-test `questions`, per-claim `confidence`, plus the underlying `atoms` and +`lineage`. **Heaviest tool** — reads source and makes three model calls +(atoms → lineage → Feynman). + +```json +{ + "repoId": "honojs/hono", + "degraded": false, + "explanation": "Hono is a small web framework that ...", + "gaps": ["..."], + "assumptions": ["..."], + "questions": [{ "q": "What runs a request?", "a": "..." }], + "confidence": [{ "claim": "...", "level": "high", "note": "..." }], + "atoms": [{ "id": "router", "name": "Router", "kind": "subsystem", "purpose": "..." }], + "lineage": { "links": [{ "from": "app", "to": "router", "relation": "depends-on" }], "roots": ["app"], "leaves": [] } +} +``` + ## Setup ```bash cd mcp npm install -export ANTHROPIC_API_KEY=sk-ant-... # required -export ANTHROPIC_MODEL=claude-sonnet-4-6 # optional override -node server.js # speaks MCP over stdio +export ANTHROPIC_API_KEY=sk-ant-... # required +export ANTHROPIC_MODEL=claude-sonnet-4-6 # optional override +export ANTHROPIC_TIMEOUT_MS=60000 # optional; hard per-call timeout (default 60s) +export GITHUB_TOKEN=ghp_... # optional; lifts GitHub 60/hr → 5000/hr +node server.js # speaks MCP over stdio ``` +A `GITHUB_TOKEN` is **strongly recommended** for `blueprint_scene` and `deep_dive`: +each makes 10+ GitHub calls per run and will hit the 60 req/hr anonymous limit +(surfacing as a mid-scan `GitHub 403`) without one. + ### Add to Claude Desktop In `claude_desktop_config.json`: @@ -73,7 +108,7 @@ In `claude_desktop_config.json`: "repolens": { "command": "node", "args": ["/absolute/path/to/repolens/mcp/server.js"], - "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } + "env": { "ANTHROPIC_API_KEY": "sk-ant-...", "GITHUB_TOKEN": "ghp_..." } } } } @@ -81,7 +116,9 @@ In `claude_desktop_config.json`: ## Notes -- Unauthenticated GitHub requests are rate-limited. For heavier use, a `GITHUB_TOKEN` - pass-through is a follow-up. -- Next (follow-ups): `deep_dive` (the plain-English layer), multi-provider, and - npm / PyPI / GitLab support; a `GITHUB_TOKEN` pass-through for higher rate limits. +- `deep_dive` and `blueprint_scene` are GitHub-deep but README-shallow elsewhere: + only GitHub exposes a file tree, so on other platforms they degrade (`deep_dive` + sets `degraded: true`). +- Next (follow-ups): multi-provider (reuse the extension's `providers.js` registry), + npm / PyPI / GitLab inputs for `scan_repo` (the fetcher already supports them — + only the input parser is GitHub-only), and a `tools/list` structural smoke test. diff --git a/tests/mcp-deep-dive.test.js b/tests/mcp-deep-dive.test.js new file mode 100644 index 0000000..153123c --- /dev/null +++ b/tests/mcp-deep-dive.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildDeepDiveResult, runDeepDive } from '../mcp/deep-dive.js'; + +describe('buildDeepDiveResult', () => { + it('assembles the Feynman layer + atoms/lineage and flags degraded', () => { + const feynman = { + explanation: 'It works.', + gaps: ['g'], + assumptions: ['a'], + questions: [{ q: '?', a: '!' }], + confidence: [{ claim: 'c', level: 'high', note: 'n' }], + }; + const out = buildDeepDiveResult('a/b', [{ id: 'x' }], { links: [], roots: [], leaves: [] }, feynman, { degraded: true }); + expect(out.repoId).toBe('a/b'); + expect(out.explanation).toBe('It works.'); + expect(out.degraded).toBe(true); + expect(out.atoms).toEqual([{ id: 'x' }]); + expect(out.questions).toEqual([{ q: '?', a: '!' }]); + }); +}); + +describe('runDeepDive (offline, mocked GitHub + Anthropic)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + process.env.ANTHROPIC_API_KEY = 'sk-test'; + }); + afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + }); + + it('makes three sequential model calls (atoms → lineage → Feynman)', async () => { + const atomsJson = JSON.stringify({ atoms: [{ id: 'core', name: 'Core', kind: 'subsystem', purpose: 'p', files: ['package.json'] }] }); + const lineageJson = JSON.stringify({ links: [], roots: ['core'], leaves: [] }); + const feynmanJson = JSON.stringify({ explanation: 'Plain.', gaps: [], assumptions: [], questions: [], confidence: [] }); + const anthropicQueue = [atomsJson, lineageJson, feynmanJson]; + + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) { + return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: anthropicQueue.shift() }] }) }); + } + if (u.includes('/git/trees/')) return Promise.resolve({ ok: true, json: async () => ({ tree: [{ type: 'blob', path: 'package.json' }] }) }); + if (u.includes('/contents/')) return Promise.resolve({ ok: true, json: async () => ({ encoding: 'base64', content: btoa('{"name":"x"}') }) }); + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + if (/\/repos\/[^/]+\/[^/]+$/.test(u)) { + return Promise.resolve({ ok: true, json: async () => ({ description: 'x', stargazers_count: 0, language: 'JavaScript', license: { spdx_id: 'MIT' }, default_branch: 'main' }) }); + } + return Promise.resolve({ ok: false }); + }); + + const out = await runDeepDive({ repo: 'honojs/hono' }); + expect(out.repoId).toBe('honojs/hono'); + expect(out.explanation).toBe('Plain.'); + expect(out.atoms).toHaveLength(1); + expect(out.lineage.roots).toEqual(['core']); + expect(out.degraded).toBe(false); + + const anthropicCalls = global.fetch.mock.calls.filter((c) => String(c[0]).includes('api.anthropic.com')); + expect(anthropicCalls).toHaveLength(3); + }); +}); diff --git a/tests/mcp-github-auth.test.js b/tests/mcp-github-auth.test.js new file mode 100644 index 0000000..353ee8b --- /dev/null +++ b/tests/mcp-github-auth.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ghOpts } from '../mcp/github-auth.js'; + +describe('ghOpts', () => { + const original = process.env.GITHUB_TOKEN; + afterEach(() => { + if (original === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = original; + }); + + it('returns empty opts when GITHUB_TOKEN is unset (anonymous, like the extension)', () => { + delete process.env.GITHUB_TOKEN; + expect(ghOpts()).toEqual({}); + }); + + it('returns the token when GITHUB_TOKEN is set', () => { + process.env.GITHUB_TOKEN = 'ghp_test123'; + expect(ghOpts()).toEqual({ githubToken: 'ghp_test123' }); + }); +}); diff --git a/tests/mcp-scan-repo.test.js b/tests/mcp-scan-repo.test.js new file mode 100644 index 0000000..a595d57 --- /dev/null +++ b/tests/mcp-scan-repo.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildScanResult, runScanRepo } from '../mcp/scan-repo.js'; + +describe('buildScanResult', () => { + const repoData = { repoId: 'a/b', language: 'JS', license: 'MIT', stars: 5, description: 'x' }; + + it('derives fit — the field parseClaudeResponse never produces', () => { + const analysis = { health: { score: 90 }, red_flags: [], pros: ['p1', 'p2'], cons: [], bottom_line: 'Good.' }; + const out = buildScanResult('github', repoData, analysis); + expect(out.repoId).toBe('a/b'); + expect(out.platform).toBe('github'); + expect(out.fit.level).toBe('strong'); + expect(out.fit.label).toBe('Strong fit'); + expect(out.fit.why).toContain('Health 90'); + expect(out.bottom_line).toBe('Good.'); // carried through from the spread analysis + }); + + it('reflects a weak repo as a risky fit', () => { + const analysis = { health: { score: 40 }, red_flags: [{ severity: 'risk' }], pros: [], cons: ['c'] }; + expect(buildScanResult('github', repoData, analysis).fit.level).toBe('risky'); + }); +}); + +describe('runScanRepo (offline, mocked GitHub + Anthropic)', () => { + const originalToken = process.env.GITHUB_TOKEN; + beforeEach(() => { + vi.restoreAllMocks(); + process.env.ANTHROPIC_API_KEY = 'sk-test'; + }); + afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + if (originalToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalToken; + }); + + it('runs end-to-end and threads GITHUB_TOKEN into the GitHub call', async () => { + process.env.GITHUB_TOKEN = 'ghp_abc'; + const scanJson = JSON.stringify({ health: { score: 80 }, red_flags: [], pros: ['a'], cons: [], bottom_line: 'Solid.' }); + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) { + return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: scanJson }] }) }); + } + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + if (u.endsWith('/repos/facebook/react')) { + return Promise.resolve({ ok: true, json: async () => ({ description: 'UI', stargazers_count: 1, language: 'JavaScript', license: { spdx_id: 'MIT' } }) }); + } + return Promise.resolve({ ok: false }); + }); + + const out = await runScanRepo({ repo: 'facebook/react' }); + expect(out.repoId).toBe('facebook/react'); + expect(out.fit.level).toBe('solid'); + + const ghCall = global.fetch.mock.calls.find((c) => String(c[0]).endsWith('/repos/facebook/react')); + expect(ghCall[1]?.headers?.Authorization).toBe('Bearer ghp_abc'); + const anthCall = global.fetch.mock.calls.find((c) => String(c[0]).includes('api.anthropic.com')); + expect(anthCall[1].headers['x-api-key']).toBe('sk-test'); + }); + + it('sends no Authorization header when GITHUB_TOKEN is unset', async () => { + delete process.env.GITHUB_TOKEN; + const scanJson = JSON.stringify({ health: { score: 60 }, red_flags: [], pros: [], cons: [] }); + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: scanJson }] }) }); + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + return Promise.resolve({ ok: true, json: async () => ({ description: '', stargazers_count: 0, language: 'JavaScript', license: { spdx_id: 'MIT' } }) }); + }); + await runScanRepo({ repo: 'x/y' }); + const ghCall = global.fetch.mock.calls.find((c) => String(c[0]).endsWith('/repos/x/y')); + // No init / no headers => anonymous, identical to extension behavior. + expect(ghCall[1]?.headers?.Authorization).toBeUndefined(); + }); +});