Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 42 additions & 34 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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.
209 changes: 209 additions & 0 deletions mcp/analyze-product.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';

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(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_TEXT_CHARS);
}

function extractTitle(html, fallback) {
const match = String(html || '').match(/<title[^>]*>([\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 (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 = await assertPublicUrl(rawUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
try {
const res = await fetch(url, {
redirect: 'manual',
signal: controller.signal,
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')) {
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: 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);
}
Loading
Loading