@chappibunny/repolens 1.13.1 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to RepoLens will be documented in this file.
4
4
 
5
+ ## 1.14.1
6
+
7
+ ### Registry name and publish skip
8
+
9
+ - MCP Registry server name is `io.github.CHAPIBUNNY/repolens`. `package.json` `mcpName` and `server.json` `name` use that spelling. The registry matches the GitHub username case
10
+ - `server.json` `description` is 100 characters or fewer
11
+ - The release workflow skips `npm publish` when that exact version is already on the registry
12
+
13
+ ## 1.14.0
14
+
15
+ ### MCP server
16
+
17
+ AI coding tools can call RepoLens over stdio.
18
+
19
+ - New command: `repolens mcp`. The package also installs `repolens-mcp`
20
+ - Official `@modelcontextprotocol/sdk` server. Tools: `get_system_overview`, `list_modules`, `get_api_surface` (includes routes), `get_dependency_graph`, `get_architecture_diff`, `generate_docs`
21
+ - Read-only except `generate_docs`, which writes Markdown inside the workspace
22
+ - Every path stays inside the workspace root (`REPOLENS_WORKSPACE` or the working directory), using the existing `isInsideRoot` and `resolveOutputDir` helpers
23
+ - Deterministic mode is the default and does not make network calls. Logs go to stderr so they do not corrupt the protocol
24
+ - `get_architecture_diff` uses the existing git diff. `baseRef` compares against the working tree. `headRef` diffs two refs. Unsafe refs are rejected
25
+ - `package.json` `mcpName` is `io.github.CHAPIBUNNY/repolens`. Root `server.json` matches it for the MCP Registry. `smithery.yaml` starts `npx -y @chappibunny/repolens mcp`
26
+ - Client setup, including Cursor and VS Code install badges, is in `docs/MCP.md`
27
+
28
+ ### GitHub Action
29
+
30
+ - Root `action.yml`: composite action **RepoLens Architecture Docs** (icon `book-open`, color `blue`)
31
+ - Inputs: `command` (`publish`, `demo`, `doctor`), `config`, `node-version`, `pr-comment`, `version`
32
+ - Runs the published package with `npx --yes @chappibunny/repolens@<version>`. Inputs are passed as environment variables and checked before they are placed on the npx argument list
33
+ - `REPOLENS_PR_COMMENT=false` skips the architecture diff comment even when `GITHUB_EVENT_NAME` is `pull_request`
34
+ - Marketplace listing is a checkbox on a GitHub Release. The steps are in `docs/RELEASE.md`. This release does not publish the action, the npm package, or a tag
35
+
36
+ ### Docs badge
37
+
38
+ - Generated Markdown ends with a shields.io badge linking to https://github.com/CHAPIBUNNY/repolens
39
+ - On by default. Set `branding: false` or `branding.badge: false` to turn it off. Schema v1 stays at `configVersion: 1`
40
+ - README section **Add the badge to your repo** has the snippet
41
+
5
42
  ## 1.13.1
6
43
 
7
44
  ### Docs
package/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
  [![VS Code Extension](https://img.shields.io/visual-studio-marketplace/v/repolensdox.repolensdox?label=VS%20Code)](https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox)
13
13
  [![Tests](https://img.shields.io/github/actions/workflow/status/CHAPIBUNNY/repolens/test.yml?label=tests)](https://github.com/CHAPIBUNNY/repolens/actions/workflows/test.yml)
14
14
  [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
15
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=repolens&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBjaGFwcGlidW5ueS9yZXBvbGVucyIsIm1jcCJdfQ%3D%3D)
16
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=repolens&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40chappibunny%2Frepolens%22%2C%22mcp%22%5D%7D)
15
17
 
16
18
  **Your architecture docs are already outdated.** RepoLens fixes that.
17
19
 
@@ -175,19 +177,78 @@ Step-by-step setup for publishers, AI features, Notion, Confluence, GitHub Wiki,
175
177
  | `npx @chappibunny/repolens uninstall` | Remove all RepoLens files (config, docs, workflow) |
176
178
  | `npx @chappibunny/repolens uninstall --force` | Remove without confirmation prompt |
177
179
  | `npx @chappibunny/repolens feedback` | Send feedback to the team |
180
+ | `npx @chappibunny/repolens mcp` | Start the stdio MCP server (`repolens-mcp` is the same server) |
178
181
 
179
182
  > **Note:** If you installed globally with `npm install -g @chappibunny/repolens`, you can use the shorter `repolens <command>` form.
180
183
  > **Warning:** Do not run bare `npx repolens` — there is an unrelated `repolens@0.0.1` placeholder package on npm. Always use the scoped name `@chappibunny/repolens`.
181
184
 
182
185
  ---
183
186
 
187
+ ## MCP server
188
+
189
+ Cursor, Claude Desktop, Claude Code, and VS Code Copilot can call RepoLens over stdio. The server is read-only except for `generate_docs`, which writes Markdown inside the workspace. Deterministic mode does not use the network. Paths cannot leave the workspace root.
190
+
191
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=repolens&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBjaGFwcGlidW5ueS9yZXBvbGVucyIsIm1jcCJdfQ%3D%3D)
192
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=repolens&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40chappibunny%2Frepolens%22%2C%22mcp%22%5D%7D)
193
+
194
+ Copy-paste configs for Cursor, Claude Desktop, and VS Code are in [docs/MCP.md](docs/MCP.md).
195
+
196
+ ```json
197
+ {
198
+ "mcpServers": {
199
+ "repolens": {
200
+ "command": "npx",
201
+ "args": ["-y", "@chappibunny/repolens", "mcp"]
202
+ }
203
+ }
204
+ }
205
+ ```
206
+
207
+ ## GitHub Action
208
+
209
+ `action.yml` is a composite action named **RepoLens Architecture Docs**. It runs the published npm package (`npx --yes @chappibunny/repolens@1.14.1`). Inputs are `command` (`publish`, `demo`, or `doctor`), `config`, `node-version`, `pr-comment`, and `version`.
210
+
211
+ ```yaml
212
+ name: Architecture docs
213
+ on:
214
+ pull_request:
215
+ push:
216
+ branches: [main]
217
+ permissions:
218
+ contents: read
219
+ pull-requests: write
220
+ jobs:
221
+ docs:
222
+ runs-on: ubuntu-latest
223
+ steps:
224
+ - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
225
+ - uses: CHAPIBUNNY/repolens@v1.14.1
226
+ with:
227
+ command: publish
228
+ config: .repolens.yml
229
+ node-version: "20"
230
+ pr-comment: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
231
+ ```
232
+
233
+ The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.14.1` has to exist before `uses: CHAPIBUNNY/repolens@v1.14.1` resolves, and `@chappibunny/repolens@1.14.1` has to be on npm before the action can install it.
234
+
235
+ ## Add the badge to your repo
236
+
237
+ Generated Markdown includes this line unless `.repolens.yml` sets `branding: false` (or `branding.badge: false`). Add the same badge to a repository README:
238
+
239
+ ```markdown
240
+ [![Docs by RepoLens](https://img.shields.io/badge/Docs%20by-RepoLens-0B5FFF)](https://github.com/CHAPIBUNNY/repolens)
241
+ ```
242
+
243
+ ---
244
+
184
245
  ## 📸 Example Output
185
246
 
186
247
  ### System map
187
248
 
188
249
  Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
189
250
 
190
- The publisher set in 1.13.0 is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
251
+ The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
191
252
 
192
253
  ### System Overview (Technical Profile)
193
254
 
@@ -248,6 +309,7 @@ When you open a pull request, RepoLens posts:
248
309
  | [Troubleshooting](docs/TROUBLESHOOTING.md) | Common issues and fixes |
249
310
  | [Migration](docs/MIGRATION.md) | Upgrading from v0.3.0 or earlier |
250
311
  | [Stability](docs/STABILITY.md) | API contract and semver guarantees |
312
+ | [MCP server](docs/MCP.md) | stdio server for Cursor, Claude, and VS Code |
251
313
 
252
314
  ---
253
315
 
@@ -269,6 +331,9 @@ v1.0+ features complete — CLI, config schema, and plugin interface are frozen.
269
331
  **Completed:**
270
332
  - [x] VS Code extension ([available on Marketplace](https://marketplace.visualstudio.com/items?itemName=repolensdox.repolensdox))
271
333
  - [x] Obsidian vault publisher (local notes with wikilinks and frontmatter)
334
+ - [x] MCP server (`repolens mcp`) for Cursor, Claude, and VS Code
335
+ - [x] GitHub Action `RepoLens Architecture Docs` (`action.yml`)
336
+ - [x] Docs by RepoLens badge on generated Markdown
272
337
 
273
338
  **Next:**
274
339
  - [ ] GitHub App for automated setup and status checks
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const argv = process.argv.slice(2);
5
+ const wantsHelp = argv.includes("--help") || argv.includes("-h");
6
+ const wantsVersion = argv.includes("--version") || argv.includes("-v");
7
+
8
+ if (wantsVersion && !wantsHelp) {
9
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
+ console.log(pkg.version || "0.0.0");
11
+ process.exit(0);
12
+ }
13
+
14
+ if (wantsHelp) {
15
+ const { printHelp } = await import("../src/help.js");
16
+ printHelp();
17
+ process.exit(0);
18
+ }
19
+
20
+ // Stdio MCP owns stdout. Keep RepoLens logs on stderr.
21
+ process.env.REPOLENS_LOG_STDERR = "1";
22
+ const { startMcpServer } = await import("../src/mcp/server.js");
23
+ await startMcpServer();
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.13.1",
3
+ "version": "1.14.1",
4
+ "mcpName": "io.github.CHAPIBUNNY/repolens",
4
5
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
5
6
  "license": "MIT",
6
7
  "type": "module",
@@ -13,7 +14,8 @@
13
14
  "./package.json": "./package.json"
14
15
  },
15
16
  "bin": {
16
- "repolens": "bin/repolens.js"
17
+ "repolens": "bin/repolens.js",
18
+ "repolens-mcp": "bin/repolens-mcp.js"
17
19
  },
18
20
  "files": [
19
21
  "bin",
@@ -37,7 +39,9 @@
37
39
  "team-collaboration",
38
40
  "discord",
39
41
  "obsidian",
40
- "ai-documentation"
42
+ "ai-documentation",
43
+ "mcp",
44
+ "model-context-protocol"
41
45
  ],
42
46
  "repository": {
43
47
  "type": "git",
@@ -66,9 +70,11 @@
66
70
  "release:major": "npm version major"
67
71
  },
68
72
  "dependencies": {
73
+ "@modelcontextprotocol/sdk": "^1.32.1",
69
74
  "dotenv": "^18.0.8",
70
75
  "fast-glob": "^3.3.3",
71
- "js-yaml": "^4.3.2"
76
+ "js-yaml": "^4.3.2",
77
+ "zod": "3.25.76"
72
78
  },
73
79
  "optionalDependencies": {
74
80
  "@sentry/node": "^10.42.0"
package/src/cli.js CHANGED
@@ -246,6 +246,15 @@ async function main() {
246
246
 
247
247
  const command = parsed.command;
248
248
 
249
+ // MCP stdio must start before telemetry and the npm update check.
250
+ // Those both can open the network, and logs must stay off stdout.
251
+ if (command === "mcp") {
252
+ process.env.REPOLENS_LOG_STDERR = "1";
253
+ const { startMcpServer } = await import("./mcp/server.js");
254
+ await startMcpServer();
255
+ return;
256
+ }
257
+
249
258
  // Initialize telemetry (opt-in via REPOLENS_TELEMETRY_ENABLED=true).
250
259
  // @sentry/node is optional; init is a no-op when it is not installed.
251
260
  await initTelemetry();
@@ -732,7 +741,7 @@ async function main() {
732
741
  }
733
742
 
734
743
  error(`Unknown command: ${command}`);
735
- error("Available commands: init, doctor, migrate, publish, demo, watch, uninstall, feedback, version, help");
744
+ error("Available commands: init, doctor, migrate, publish, demo, watch, uninstall, feedback, mcp, version, help");
736
745
  process.exit(EXIT_ERROR);
737
746
  }
738
747
 
@@ -342,6 +342,18 @@ export function validateConfig(config) {
342
342
  }
343
343
  }
344
344
 
345
+ // Validate branding (optional). Omitted means the docs badge stays on.
346
+ if (config.branding !== undefined) {
347
+ const branding = config.branding;
348
+ const isBoolean = typeof branding === "boolean";
349
+ const isObject = branding !== null && typeof branding === "object" && !Array.isArray(branding);
350
+ if (!isBoolean && !isObject) {
351
+ errors.push("branding must be a boolean or an object");
352
+ } else if (isObject && branding.badge !== undefined && typeof branding.badge !== "boolean") {
353
+ errors.push("branding.badge must be a boolean");
354
+ }
355
+ }
356
+
345
357
  // Validate documentation configuration (optional)
346
358
  if (config.documentation !== undefined) {
347
359
  if (typeof config.documentation !== "object" || Array.isArray(config.documentation)) {
@@ -431,6 +443,7 @@ const VALID_CONFIG_KEYS = [
431
443
  "documentation",
432
444
  "domains",
433
445
  "plugins",
446
+ "branding",
434
447
  ];
435
448
 
436
449
  /**
package/src/core/diff.js CHANGED
@@ -7,6 +7,14 @@ function emptyDiff() {
7
7
  return { added: [], removed: [], modified: [] };
8
8
  }
9
9
 
10
+ /**
11
+ * True when `ref` can be passed as one git argument.
12
+ * Rejects flag-like values and shell metacharacters.
13
+ */
14
+ export function isSafeGitRef(ref) {
15
+ return typeof ref === "string" && ref.length > 0 && !ref.startsWith("-") && SAFE_REF.test(ref);
16
+ }
17
+
10
18
  /**
11
19
  * Classify one `git diff --name-status` line.
12
20
  * Renames and copies (`R100`, `C75`) record the destination path.
@@ -26,17 +34,27 @@ export function classifyDiffLine(line) {
26
34
  return { bucket: "modified", file };
27
35
  }
28
36
 
29
- export function getGitDiff(baseRef = "origin/main", cwd = process.cwd()) {
30
- if (typeof baseRef !== "string" || baseRef.startsWith("-") || !SAFE_REF.test(baseRef)) {
37
+ export function getGitDiff(baseRef = "origin/main", cwd = process.cwd(), headRef) {
38
+ if (!isSafeGitRef(baseRef)) {
31
39
  warn("git diff skipped: base ref contains unsupported characters.");
32
40
  return emptyDiff();
33
41
  }
34
42
 
43
+ const args = ["diff", "--name-status", baseRef];
44
+ if (headRef != null && headRef !== "") {
45
+ if (!isSafeGitRef(headRef)) {
46
+ warn("git diff skipped: head ref contains unsupported characters.");
47
+ return emptyDiff();
48
+ }
49
+ args.push(headRef);
50
+ }
51
+
35
52
  let output = "";
36
53
 
37
54
  try {
38
55
  // Pipe stderr so a missing repo or ref does not dump git's usage text.
39
- output = execFileSync("git", ["diff", "--name-status", baseRef], {
56
+ // Arguments stay in an array. Refs are checked by isSafeGitRef first.
57
+ output = execFileSync("git", args, {
40
58
  encoding: "utf8",
41
59
  cwd,
42
60
  stdio: ["ignore", "pipe", "pipe"],
@@ -92,6 +92,12 @@ async function findExistingRepoLensComment(owner, repo, issueNumber) {
92
92
  }
93
93
 
94
94
  export async function upsertPrComment(diff) {
95
+ const flag = process.env.REPOLENS_PR_COMMENT;
96
+ if (flag === "false" || flag === "0") {
97
+ log("REPOLENS_PR_COMMENT is disabled, skipping PR comment.");
98
+ return;
99
+ }
100
+
95
101
  const eventName = process.env.GITHUB_EVENT_NAME;
96
102
  if (eventName !== "pull_request") {
97
103
  log("GITHUB_EVENT_NAME is not 'pull_request', skipping PR comment.");
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Footer badge on generated Markdown.
3
+ * On unless config sets `branding: false` or `branding.badge: false`.
4
+ */
5
+
6
+ export const DOCS_BADGE_MARKER = "<!-- repolens:docs-badge -->";
7
+
8
+ export const DOCS_BADGE_MARKDOWN =
9
+ "[![Docs by RepoLens](https://img.shields.io/badge/Docs%20by-RepoLens-0B5FFF)](https://github.com/CHAPIBUNNY/repolens)";
10
+
11
+ export function brandingEnabled(config) {
12
+ const branding = config?.branding;
13
+ if (branding === false) return false;
14
+ if (branding && typeof branding === "object" && !Array.isArray(branding) && branding.badge === false) {
15
+ return false;
16
+ }
17
+ return true;
18
+ }
19
+
20
+ /**
21
+ * Append the Docs by RepoLens line once. Non-strings are returned unchanged.
22
+ */
23
+ export function applyBrandingFooter(content, config) {
24
+ if (typeof content !== "string") return content;
25
+ if (!brandingEnabled(config)) return content;
26
+ if (content.includes(DOCS_BADGE_MARKER)) return content;
27
+ const trimmed = content.trimEnd();
28
+ return `${trimmed}\n\n${DOCS_BADGE_MARKER}\n${DOCS_BADGE_MARKDOWN}\n`;
29
+ }
@@ -34,6 +34,7 @@ import {
34
34
  } from "../renderers/renderAnalysis.js";
35
35
  import { info, warn } from "../utils/logger.js";
36
36
  import path from "node:path";
37
+ import { applyBrandingFooter } from "./branding.js";
37
38
 
38
39
  export async function generateDocumentSet(scanResult, config, diffData = null, pluginManager = null) {
39
40
  info("Building structured context for AI...");
@@ -152,7 +153,7 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
152
153
 
153
154
  documents.push({
154
155
  ...docPlan,
155
- content,
156
+ content: applyBrandingFooter(content, config),
156
157
  generated: new Date().toISOString()
157
158
  });
158
159
 
@@ -163,7 +164,10 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
163
164
  // Add placeholder document so publishers know about the failure
164
165
  documents.push({
165
166
  ...docPlan,
166
- content: `# ${docPlan.title}\n\n> **Generation Error:** ${error.message}\n\nPlease check the RepoLens logs for details.\n`,
167
+ content: applyBrandingFooter(
168
+ `# ${docPlan.title}\n\n> **Generation Error:** ${error.message}\n\nPlease check the RepoLens logs for details.\n`,
169
+ config
170
+ ),
167
171
  generated: new Date().toISOString(),
168
172
  error: true
169
173
  });
@@ -201,7 +205,7 @@ export async function generateDocumentSet(scanResult, config, diffData = null, p
201
205
  filename: renderer.filename || `${key}.md`,
202
206
  category: renderer.category || "custom",
203
207
  audience: renderer.audience || ["technical"],
204
- content,
208
+ content: applyBrandingFooter(content, config),
205
209
  generated: new Date().toISOString(),
206
210
  });
207
211
 
package/src/help.js CHANGED
@@ -14,6 +14,7 @@ Commands:
14
14
  watch Watch for file changes and regenerate docs
15
15
  uninstall Remove RepoLens-generated files from a repository
16
16
  feedback Send feedback to the RepoLens team
17
+ mcp Start the stdio MCP server (also available as repolens-mcp)
17
18
  version Print the current RepoLens version
18
19
  help Show this help message
19
20
 
@@ -49,6 +50,7 @@ Examples:
49
50
  repolens demo
50
51
  repolens watch
51
52
  repolens uninstall --target /tmp/my-repo
53
+ repolens mcp
52
54
  repolens --version
53
55
  `);
54
56
  }
package/src/init.js CHANGED
@@ -738,6 +738,9 @@ function buildRepoLensConfig(projectName, detectedRoots, options = {}) {
738
738
  ` - notion # Auto-detected: publishes if NOTION_TOKEN is set`,
739
739
  ` - confluence # Auto-detected: publishes if CONFLUENCE_URL is set`,
740
740
  ``,
741
+ `# Docs by RepoLens badge on generated Markdown (default: on)`,
742
+ `# branding: false`,
743
+ ``,
741
744
  `# Optional: Configure Notion publishing behavior`,
742
745
  `# notion:`,
743
746
  `# branches:`,
@@ -1390,6 +1393,9 @@ function buildWizardConfig(answers) {
1390
1393
  for (const p of answers.publishers) {
1391
1394
  lines.push(` - ${p}`);
1392
1395
  }
1396
+ lines.push(``);
1397
+ lines.push(`# Docs by RepoLens badge on generated Markdown (default: on)`);
1398
+ lines.push(`# branding: false`);
1393
1399
 
1394
1400
  if (answers.publishers.includes("notion") && answers.branches.length) {
1395
1401
  lines.push(``);
@@ -0,0 +1,130 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { callTool } from "./tools.js";
8
+
9
+ const rootField = z.string().optional().describe(
10
+ "Directory to read. Relative or absolute, and it must stay inside the workspace root. Defaults to the workspace root."
11
+ );
12
+
13
+ const readOnly = {
14
+ readOnlyHint: true,
15
+ destructiveHint: false,
16
+ idempotentHint: true,
17
+ openWorldHint: false,
18
+ };
19
+
20
+ function packageVersion() {
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ const pkg = JSON.parse(readFileSync(path.join(here, "../../package.json"), "utf8"));
23
+ return pkg.version || "0.0.0";
24
+ }
25
+
26
+ /**
27
+ * Stdio MCP server. Tool handlers live in tools.js so tests can call them
28
+ * without opening a transport. Logs must stay off stdout.
29
+ */
30
+ export function createMcpServer() {
31
+ const server = new McpServer(
32
+ { name: "repolens", version: packageVersion() },
33
+ {
34
+ instructions: [
35
+ "RepoLens reads the workspace and returns architecture documentation.",
36
+ "Every path stays inside the workspace root.",
37
+ "Tools are read-only except generate_docs, which writes Markdown inside that root.",
38
+ "Deterministic mode (the default) does not make network calls.",
39
+ ].join(" "),
40
+ }
41
+ );
42
+
43
+ server.registerTool(
44
+ "get_system_overview",
45
+ {
46
+ title: "System overview",
47
+ description: "Scan the workspace and return the RepoLens system overview. Read-only. No network calls.",
48
+ inputSchema: { root: rootField },
49
+ annotations: readOnly,
50
+ },
51
+ async (args) => callTool("get_system_overview", args)
52
+ );
53
+
54
+ server.registerTool(
55
+ "list_modules",
56
+ {
57
+ title: "List modules",
58
+ description: "Scan the workspace and list detected modules. Read-only. No network calls.",
59
+ inputSchema: { root: rootField },
60
+ annotations: readOnly,
61
+ },
62
+ async (args) => callTool("list_modules", args)
63
+ );
64
+
65
+ server.registerTool(
66
+ "get_api_surface",
67
+ {
68
+ title: "API surface and routes",
69
+ description: "Scan the workspace and return the API surface and the route map. Read-only. No network calls.",
70
+ inputSchema: { root: rootField },
71
+ annotations: readOnly,
72
+ },
73
+ async (args) => callTool("get_api_surface", args)
74
+ );
75
+
76
+ server.registerTool(
77
+ "get_dependency_graph",
78
+ {
79
+ title: "Dependency graph",
80
+ description: "Scan the workspace and return the import dependency graph. Read-only. No network calls.",
81
+ inputSchema: { root: rootField },
82
+ annotations: readOnly,
83
+ },
84
+ async (args) => callTool("get_dependency_graph", args)
85
+ );
86
+
87
+ server.registerTool(
88
+ "get_architecture_diff",
89
+ {
90
+ title: "Architecture diff",
91
+ description: "Return the architecture diff. With only baseRef, compare that ref to the working tree. With baseRef and headRef, run git diff between those two refs. Read-only. No network calls.",
92
+ inputSchema: {
93
+ root: rootField,
94
+ baseRef: z.string().optional().describe("Base git ref. Defaults to HEAD. Compared with the working tree when headRef is omitted."),
95
+ headRef: z.string().optional().describe("Optional second git ref. When set, the diff is baseRef...headRef as git diff baseRef headRef."),
96
+ },
97
+ annotations: readOnly,
98
+ },
99
+ async (args) => callTool("get_architecture_diff", args)
100
+ );
101
+
102
+ server.registerTool(
103
+ "generate_docs",
104
+ {
105
+ title: "Generate docs",
106
+ description: "Generate RepoLens Markdown into a directory inside the workspace. Deterministic by default, which disables AI and does not make network calls. This is the only tool that writes files.",
107
+ inputSchema: {
108
+ root: rootField,
109
+ outputDir: z.string().optional().describe("Output directory relative to the repository. Must stay inside the repository. Defaults to .repolens or documentation.output_dir."),
110
+ deterministic: z.boolean().optional().describe("Defaults to true. When true, AI stays off and this call does not use the network."),
111
+ },
112
+ annotations: {
113
+ readOnlyHint: false,
114
+ destructiveHint: false,
115
+ idempotentHint: true,
116
+ openWorldHint: false,
117
+ },
118
+ },
119
+ async (args) => callTool("generate_docs", args)
120
+ );
121
+
122
+ return server;
123
+ }
124
+
125
+ export async function startMcpServer() {
126
+ process.env.REPOLENS_LOG_STDERR = "1";
127
+ const server = createMcpServer();
128
+ const transport = new StdioServerTransport();
129
+ await server.connect(transport);
130
+ }
@@ -0,0 +1,202 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { loadConfig } from "../core/config.js";
4
+ import { scanRepo } from "../core/scan.js";
5
+ import { getGitDiff, isSafeGitRef } from "../core/diff.js";
6
+ import { analyzeDependencyGraph } from "../analyzers/dependency-graph.js";
7
+ import {
8
+ renderSystemOverview,
9
+ renderModuleCatalog,
10
+ renderApiSurface,
11
+ renderRouteMap,
12
+ } from "../renderers/render.js";
13
+ import { renderDependencyGraph } from "../renderers/renderAnalysis.js";
14
+ import { renderArchitectureDiff } from "../renderers/renderDiff.js";
15
+ import { generateDocumentSet } from "../docs/generate-doc-set.js";
16
+ import { writeDocumentSet } from "../docs/write-doc-set.js";
17
+ import { resolveDocsOutputDir, resolveInsideWorkspace, resolveWorkspaceRoot } from "./workspace.js";
18
+
19
+ const MAX_TEXT = 80_000;
20
+
21
+ export function asToolText(text) {
22
+ const body = String(text ?? "");
23
+ if (body.length <= MAX_TEXT) return body;
24
+ return `${body.slice(0, MAX_TEXT)}\n\n[truncated ${body.length - MAX_TEXT} characters]`;
25
+ }
26
+
27
+ export function ok(text) {
28
+ return { content: [{ type: "text", text: asToolText(text) }] };
29
+ }
30
+
31
+ export function fail(error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return { isError: true, content: [{ type: "text", text: message }] };
34
+ }
35
+
36
+ function projectRootFromArgs(args) {
37
+ const workspace = resolveWorkspaceRoot();
38
+ return resolveInsideWorkspace(workspace, args?.root, "root");
39
+ }
40
+
41
+ function defaultConfig(root) {
42
+ return {
43
+ configVersion: 1,
44
+ project: { name: path.basename(root) || "repository" },
45
+ publishers: ["markdown"],
46
+ scan: {
47
+ include: ["**/*.{js,ts,jsx,tsx,mjs,cjs,py,go,rs,java,rb,php,cs,swift,kt}"],
48
+ ignore: [
49
+ "**/node_modules/**",
50
+ "**/dist/**",
51
+ "**/build/**",
52
+ "**/.git/**",
53
+ "**/vendor/**",
54
+ "**/target/**",
55
+ "**/__pycache__/**",
56
+ ],
57
+ },
58
+ module_roots: ["src", "lib", "app", "packages"],
59
+ outputs: {
60
+ pages: [
61
+ { key: "system_overview", title: "System Overview" },
62
+ { key: "module_catalog", title: "Module Catalog" },
63
+ { key: "api_surface", title: "API Surface" },
64
+ { key: "route_map", title: "Route Map" },
65
+ ],
66
+ },
67
+ documentation: { output_dir: ".repolens", include_artifacts: false },
68
+ ai: { enabled: false, mode: "off" },
69
+ branding: true,
70
+ __repoRoot: root,
71
+ __configPath: path.join(root, ".repolens.yml"),
72
+ };
73
+ }
74
+
75
+ async function prepareConfig(root, { deterministic = true, outputDir } = {}) {
76
+ const configPath = path.join(root, ".repolens.yml");
77
+ let cfg;
78
+ try {
79
+ await fs.access(configPath);
80
+ cfg = await loadConfig(configPath);
81
+ } catch (error) {
82
+ if (error && error.code === "ENOENT") {
83
+ cfg = defaultConfig(root);
84
+ } else {
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ cfg.__repoRoot = root;
90
+ if (deterministic) {
91
+ cfg.ai = { ...(cfg.ai || {}), enabled: false, mode: "off" };
92
+ }
93
+ if (outputDir != null && outputDir !== "") {
94
+ cfg.documentation = {
95
+ ...(cfg.documentation || {}),
96
+ output_dir: resolveDocsOutputDir(root, outputDir),
97
+ };
98
+ }
99
+ return cfg;
100
+ }
101
+
102
+ /**
103
+ * Drop REPOLENS_AI_ENABLED for one call so deterministic mode cannot
104
+ * reach an AI provider. The previous value is restored afterwards.
105
+ */
106
+ export async function runDeterministic(fn) {
107
+ const previous = process.env.REPOLENS_AI_ENABLED;
108
+ delete process.env.REPOLENS_AI_ENABLED;
109
+ try {
110
+ return await fn();
111
+ } finally {
112
+ if (previous === undefined) delete process.env.REPOLENS_AI_ENABLED;
113
+ else process.env.REPOLENS_AI_ENABLED = previous;
114
+ }
115
+ }
116
+
117
+ async function scanned(args) {
118
+ const root = projectRootFromArgs(args);
119
+ const cfg = await prepareConfig(root, { deterministic: true });
120
+ const scan = await runDeterministic(() => scanRepo(cfg));
121
+ return { root, cfg, scan };
122
+ }
123
+
124
+ export async function getSystemOverview(args = {}) {
125
+ const { cfg, scan } = await scanned(args);
126
+ return renderSystemOverview(cfg, scan);
127
+ }
128
+
129
+ export async function listModules(args = {}) {
130
+ const { cfg, scan } = await scanned(args);
131
+ return renderModuleCatalog(cfg, scan);
132
+ }
133
+
134
+ export async function getApiSurface(args = {}) {
135
+ const { cfg, scan } = await scanned(args);
136
+ return `${renderApiSurface(cfg, scan)}\n\n${renderRouteMap(cfg, scan)}`;
137
+ }
138
+
139
+ export async function getDependencyGraph(args = {}) {
140
+ const { root, scan } = await scanned(args);
141
+ const graph = await runDeterministic(() => analyzeDependencyGraph(scan._files || [], root));
142
+ return renderDependencyGraph(graph);
143
+ }
144
+
145
+ export async function getArchitectureDiff(args = {}) {
146
+ const root = projectRootFromArgs(args);
147
+ const baseRef = args.baseRef == null || args.baseRef === "" ? "HEAD" : args.baseRef;
148
+ const headRef = args.headRef == null || args.headRef === "" ? undefined : args.headRef;
149
+ if (!isSafeGitRef(baseRef)) {
150
+ throw new Error("baseRef contains unsupported characters");
151
+ }
152
+ if (headRef !== undefined && !isSafeGitRef(headRef)) {
153
+ throw new Error("headRef contains unsupported characters");
154
+ }
155
+ const raw = getGitDiff(baseRef, root, headRef);
156
+ return renderArchitectureDiff(raw);
157
+ }
158
+
159
+ export async function generateDocs(args = {}) {
160
+ const root = projectRootFromArgs(args);
161
+ const deterministic = args.deterministic !== false;
162
+ const outputDir = args.outputDir;
163
+
164
+ const run = async () => {
165
+ const cfg = await prepareConfig(root, { deterministic, outputDir });
166
+ const scan = await scanRepo(cfg);
167
+ const raw = getGitDiff("HEAD", root);
168
+ const docSet = await generateDocumentSet(scan, cfg, raw);
169
+ const written = await writeDocumentSet(docSet, root);
170
+ const rel = path.relative(root, written.outputDir) || ".";
171
+ const lines = [
172
+ `Wrote ${written.documentCount} documents to ${rel}`,
173
+ ...written.files.map((file) => `- ${file}`),
174
+ ];
175
+ return lines.join("\n");
176
+ };
177
+
178
+ if (deterministic) return runDeterministic(run);
179
+ return run();
180
+ }
181
+
182
+ const HANDLERS = {
183
+ get_system_overview: getSystemOverview,
184
+ list_modules: listModules,
185
+ get_api_surface: getApiSurface,
186
+ get_dependency_graph: getDependencyGraph,
187
+ get_architecture_diff: getArchitectureDiff,
188
+ generate_docs: generateDocs,
189
+ };
190
+
191
+ export const TOOL_NAMES = Object.keys(HANDLERS);
192
+
193
+ export async function callTool(name, args) {
194
+ const handler = HANDLERS[name];
195
+ if (!handler) return fail(new Error(`Unknown tool: ${name}`));
196
+ try {
197
+ const text = await handler(args || {});
198
+ return ok(text);
199
+ } catch (error) {
200
+ return fail(error);
201
+ }
202
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { isInsideRoot, resolveOutputDir } from "../utils/safe-path.js";
3
+
4
+ /**
5
+ * Workspace root for the MCP server.
6
+ * REPOLENS_WORKSPACE wins over the process working directory.
7
+ */
8
+ export function resolveWorkspaceRoot(explicit) {
9
+ const candidate = explicit || process.env.REPOLENS_WORKSPACE || process.cwd();
10
+ if (typeof candidate !== "string" || candidate.length === 0) {
11
+ throw new Error("Workspace root must be a path string");
12
+ }
13
+ if (candidate.includes("\0")) {
14
+ throw new Error("Workspace root contains a null byte");
15
+ }
16
+ return path.resolve(candidate);
17
+ }
18
+
19
+ /**
20
+ * Resolve `requested` and require it to stay inside `workspaceRoot`.
21
+ * An empty value is the workspace root itself.
22
+ */
23
+ export function resolveInsideWorkspace(workspaceRoot, requested, label = "path") {
24
+ const root = path.resolve(workspaceRoot);
25
+ if (!isInsideRoot(root, root)) {
26
+ throw new Error("Workspace root is not a directory path");
27
+ }
28
+ if (requested == null || requested === "" || requested === ".") {
29
+ return root;
30
+ }
31
+ if (typeof requested !== "string") {
32
+ throw new Error(`${label} must be a string`);
33
+ }
34
+ if (requested.includes("\0")) {
35
+ throw new Error(`${label} contains a null byte`);
36
+ }
37
+ const resolved = path.resolve(root, requested);
38
+ if (!isInsideRoot(root, resolved)) {
39
+ throw new Error(`${label} escapes the workspace: ${requested}`);
40
+ }
41
+ return resolved;
42
+ }
43
+
44
+ /**
45
+ * Validate a documentation output directory and return the relative
46
+ * path `resolveOutputDir` will accept later.
47
+ */
48
+ export function resolveDocsOutputDir(projectRoot, outputDir) {
49
+ const relative = outputDir == null || outputDir === "" ? ".repolens" : outputDir;
50
+ resolveOutputDir(projectRoot, relative);
51
+ return relative;
52
+ }
@@ -69,9 +69,14 @@ function sanitizeArgs(args) {
69
69
  });
70
70
  }
71
71
 
72
+ function logSink() {
73
+ // The MCP stdio transport owns stdout. Logs go to stderr in that mode.
74
+ return process.env.REPOLENS_LOG_STDERR === "1" ? console.error : console.log;
75
+ }
76
+
72
77
  export function log(...args) {
73
78
  if (!isTest && isVerbose) {
74
- console.log("[RepoLens]", ...sanitizeArgs(args));
79
+ logSink()("[RepoLens]", ...sanitizeArgs(args));
75
80
  }
76
81
  }
77
82
 
@@ -81,13 +86,13 @@ export function log(...args) {
81
86
  */
82
87
  export function verbose(...args) {
83
88
  if (!isTest && isVerbose) {
84
- console.log("[RepoLens]", ...sanitizeArgs(args));
89
+ logSink()("[RepoLens]", ...sanitizeArgs(args));
85
90
  }
86
91
  }
87
92
 
88
93
  export function info(...args) {
89
94
  if (!isTest) {
90
- console.log("[RepoLens]", ...sanitizeArgs(args));
95
+ logSink()("[RepoLens]", ...sanitizeArgs(args));
91
96
  }
92
97
  }
93
98