Skip to content

Commit e5f0512

Browse files
committed
feat(mcp): support more providers and inputs
1 parent 9a318cf commit e5f0512

12 files changed

Lines changed: 404 additions & 105 deletions

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ It also ships a **local MCP server** so your coding agent can scan repos before
3030
Run the local MCP server, then ask Claude/Cursor/Pi/etc. to use RepoLens before adding a dependency:
3131

3232
```bash
33-
cd mcp
34-
npm install
33+
# After npm publish
34+
ANTHROPIC_API_KEY=sk-ant-... npx repolens-mcp
35+
36+
# From this repo today
37+
cd mcp && npm install
3538
ANTHROPIC_API_KEY=sk-ant-... node server.js
3639
```
3740

@@ -49,7 +52,7 @@ MCP tools:
4952
- `deep_dive` — plain-English architecture explanation with gaps/assumptions.
5053
- `blueprint_scene` — graph-shaped architecture map.
5154

52-
Each tool writes a self-contained local `.html` report and opens it by default. See [`mcp/README.md`](mcp/README.md) for Claude Desktop config and environment options.
55+
Each tool writes a self-contained local `.html` report and opens it by default. MCP supports Anthropic, OpenAI, OpenRouter, and Google env keys, and `scan_repo` accepts GitHub, GitLab, npm, and PyPI inputs. See [`mcp/README.md`](mcp/README.md) for Claude Desktop config and environment options.
5356

5457
---
5558

mcp/README.md

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,42 @@ The returned JSON includes:
5151

5252
## Setup
5353

54+
After npm publish, the intended one-command path is:
55+
56+
```bash
57+
ANTHROPIC_API_KEY=sk-ant-... npx repolens-mcp
58+
```
59+
60+
From a repo checkout today:
61+
5462
```bash
5563
cd mcp
5664
npm install
57-
export ANTHROPIC_API_KEY=sk-ant-... # required
58-
export ANTHROPIC_MODEL=claude-sonnet-4-6 # optional override
59-
export ANTHROPIC_TIMEOUT_MS=60000 # optional; hard per-call timeout (default 60s)
65+
export ANTHROPIC_API_KEY=sk-ant-... # one provider key is required
6066
export GITHUB_TOKEN=ghp_... # optional; lifts GitHub 60/hr → 5000/hr
6167
node server.js # speaks MCP over stdio
6268
```
6369

70+
Provider environment variables:
71+
72+
```bash
73+
# Auto-pick order: Anthropic → OpenAI → OpenRouter → Google
74+
export ANTHROPIC_API_KEY=sk-ant-...
75+
export OPENAI_API_KEY=sk-...
76+
export OPENROUTER_API_KEY=sk-or-...
77+
export GOOGLE_API_KEY=AIza...
78+
79+
# Optional model overrides
80+
export ANTHROPIC_MODEL=claude-sonnet-4-6
81+
export OPENAI_MODEL=gpt-4.1-mini
82+
export OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
83+
export GOOGLE_MODEL=gemini-2.5-flash
84+
85+
# Force one provider instead of auto-pick
86+
export REPOLENS_MCP_PROVIDER=openai # anthropic | openai | openrouter | google
87+
export REPOLENS_MCP_TIMEOUT_MS=60000
88+
```
89+
6490
Optional report environment variables:
6591

6692
```bash
@@ -108,13 +134,30 @@ Generate a RepoLens deep_dive for github.com/fastify/fastify and summarize the g
108134
Use blueprint_scene on remix-run/remix so I can see how the repo is structured.
109135
```
110136

137+
## Supported inputs
138+
139+
`scan_repo` supports all fetcher-backed RepoLens targets:
140+
141+
```text
142+
honojs/hono
143+
https://github.com/honojs/hono
144+
github:honojs/hono
145+
gitlab:inkscape/inkscape
146+
https://gitlab.com/inkscape/inkscape
147+
npm:react
148+
https://www.npmjs.com/package/@modelcontextprotocol/sdk
149+
pypi:fastapi
150+
https://pypi.org/project/fastapi/
151+
```
152+
153+
`deep_dive` and `blueprint_scene` accept the same inputs, but source-tree reads are
154+
GitHub-deep today; non-GitHub targets degrade to README/metadata context.
155+
111156
## Current scope
112157

113-
- GitHub input only: `owner/name` or a GitHub URL.
114-
- Anthropic provider only via `ANTHROPIC_API_KEY`.
115158
- Local-only: no hosted backend, no RepoLens account.
116-
- The Chrome extension still has the broader provider/platform UX; MCP is the
159+
- Provider support: Anthropic, OpenAI, OpenRouter, and Google via env keys.
160+
- The Chrome extension still has the richest provider/platform UI; MCP is the
117161
agent-native path.
118162

119-
Planned next steps: publish as `repolens-mcp`, add OpenAI/OpenRouter/Gemini env
120-
providers, and extend `scan_repo` to npm/PyPI/GitLab inputs.
163+
Planned next steps: publish `repolens-mcp` to npm and add a comparison tool.

mcp/anthropic.js

Lines changed: 3 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,3 @@
1-
// Minimal Anthropic Messages API call for the MCP server. The key comes from the
2-
// environment (ANTHROPIC_API_KEY) — never chrome.storage, never a hosted backend —
3-
// so the server stays local and bring-your-own-key. Mirrors the extension's
4-
// callAnthropic shape (background.js): same endpoint, version header, and default model.
5-
6-
const ENDPOINT = 'https://api.anthropic.com/v1/messages';
7-
const DEFAULT_MODEL = 'claude-sonnet-4-6';
8-
const DEFAULT_TIMEOUT_MS = 60_000;
9-
10-
/**
11-
* @param {string} prompt - the fully-assembled analysis prompt.
12-
* @returns {Promise<string>} the model's text response.
13-
*/
14-
export async function callAnthropic(prompt) {
15-
const key = process.env.ANTHROPIC_API_KEY;
16-
if (!key) throw new Error('ANTHROPIC_API_KEY is not set in the environment');
17-
const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL;
18-
const timeoutMs = Number(process.env.ANTHROPIC_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
19-
20-
// Hard timeout so a stalled connection can't hang the tool call forever
21-
// (mirrors the extension's per-provider timeout in background.js).
22-
const controller = new AbortController();
23-
const timer = setTimeout(() => controller.abort(), timeoutMs);
24-
let res;
25-
try {
26-
res = await fetch(ENDPOINT, {
27-
method: 'POST',
28-
headers: {
29-
'content-type': 'application/json',
30-
'anthropic-version': '2023-06-01',
31-
'x-api-key': key,
32-
},
33-
body: JSON.stringify({
34-
model,
35-
max_tokens: 4096,
36-
messages: [{ role: 'user', content: prompt }],
37-
}),
38-
signal: controller.signal,
39-
});
40-
} catch (err) {
41-
if (err && err.name === 'AbortError') {
42-
throw new Error(`Anthropic request timed out after ${timeoutMs}ms`);
43-
}
44-
throw err;
45-
} finally {
46-
clearTimeout(timer);
47-
}
48-
49-
if (!res.ok) {
50-
const detail = await res.text().catch(() => '');
51-
throw new Error(`Anthropic API ${res.status}: ${detail.slice(0, 300)}`);
52-
}
53-
54-
const data = await res.json();
55-
const text = (data.content || [])
56-
.map((b) => b.text || '')
57-
.join('')
58-
.trim();
59-
if (!text) throw new Error('Anthropic returned an empty response');
60-
return text;
61-
}
1+
// Back-compat shim: older docs/tests imported callAnthropic directly. The MCP
2+
// server now supports Anthropic, OpenAI, OpenRouter, and Google via model.js.
3+
export { callModel as callAnthropic } from './model.js';

mcp/blueprint-scene.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '../src/deepdive.js';
1818
import { buildBlueprintScene } from '../src/blueprint-adapter.js';
1919
import { parseRepoInput } from './repo-input.js';
20-
import { callAnthropic } from './anthropic.js';
20+
import { callModel } from './model.js';
2121
import { ghOpts } from './github-auth.js';
2222
import { attachHtmlReport } from './report.js';
2323

@@ -30,7 +30,7 @@ export const BLUEPRINT_TOOL = {
3030
inputSchema: {
3131
type: 'object',
3232
properties: {
33-
repo: { type: 'string', description: 'A repo as owner/name or a GitHub URL' },
33+
repo: { type: 'string', description: 'owner/name, platform:name, or a GitHub/GitLab/npm/PyPI URL' },
3434
report: { type: 'boolean', description: 'Write a local HTML report. Default: true.' },
3535
openReport: {
3636
type: 'boolean',
@@ -110,8 +110,8 @@ export async function runBlueprintScene(args) {
110110
const opts = ghOpts();
111111
const repoData = await fetchRepoData(platform, repoId, opts);
112112
const source = await fetchSource(platform, repoId, opts);
113-
const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null)));
114-
const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms)));
113+
const { atoms } = parseAtoms(await callModel(buildAtomsPrompt(repoData, source, null)));
114+
const lineage = parseLineage(await callModel(buildLineagePrompt(atoms)));
115115
const result = buildBlueprintScene({ deepDive: { atoms, lineage }, repoId, title: repoId });
116116
return attachHtmlReport('blueprint_scene', repoId, result, args);
117117
}

mcp/deep-dive.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
parseFeynman,
1919
} from '../src/deepdive.js';
2020
import { parseRepoInput } from './repo-input.js';
21-
import { callAnthropic } from './anthropic.js';
21+
import { callModel } from './model.js';
2222
import { ghOpts } from './github-auth.js';
2323
import { attachHtmlReport } from './report.js';
2424

@@ -32,7 +32,7 @@ export const DEEP_DIVE_TOOL = {
3232
inputSchema: {
3333
type: 'object',
3434
properties: {
35-
repo: { type: 'string', description: 'A repo as owner/name or a GitHub URL' },
35+
repo: { type: 'string', description: 'owner/name, platform:name, or a GitHub/GitLab/npm/PyPI URL' },
3636
report: { type: 'boolean', description: 'Write a local HTML report. Default: true.' },
3737
openReport: {
3838
type: 'boolean',
@@ -105,9 +105,9 @@ export async function runDeepDive(args) {
105105
const opts = ghOpts();
106106
const repoData = await fetchRepoData(platform, repoId, opts);
107107
const source = await fetchSource(platform, repoId, opts);
108-
const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null)));
109-
const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms)));
110-
const feynman = parseFeynman(await callAnthropic(buildFeynmanPrompt(repoData, atoms, lineage)));
108+
const { atoms } = parseAtoms(await callModel(buildAtomsPrompt(repoData, source, null)));
109+
const lineage = parseLineage(await callModel(buildLineagePrompt(atoms)));
110+
const feynman = parseFeynman(await callModel(buildFeynmanPrompt(repoData, atoms, lineage)));
111111
const result = buildDeepDiveResult(repoId, atoms, lineage, feynman, source);
112112
return attachHtmlReport('deep_dive', repoId, result, args);
113113
}

mcp/model.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Environment-driven model provider for RepoLens MCP.
2+
// Keeps the MCP local + BYO-key while supporting the providers people already
3+
// have in agent workflows. Auto-select order is explicit and deterministic.
4+
5+
const DEFAULT_TIMEOUT_MS = 60_000;
6+
7+
const PROVIDERS = {
8+
anthropic: {
9+
env: 'ANTHROPIC_API_KEY',
10+
modelEnv: 'ANTHROPIC_MODEL',
11+
defaultModel: 'claude-sonnet-4-6',
12+
},
13+
openai: {
14+
env: 'OPENAI_API_KEY',
15+
modelEnv: 'OPENAI_MODEL',
16+
defaultModel: 'gpt-4.1-mini',
17+
},
18+
openrouter: {
19+
env: 'OPENROUTER_API_KEY',
20+
modelEnv: 'OPENROUTER_MODEL',
21+
defaultModel: 'anthropic/claude-sonnet-4.5',
22+
},
23+
google: {
24+
env: 'GOOGLE_API_KEY',
25+
modelEnv: 'GOOGLE_MODEL',
26+
defaultModel: 'gemini-2.5-flash',
27+
},
28+
};
29+
30+
function timeoutMs() {
31+
return (
32+
Number(process.env.REPOLENS_MCP_TIMEOUT_MS) ||
33+
Number(process.env.ANTHROPIC_TIMEOUT_MS) ||
34+
DEFAULT_TIMEOUT_MS
35+
);
36+
}
37+
38+
export function pickModelProvider(env = process.env) {
39+
const forced = String(env.REPOLENS_MCP_PROVIDER || '')
40+
.toLowerCase()
41+
.trim();
42+
if (forced) {
43+
const cfg = PROVIDERS[forced];
44+
if (!cfg) throw new Error(`Unknown REPOLENS_MCP_PROVIDER "${forced}"`);
45+
if (!env[cfg.env]) throw new Error(`${cfg.env} is not set for REPOLENS_MCP_PROVIDER=${forced}`);
46+
return { id: forced, key: env[cfg.env], model: env[cfg.modelEnv] || cfg.defaultModel };
47+
}
48+
for (const id of ['anthropic', 'openai', 'openrouter', 'google']) {
49+
const cfg = PROVIDERS[id];
50+
if (env[cfg.env]) return { id, key: env[cfg.env], model: env[cfg.modelEnv] || cfg.defaultModel };
51+
}
52+
throw new Error(
53+
'No MCP model provider configured — set ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or GOOGLE_API_KEY'
54+
);
55+
}
56+
57+
async function fetchWithTimeout(url, opts, label) {
58+
const ms = timeoutMs();
59+
const controller = new AbortController();
60+
const timer = setTimeout(() => controller.abort(), ms);
61+
try {
62+
return await fetch(url, { ...opts, signal: controller.signal });
63+
} catch (err) {
64+
if (err && err.name === 'AbortError') throw new Error(`${label} request timed out after ${ms}ms`);
65+
throw err;
66+
} finally {
67+
clearTimeout(timer);
68+
}
69+
}
70+
71+
function openAiBody(model, prompt) {
72+
return { model, max_tokens: 4096, messages: [{ role: 'user', content: prompt }] };
73+
}
74+
75+
function parseOpenAiText(json, label) {
76+
const text = json?.choices?.[0]?.message?.content;
77+
if (!text) throw new Error(`${label} returned an empty response`);
78+
return text;
79+
}
80+
81+
async function callAnthropic({ key, model }, prompt) {
82+
const res = await fetchWithTimeout(
83+
'https://api.anthropic.com/v1/messages',
84+
{
85+
method: 'POST',
86+
headers: {
87+
'content-type': 'application/json',
88+
'anthropic-version': '2023-06-01',
89+
'x-api-key': key,
90+
},
91+
body: JSON.stringify({ model, max_tokens: 4096, messages: [{ role: 'user', content: prompt }] }),
92+
},
93+
'Anthropic'
94+
);
95+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 300)}`);
96+
const data = await res.json();
97+
const text = (data.content || [])
98+
.map((b) => b.text || '')
99+
.join('')
100+
.trim();
101+
if (!text) throw new Error('Anthropic returned an empty response');
102+
return text;
103+
}
104+
105+
async function callOpenAI({ key, model }, prompt) {
106+
const res = await fetchWithTimeout(
107+
'https://api.openai.com/v1/chat/completions',
108+
{
109+
method: 'POST',
110+
headers: { 'content-type': 'application/json', Authorization: `Bearer ${key}` },
111+
body: JSON.stringify(openAiBody(model, prompt)),
112+
},
113+
'OpenAI'
114+
);
115+
if (!res.ok) throw new Error(`OpenAI API ${res.status}: ${(await res.text()).slice(0, 300)}`);
116+
return parseOpenAiText(await res.json(), 'OpenAI');
117+
}
118+
119+
async function callOpenRouter({ key, model }, prompt) {
120+
const res = await fetchWithTimeout(
121+
'https://openrouter.ai/api/v1/chat/completions',
122+
{
123+
method: 'POST',
124+
headers: {
125+
'content-type': 'application/json',
126+
Authorization: `Bearer ${key}`,
127+
'HTTP-Referer': 'https://github.com/New1Direction/RepoLens',
128+
'X-Title': 'RepoLens MCP',
129+
},
130+
body: JSON.stringify(openAiBody(model, prompt)),
131+
},
132+
'OpenRouter'
133+
);
134+
if (!res.ok) throw new Error(`OpenRouter API ${res.status}: ${(await res.text()).slice(0, 300)}`);
135+
return parseOpenAiText(await res.json(), 'OpenRouter');
136+
}
137+
138+
async function callGoogle({ key, model }, prompt) {
139+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(key)}`;
140+
const res = await fetchWithTimeout(
141+
endpoint,
142+
{
143+
method: 'POST',
144+
headers: { 'content-type': 'application/json' },
145+
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] }),
146+
},
147+
'Google'
148+
);
149+
if (!res.ok) throw new Error(`Google API ${res.status}: ${(await res.text()).slice(0, 300)}`);
150+
const data = await res.json();
151+
const text = (data.candidates?.[0]?.content?.parts || [])
152+
.map((p) => p.text || '')
153+
.join('')
154+
.trim();
155+
if (!text) throw new Error('Google returned an empty response');
156+
return text;
157+
}
158+
159+
export async function callModel(prompt) {
160+
const provider = pickModelProvider();
161+
if (provider.id === 'anthropic') return callAnthropic(provider, prompt);
162+
if (provider.id === 'openai') return callOpenAI(provider, prompt);
163+
if (provider.id === 'openrouter') return callOpenRouter(provider, prompt);
164+
if (provider.id === 'google') return callGoogle(provider, prompt);
165+
throw new Error(`Unsupported MCP provider: ${provider.id}`);
166+
}

0 commit comments

Comments
 (0)