From 4a2bbe1bb4229bdeb7fa3104bae23ae9fe79b467 Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:53:03 -0700 Subject: [PATCH 1/6] feat(mcp): add goal-aware repo evaluation --- mcp/evaluate-for-goal.js | 106 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 mcp/evaluate-for-goal.js diff --git a/mcp/evaluate-for-goal.js b/mcp/evaluate-for-goal.js new file mode 100644 index 0000000..2ba2ac2 --- /dev/null +++ b/mcp/evaluate-for-goal.js @@ -0,0 +1,106 @@ +import { fetchRepoData } from '../src/fetcher.js'; +import { buildPrompt } from '../src/prompt.js'; +import { parseClaudeResponse } from '../src/parser.js'; +import { deriveFit } from '../src/verdict.js'; +import { parseRepoInput } from './repo-input.js'; +import { callModel } from './model.js'; +import { ghOpts } from './github-auth.js'; +import { attachHtmlReport } from './report.js'; + +export const EVALUATE_FOR_GOAL_TOOL = { + name: 'evaluate_for_goal', + description: + 'Evaluate a GitHub/GitLab/npm/PyPI repo against a concrete engineering goal and explicit constraints. ' + + 'Returns a decision, fit score, blockers, integration/replacement cost, dependency risk, evidence, and a short trial plan.', + inputSchema: { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name, platform:name, or GitHub/GitLab/npm/PyPI URL' }, + goal: { type: 'string', description: 'Concrete engineering goal this repo must satisfy.' }, + constraints: { type: 'array', items: { type: 'string' }, maxItems: 20 }, + report: { type: 'boolean', description: 'Write a local HTML report. Default: true.' }, + openReport: { type: 'boolean', description: 'Open the local HTML report. Default: true.' }, + }, + required: ['repo', 'goal'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + repoId: { type: 'string' }, + platform: { type: 'string' }, + goal: { type: 'string' }, + constraints: { type: 'array', items: { type: 'string' } }, + decision: { type: 'string', enum: ['adopt', 'trial', 'hold', 'reject'] }, + fit_score: { type: 'number' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + bottom_line: { type: 'string' }, + blockers: { type: 'array', items: { type: 'string' } }, + integration_cost: { type: 'string', enum: ['low', 'medium', 'high', 'unknown'] }, + replacement_cost: { type: 'string', enum: ['low', 'medium', 'high', 'unknown'] }, + dependency_risk: { type: 'string', enum: ['low', 'medium', 'high', 'unknown'] }, + evidence: { type: 'array' }, + trial_plan: { type: 'array', items: { type: 'string' } }, + base_scan: { type: 'object' }, + report: { type: 'object' }, + }, + required: ['repoId', 'goal', 'decision', 'fit_score', 'bottom_line'], + }, +}; + +const strings = (xs, max = 20) => + Array.isArray(xs) ? xs.map(String).map((s) => s.trim()).filter(Boolean).slice(0, max) : []; + +function extractJson(rawText) { + const text = String(rawText || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1) throw new Error('No JSON object found in goal evaluation response'); + return JSON.parse(text.slice(start, end + 1)); +} + +export function buildGoalPrompt(scan, goal, constraints = []) { + return `You are evaluating whether an experienced software engineer should adopt a repository for a specific goal.\n\nGoal:\n${goal}\n\nConstraints:\n${constraints.length ? constraints.map((c) => `- ${c}`).join('\n') : '- none provided'}\n\nRepoLens base scan evidence:\n${JSON.stringify(scan, null, 2)}\n\nTreat the base scan as evidence, not truth. Do not infer verification that is not present. Return ONLY valid JSON with this shape:\n{\n \"decision\": \"adopt | trial | hold | reject\",\n \"fit_score\": 0,\n \"confidence\": \"high | medium | low\",\n \"bottom_line\": \"One decisive sentence.\",\n \"blockers\": [\"Concrete blocker\"],\n \"integration_cost\": \"low | medium | high | unknown\",\n \"replacement_cost\": \"low | medium | high | unknown\",\n \"dependency_risk\": \"low | medium | high | unknown\",\n \"evidence\": [{\"claim\": \"Why it fits or fails\", \"source\": \"base_scan | metadata | readme | inferred\", \"verified\": false}],\n \"trial_plan\": [\"A concrete short test\"]\n}`; +} + +export function parseGoalResponse(rawText) { + const data = extractJson(rawText); + const allowedDecision = new Set(['adopt', 'trial', 'hold', 'reject']); + const allowedConfidence = new Set(['high', 'medium', 'low']); + const allowedCost = new Set(['low', 'medium', 'high', 'unknown']); + const cost = (v) => allowedCost.has(String(v)) ? String(v) : 'unknown'; + return { + decision: allowedDecision.has(String(data.decision)) ? String(data.decision) : 'trial', + fit_score: Math.max(0, Math.min(100, Number(data.fit_score) || 0)), + confidence: allowedConfidence.has(String(data.confidence)) ? String(data.confidence) : 'low', + bottom_line: String(data.bottom_line || ''), + blockers: strings(data.blockers, 10), + integration_cost: cost(data.integration_cost), + replacement_cost: cost(data.replacement_cost), + dependency_risk: cost(data.dependency_risk), + evidence: Array.isArray(data.evidence) ? data.evidence.slice(0, 12) : [], + trial_plan: strings(data.trial_plan, 8), + }; +} + +export async function runEvaluateForGoal(args) { + const goal = String(args?.goal || '').trim(); + if (!goal) throw new Error('evaluate_for_goal requires a non-empty goal'); + const constraints = strings(args?.constraints, 20); + const { platform, repoId } = parseRepoInput(args?.repo); + const repoData = await fetchRepoData(platform, repoId, ghOpts()); + const analysis = parseClaudeResponse(await callModel(buildPrompt(repoData))); + const baseScan = { + repoId: repoData.repoId, + platform, + language: repoData.language, + license: repoData.license, + stars: repoData.stars, + description: repoData.description, + ...analysis, + fit: deriveFit(analysis), + }; + const evaluation = parseGoalResponse(await callModel(buildGoalPrompt(baseScan, goal, constraints))); + const result = { repoId: repoData.repoId, platform, goal, constraints, ...evaluation, base_scan: baseScan }; + return attachHtmlReport('evaluate_for_goal', repoData.repoId, result, args); +} From fe4cc7888622d8fb3c6df2e32c7327c86098f678 Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:53:35 -0700 Subject: [PATCH 2/6] feat(mcp): add product URL analysis --- mcp/analyze-product.js | 146 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 mcp/analyze-product.js diff --git a/mcp/analyze-product.js b/mcp/analyze-product.js new file mode 100644 index 0000000..93df678 --- /dev/null +++ b/mcp/analyze-product.js @@ -0,0 +1,146 @@ +import { callModel } from './model.js'; +import { attachHtmlReport } from './report.js'; + +const MAX_HTML_BYTES = 750_000; +const MAX_TEXT_CHARS = 45_000; + +export const ANALYZE_PRODUCT_TOOL = { + name: 'analyze_product', + description: + 'Analyze a deployed software product from a public URL. Extracts product claims, core loops, dependencies, critical systems, failure modes, and a claim/evidence verification map. ' + + 'Use this when the thing to inspect is a live product or website rather than a repository.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Public http(s) product URL.' }, + goal: { type: 'string', description: 'Optional question or evaluation goal.' }, + report: { type: 'boolean', description: 'Write a local HTML report. Default: true.' }, + openReport: { type: 'boolean', description: 'Open the local HTML report. Default: true.' }, + }, + required: ['url'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + title: { type: 'string' }, + product_model: { type: 'string' }, + core_loop: { type: 'array', items: { type: 'string' } }, + dependencies: { type: 'array', items: { type: 'string' } }, + strengths: { type: 'array', items: { type: 'string' } }, + critical_systems: { type: 'array', items: { type: 'string' } }, + failure_modes: { type: 'array', items: { type: 'string' } }, + claims: { type: 'array' }, + verdict: { type: 'string' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + source_scope: { type: 'string' }, + report: { type: 'object' }, + }, + required: ['url', 'product_model', 'claims', 'verdict'], + }, +}; + +function cleanText(html) { + return String(html || '') + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(/]*>[\s\S]*?<\/noscript>/gi, ' ') + .replace(/]*>[\s\S]*?<\/svg>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_TEXT_CHARS); +} + +function extractTitle(html, fallback) { + const match = String(html || '').match(/]*>([\s\S]*?)<\/title>/i); + return cleanText(match?.[1] || fallback).slice(0, 200); +} + +function normalizeUrl(raw) { + let url; + try { + url = new URL(String(raw || '').trim()); + } catch { + throw new Error('analyze_product requires a valid URL'); + } + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('analyze_product only supports http(s) URLs'); + if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) throw new Error('analyze_product does not fetch localhost URLs'); + return url; +} + +async function fetchProductPage(rawUrl) { + const url = normalizeUrl(rawUrl); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20_000); + try { + const res = await fetch(url, { + redirect: 'follow', + signal: controller.signal, + headers: { 'user-agent': 'RepoLens-MCP/0.1 (+https://github.com/New1Direction/RepoLens)' }, + }); + if (!res.ok) throw new Error(`Product page HTTP ${res.status}`); + const type = res.headers.get('content-type') || ''; + if (!type.includes('text/html') && !type.includes('text/plain')) { + throw new Error(`Unsupported product content type: ${type || 'unknown'}`); + } + const declared = Number(res.headers.get('content-length') || 0); + if (declared > MAX_HTML_BYTES) throw new Error(`Product page exceeds ${MAX_HTML_BYTES} bytes`); + const html = (await res.text()).slice(0, MAX_HTML_BYTES); + return { finalUrl: res.url || url.href, title: extractTitle(html, url.hostname), text: cleanText(html) }; + } finally { + clearTimeout(timer); + } +} + +function extractJson(rawText) { + const text = String(rawText || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1) throw new Error('No JSON object found in product analysis response'); + return JSON.parse(text.slice(start, end + 1)); +} + +const strings = (xs, max = 12) => + Array.isArray(xs) ? xs.map(String).map((s) => s.trim()).filter(Boolean).slice(0, max) : []; + +export function buildProductPrompt(page, goal = '') { + return `You are RepoLens analyzing a deployed software product from its public product page.\n\nURL: ${page.finalUrl}\nTitle: ${page.title}\nEvaluation goal: ${goal || 'Understand how the product works and what must be verified before trusting it.'}\n\nVisible page text:\n${page.text}\n\nImportant rules:\n- Treat page statements as product claims, not verified implementation facts.\n- Never say code, contracts, accounting, security, or runtime behavior is verified unless the supplied page itself proves it.\n- Separate observed website evidence from inferred architecture.\n- Identify what source/code/contract/runtime evidence would be needed to verify important claims.\n\nReturn ONLY valid JSON:\n{\n \"product_model\": \"One concise explanation of how value/data/actions flow through the product.\",\n \"core_loop\": [\"Step 1\", \"Step 2\"],\n \"dependencies\": [\"External dependency or subsystem\"],\n \"strengths\": [\"Architectural/product strength visible from the page\"],\n \"critical_systems\": [\"Subsystem whose correctness matters\"],\n \"failure_modes\": [\"Concrete way the system could fail\"],\n \"claims\": [{\n \"claim\": \"Important product claim\",\n \"website_evidence\": \"Short paraphrase of what the page says\",\n \"verification_status\": \"website_only | partial | verified | contradicted | unknown\",\n \"needs\": [\"code\", \"contract\", \"runtime\", \"accounting\"],\n \"confidence\": \"high | medium | low\"\n }],\n \"verdict\": \"Decision-oriented conclusion focused on architecture and verification gaps.\",\n \"confidence\": \"high | medium | low\"\n}`; +} + +export function parseProductResponse(rawText) { + const data = extractJson(rawText); + const confidence = ['high', 'medium', 'low'].includes(String(data.confidence)) ? String(data.confidence) : 'low'; + return { + product_model: String(data.product_model || ''), + core_loop: strings(data.core_loop), + dependencies: strings(data.dependencies), + strengths: strings(data.strengths), + critical_systems: strings(data.critical_systems), + failure_modes: strings(data.failure_modes), + claims: Array.isArray(data.claims) ? data.claims.slice(0, 20) : [], + verdict: String(data.verdict || ''), + confidence, + }; +} + +export async function runAnalyzeProduct(args) { + const page = await fetchProductPage(args?.url); + const goal = String(args?.goal || '').trim(); + const analysis = parseProductResponse(await callModel(buildProductPrompt(page, goal))); + const result = { + url: page.finalUrl, + title: page.title, + goal, + ...analysis, + source_scope: 'Public product-page HTML only. Code, contracts, private APIs, and runtime behavior are unverified unless separately supplied.', + }; + return attachHtmlReport('analyze_product', page.title || page.finalUrl, result, args); +} From b9b514238f99219ac563cac2eb7ad65c07d8ed2b Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:53:54 -0700 Subject: [PATCH 3/6] feat(mcp): register goal and product analysis tools --- mcp/server.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/mcp/server.js b/mcp/server.js index 6466a02..40eccbe 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -1,13 +1,13 @@ #!/usr/bin/env node -// RepoLens MCP server. Exposes RepoLens's repo analysis as MCP tools over local -// stdio, bring-your-own Anthropic key. Each tool reuses the extension's own -// pipeline modules; the only MCP-specific piece is the env-key Anthropic call. +// RepoLens MCP server. Exposes RepoLens analysis tools over local stdio. // // 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) -// compare_repos — compare dependencies and open a visual bake-off report +// scan_repo — verdict-first repo analysis +// blueprint_scene — architecture graph +// deep_dive — plain-English source explanation +// compare_repos — dependency bake-off +// evaluate_for_goal — repo fit against a concrete goal + constraints +// analyze_product — deployed product/website claim + architecture analysis import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -17,15 +17,19 @@ 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'; import { COMPARE_TOOL, runCompareRepos } from './compare-repos.js'; +import { EVALUATE_FOR_GOAL_TOOL, runEvaluateForGoal } from './evaluate-for-goal.js'; +import { ANALYZE_PRODUCT_TOOL, runAnalyzeProduct } from './analyze-product.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 }, [COMPARE_TOOL.name]: { def: COMPARE_TOOL, run: runCompareRepos }, + [EVALUATE_FOR_GOAL_TOOL.name]: { def: EVALUATE_FOR_GOAL_TOOL, run: runEvaluateForGoal }, + [ANALYZE_PRODUCT_TOOL.name]: { def: ANALYZE_PRODUCT_TOOL, run: runAnalyzeProduct }, }; -const server = new Server({ name: 'repolens', version: '0.1.0' }, { capabilities: { tools: {} } }); +const server = new Server({ name: 'repolens', version: '0.2.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: Object.values(TOOLS).map((t) => t.def), @@ -42,7 +46,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { ? `\n\nOpened local RepoLens HTML report: ${result.report.url}` : ''; const summary = - result?.bottom_line || result?.explanation || result?.title || `${req.params.name} completed.`; + result?.bottom_line || + result?.verdict || + result?.explanation || + result?.product_model || + result?.title || + `${req.params.name} completed.`; return { content: [{ type: 'text', text: `${summary}${reportLine}` }], structuredContent: result, From 583efc430b59d5bfd8286cfc7db3647b05658228 Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:54:23 -0700 Subject: [PATCH 4/6] docs(mcp): document goal and product analysis tools --- mcp/README.md | 76 ++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 468d271..bf1fbc2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -14,6 +14,11 @@ posts. RepoLens gives the agent a dependency due-diligence tool: > “Should I use this repo, what are the risks, and what should I try first?” +It can also inspect a deployed product page without pretending that marketing +claims are implementation facts. Product analysis marks website-only evidence +explicitly and tells the agent what code, contract, runtime, or accounting +evidence would still be needed to verify important claims. + ## Tools - `scan_repo` — verdict-first report: fit, health, pros, cons, red flags, @@ -23,6 +28,13 @@ posts. RepoLens gives the agent a dependency due-diligence tool: - `blueprint_scene` — graph-shaped architecture map with nodes/edges/positions. - `compare_repos` — compare 2-5 repos/packages for a use case, pick a winner, and open a visual bake-off report. +- `evaluate_for_goal` — evaluate one repo against a concrete goal and explicit + constraints; returns adopt/trial/hold/reject, fit score, blockers, costs, + dependency risk, evidence, and a short trial plan. +- `analyze_product` — inspect a public product URL and return its product model, + core loop, dependencies, critical systems, failure modes, and a claim/evidence + verification map. Website claims remain unverified unless separate evidence is + supplied later. Single-repo tools accept: @@ -34,6 +46,34 @@ Single-repo tools accept: } ``` +`evaluate_for_goal` accepts: + +```json +{ + "repo": "honojs/hono", + "goal": "HTTP layer for a deterministic autonomous agent runtime", + "constraints": [ + "low dependency count", + "edge compatible", + "actively maintained", + "no mandatory cloud dependency" + ], + "report": true, + "openReport": true +} +``` + +`analyze_product` accepts: + +```json +{ + "url": "https://www.orbio.so/", + "goal": "Understand the fee-to-inference loop and identify what must be verified before trusting the accounting", + "report": true, + "openReport": true +} +``` + `compare_repos` accepts: ```json @@ -140,41 +180,9 @@ Before you add this dependency, run RepoLens scan_repo and open the report. ``` ```text -Generate a RepoLens deep_dive for github.com/fastify/fastify and summarize the gaps. -``` - -```text -Use blueprint_scene on remix-run/remix so I can see how the repo is structured. +Use RepoLens evaluate_for_goal on honojs/hono for a deterministic agent API runtime. ``` ```text -Compare honojs/hono vs fastify/fastify for an edge API and open the RepoLens report. +Analyze https://www.orbio.so/ as a product. Separate website claims from verified implementation facts and tell me what evidence is still needed. ``` - -## Supported inputs - -`scan_repo` supports all fetcher-backed RepoLens targets: - -```text -honojs/hono -https://github.com/honojs/hono -github:honojs/hono -gitlab:inkscape/inkscape -https://gitlab.com/inkscape/inkscape -npm:react -https://www.npmjs.com/package/@modelcontextprotocol/sdk -pypi:fastapi -https://pypi.org/project/fastapi/ -``` - -`deep_dive` and `blueprint_scene` accept the same inputs, but source-tree reads are -GitHub-deep today; non-GitHub targets degrade to README/metadata context. - -## Current scope - -- Local-only: no hosted backend, no RepoLens account. -- Provider support: Anthropic, OpenAI, OpenRouter, and Google via env keys. -- The Chrome extension still has the richest provider/platform UI; MCP is the - agent-native path. - -Planned next steps: publish `repolens-mcp` to npm and add a comparison tool. From 418830f65425d40e18b8d63141233dcd35207eff Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:55:16 -0700 Subject: [PATCH 5/6] fix(mcp): harden product URL fetching against private hosts --- mcp/analyze-product.js | 73 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/mcp/analyze-product.js b/mcp/analyze-product.js index 93df678..fdbd552 100644 --- a/mcp/analyze-product.js +++ b/mcp/analyze-product.js @@ -1,3 +1,6 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + import { callModel } from './model.js'; import { attachHtmlReport } from './report.js'; @@ -72,20 +75,80 @@ function normalizeUrl(raw) { throw new Error('analyze_product requires a valid URL'); } if (!['http:', 'https:'].includes(url.protocol)) throw new Error('analyze_product only supports http(s) URLs'); - if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) throw new Error('analyze_product does not fetch localhost URLs'); + if (url.username || url.password) throw new Error('analyze_product does not allow URLs with embedded credentials'); + return url; +} + +function isPrivateIpv4(ip) { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; + const [a, b] = parts; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + a >= 224 + ); +} + +function isPrivateIp(ip) { + const kind = isIP(ip); + if (kind === 4) return isPrivateIpv4(ip); + if (kind !== 6) return true; + const normalized = ip.toLowerCase(); + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe8') || + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') || + normalized.startsWith('::ffff:127.') || + normalized.startsWith('::ffff:10.') || + normalized.startsWith('::ffff:192.168.') + ); +} + +export async function assertPublicUrl(raw) { + const url = normalizeUrl(raw); + const host = url.hostname.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) { + throw new Error('analyze_product only fetches public hosts'); + } + if (isIP(host)) { + if (isPrivateIp(host)) throw new Error('analyze_product only fetches public hosts'); + return url; + } + const records = await lookup(host, { all: true, verbatim: true }); + if (!records.length || records.some((record) => isPrivateIp(record.address))) { + throw new Error('analyze_product only fetches public hosts'); + } return url; } async function fetchProductPage(rawUrl) { - const url = normalizeUrl(rawUrl); + const url = await assertPublicUrl(rawUrl); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 20_000); try { const res = await fetch(url, { - redirect: 'follow', + redirect: 'manual', signal: controller.signal, - headers: { 'user-agent': 'RepoLens-MCP/0.1 (+https://github.com/New1Direction/RepoLens)' }, + headers: { 'user-agent': 'RepoLens-MCP/0.2 (+https://github.com/New1Direction/RepoLens)' }, }); + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get('location'); + if (!location) throw new Error(`Product page redirect ${res.status} without Location header`); + const redirected = new URL(location, url); + await assertPublicUrl(redirected.href); + return fetchProductPage(redirected.href); + } if (!res.ok) throw new Error(`Product page HTTP ${res.status}`); const type = res.headers.get('content-type') || ''; if (!type.includes('text/html') && !type.includes('text/plain')) { @@ -94,7 +157,7 @@ async function fetchProductPage(rawUrl) { const declared = Number(res.headers.get('content-length') || 0); if (declared > MAX_HTML_BYTES) throw new Error(`Product page exceeds ${MAX_HTML_BYTES} bytes`); const html = (await res.text()).slice(0, MAX_HTML_BYTES); - return { finalUrl: res.url || url.href, title: extractTitle(html, url.hostname), text: cleanText(html) }; + return { finalUrl: url.href, title: extractTitle(html, url.hostname), text: cleanText(html) }; } finally { clearTimeout(timer); } From c7e1637b12826cdc4c92cb7b498a3c4ebaa10745 Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 2 Sep 2026 05:55:40 -0700 Subject: [PATCH 6/6] test(mcp): cover goal and product analysis helpers --- tests/mcp-goal-product.test.js | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/mcp-goal-product.test.js diff --git a/tests/mcp-goal-product.test.js b/tests/mcp-goal-product.test.js new file mode 100644 index 0000000..1613636 --- /dev/null +++ b/tests/mcp-goal-product.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { parseGoalResponse } from '../mcp/evaluate-for-goal.js'; +import { assertPublicUrl, parseProductResponse } from '../mcp/analyze-product.js'; + +describe('evaluate_for_goal parsing', () => { + it('normalizes a decision-grade response', () => { + const parsed = parseGoalResponse(JSON.stringify({ + decision: 'adopt', + fit_score: 91, + confidence: 'high', + bottom_line: 'Strong fit.', + blockers: ['One migration detail'], + integration_cost: 'low', + replacement_cost: 'medium', + dependency_risk: 'low', + evidence: [{ claim: 'Small API surface', source: 'base_scan', verified: false }], + trial_plan: ['Wire one endpoint', 'Run a failure test'], + })); + + expect(parsed.decision).toBe('adopt'); + expect(parsed.fit_score).toBe(91); + expect(parsed.confidence).toBe('high'); + expect(parsed.integration_cost).toBe('low'); + expect(parsed.trial_plan).toHaveLength(2); + }); + + it('fails closed to conservative normalized values for unknown enums', () => { + const parsed = parseGoalResponse(JSON.stringify({ + decision: 'YOLO', + fit_score: 999, + confidence: 'certain', + integration_cost: 'tiny', + })); + + expect(parsed.decision).toBe('trial'); + expect(parsed.fit_score).toBe(100); + expect(parsed.confidence).toBe('low'); + expect(parsed.integration_cost).toBe('unknown'); + }); +}); + +describe('analyze_product parsing', () => { + it('keeps website claims explicitly structured', () => { + const parsed = parseProductResponse(JSON.stringify({ + product_model: 'Fees become compute credits.', + core_loop: ['trade', 'credit', 'claim'], + dependencies: ['chain', 'model provider'], + strengths: ['simple loop'], + critical_systems: ['accounting'], + failure_modes: ['double claim'], + claims: [{ + claim: 'Credits are proportional to fees', + website_evidence: 'The product page says so', + verification_status: 'website_only', + needs: ['accounting', 'code'], + confidence: 'medium', + }], + verdict: 'Interesting, but source verification is required.', + confidence: 'medium', + })); + + expect(parsed.claims[0].verification_status).toBe('website_only'); + expect(parsed.critical_systems).toEqual(['accounting']); + expect(parsed.confidence).toBe('medium'); + }); +}); + +describe('analyze_product URL boundary', () => { + it.each([ + 'http://127.0.0.1/admin', + 'http://10.0.0.1/', + 'http://169.254.169.254/latest/meta-data/', + 'http://192.168.1.1/', + 'http://[::1]/', + ])('rejects private address %s', async (url) => { + await expect(assertPublicUrl(url)).rejects.toThrow('public hosts'); + }); + + it('rejects embedded credentials before fetching', async () => { + await expect(assertPublicUrl('https://user:pass@example.com/')).rejects.toThrow('embedded credentials'); + }); +});