renovate-mcp 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -1,24 +1,43 @@
1
1
  # renovate-mcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/renovate-mcp.svg)](https://www.npmjs.com/package/renovate-mcp)
4
+ [![CI](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml)
5
+ [![Node ≥ 24](https://img.shields.io/node/v/renovate-mcp.svg)](https://nodejs.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
7
+
3
8
  An MCP server for designing [Renovate](https://github.com/renovatebot/renovate) configurations interactively. Point it at a local repo and let an LLM help you read, validate, preview, and save `renovate.json`.
4
9
 
10
+ ## Contents
11
+
12
+ - [What it does](#what-it-does)
13
+ - [What this is NOT](#what-this-is-not)
14
+ - [Requirements](#requirements)
15
+ - [Install](#install)
16
+ - [Example prompts](#example-prompts)
17
+ - [Example session](#example-session)
18
+ - [Development](#development)
19
+ - [Release flow](#release-flow)
20
+ - [Design notes](#design-notes)
21
+
5
22
  ## What it does
6
23
 
7
24
  Eight tools plus a preset reference:
8
25
 
9
- | Tool | What it does |
26
+ | Tool | Purpose |
10
27
  | --- | --- |
11
- | `check_setup` | Report Renovate CLI + validator availability, versions, env overrides, and install hints. Also runs at server startup and embeds the result in the server's `instructions` when anything's missing. |
12
- | `read_config` | Locate and parse `renovate.json` / `renovate.json5` / `.renovaterc*` / `.github/renovate.json` / `.gitlab/renovate.json` / `package.json#renovate` in a repo — in Renovate's priority order. |
13
- | `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `RENOVATE_TOKEN`, or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL) — and `platform` in addition to route `local>` presets through the same host. `bitbucket>`, `gitea>`, and npm presets remain in `presetsUnresolved` regardless. |
14
- | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — offline, no `renovate` invocation. Shows which files match `fileMatch`, which lines match each `matchStrings` regex with named capture groups, and what dep info the template fields produce. Intended for fast regex iteration; run `dry_run` afterwards for full-fidelity confirmation. |
28
+ | `check_setup` | Report Renovate CLI + validator availability, versions, and install hints. Also runs at startup. |
29
+ | `read_config` | Locate and parse a repo's Renovate config (`renovate.json`, `renovate.json5`, `.renovaterc*`, `package.json#renovate`, …) in priority order. |
30
+ | `resolve_config` | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true`. |
31
+ | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — shows file/line hits and extracted dep info. Offline. |
15
32
  | `validate_config` | Run `renovate-config-validator` against a file or inline object. |
16
- | `lint_config` | Semantic lint pass for Renovate-specific footguns schema validation misses — primarily malformed `/…/` regex patterns in `matchPackageNames` / `matchDepNames` / `matchSourceUrls` / `matchCurrentVersion`. Offline; does not shell out. Returns findings with JSON path, offending value, and a stable `ruleId` (`dead-regex-missing-slash`, `unwrapped-regex`) for suppression. Complements `validate_config`. |
17
- | `dry_run` | Run Renovate with `--platform=local --dry-run`, return the structured JSON report (no PRs, no pushes). Scans Renovate's logs for registry-auth failures (401/403/unauthorized/etc.) and surfaces them under `problems` so an empty-updates result isn't mistaken for "no updates available" when credentials were actually missing. Accepts an optional `hostRules` input for per-invocation private-registry credentials so callers don't have to restart the MCP server with new env vars (written to a mode-0600 temp file, passed via `--config-file`, cleaned up after the run; token/password values are scrubbed from any log output). |
18
- | `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
19
- | `renovate://presets` (resource) | Thin markdown index of every namespace (with preset counts) covering all 1000+ built-in presets. Snapshot from the installed `renovate` devDep. |
20
- | `renovate://presets/{namespace}` (resource template) | Markdown listing of every preset in a single namespace (e.g. `renovate://presets/config`). Fetch this instead of the full index when the LLM only cares about one namespace — cuts token cost by roughly 1/N where N is the number of namespaces. |
21
- | `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
33
+ | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses (mostly malformed `/…/` regex patterns). Offline. |
34
+ | `dry_run` | Run Renovate with `--platform=local --dry-run` and return the structured JSON report. No PRs, no pushes. Emits MCP progress notifications during the run when the caller supplies a `progressToken`. |
35
+ | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true`. |
36
+ | `renovate://presets` (resource) | Markdown index of all 1000+ built-in presets grouped by namespace. |
37
+ | `renovate://presets/{namespace}` (resource template) | Markdown listing for a single namespace (e.g. `renovate://presets/config`) — cheaper than the full index. |
38
+ | `renovate://preset/{name}` (resource template) | Expanded JSON body for one preset (e.g. `renovate://preset/config:recommended`). |
39
+
40
+ See [Design notes](#design-notes) for implementation details (timeouts, safety caps, auth scrubbing, merge semantics).
22
41
 
23
42
  ## What this is NOT
24
43
 
@@ -29,26 +48,30 @@ Eight tools plus a preset reference:
29
48
 
30
49
  ## Requirements
31
50
 
51
+ **Required**
52
+
32
53
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
33
- - Renovate available on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` and `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`, `lint_config`) work without it.
34
- - Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
35
- - `RENOVATE_MCP_REQUIRE_CLI=false` suppresses the startup "partial availability" notice. Set this if you only intend to use the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`) and don't want the missing-CLI notice in the server's MCP instructions.
36
- - Optional for `resolve_config` with `externalPresets: true`: `RENOVATE_TOKEN` (preferred — matches Renovate's own convention, where `RENOVATE_TOKEN` is the explicit Renovate-specific override), or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks, for fetching presets from private repos or to avoid rate limits. When both `RENOVATE_TOKEN` and a platform token are set, `RENOVATE_TOKEN` wins. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` if you also want `local>` presets routed there); `RENOVATE_ENDPOINT` is **not** read.
37
- - Optional for `dry_run` against private package registries: whatever credentials Renovate itself would need at lookup time — e.g. `COMPOSER_AUTH` for private Packagist / Satis proxies, `NPM_TOKEN` or `.npmrc` for private npm, Docker registry credentials, or a `RENOVATE_HOST_RULES` JSON blob covering multiple hosts. Alternatively, encode these as `hostRules` directly in the Renovate config, or pass them per-call via the `hostRules` input on `dry_run` (no MCP server restart needed — useful while debugging an empty-updates result). Per-call `hostRules` are appended to any the repo's own config already declares. Values reach Renovate through the MCP tool-call transport as JSON, so the calling LLM sees them in its context; if that matters for a given token, use the env route instead. Without any of these Renovate's lookup step often returns 0 updates silently; `dry_run` will scan its logs for auth failures and surface them under `problems` in the output.
38
- - Any env vars the tools read (tokens, `COMPOSER_AUTH`, `RENOVATE_HOST_RULES`, binary overrides, …) must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell, since the MCP server runs as a child of Claude and does not inherit shell env.
54
+ - Renovate on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` + `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`, `lint_config`) work without it.
55
+
56
+ **Optional**
57
+
58
+ - `RENOVATE_BIN` / `RENOVATE_CONFIG_VALIDATOR_BIN` — override binary locations.
59
+ - `RENOVATE_MCP_REQUIRE_CLI=false` — suppress the startup "partial availability" notice when you only intend to use the offline tools.
60
+ - `RENOVATE_TOKEN` (preferred) or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks — only needed by `resolve_config` with `externalPresets: true` for fetching presets from private repos or avoiding rate limits. `RENOVATE_TOKEN` wins when both are set. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` to route `local>` presets through the same host); `RENOVATE_ENDPOINT` is **not** read.
61
+ - Private-registry credentials for `dry_run` — whatever Renovate itself would need at lookup time (`COMPOSER_AUTH`, `NPM_TOKEN` / `.npmrc`, Docker registry creds, or a `RENOVATE_HOST_RULES` JSON blob). Alternatively encode these as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on `dry_run` (no MCP restart needed). Per-call `hostRules` are appended to whatever the repo's own config declares. Values reach Renovate as JSON through the tool-call transport, so the calling LLM sees them in its context — prefer the env route if that matters. Without any of these, Renovate's lookup often returns 0 updates silently; `dry_run` scans its logs for auth failures and surfaces them under `problems`.
62
+
63
+ > **Note:** all env vars (tokens, `COMPOSER_AUTH`, `RENOVATE_HOST_RULES`, binary overrides, …) must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell — since the MCP server runs as a child of the client and does not inherit shell env.
39
64
 
40
65
  ## Install
41
66
 
42
- Once published to npm, no install is needed — `npx` will fetch it on demand. For local development, clone and build:
67
+ `npx` fetches the [published package](https://www.npmjs.com/package/renovate-mcp) on demand — no manual install needed. For local development, clone and build first:
43
68
 
44
69
  ```bash
45
70
  npm install
46
71
  npm run build
47
72
  ```
48
73
 
49
- ## Use with Claude Code
50
-
51
- Add to `.mcp.json` (project) or `~/.claude.json` (user):
74
+ Add the following `mcpServers` entry to your client's config file:
52
75
 
53
76
  ```json
54
77
  {
@@ -61,33 +84,27 @@ Add to `.mcp.json` (project) or `~/.claude.json` (user):
61
84
  }
62
85
  ```
63
86
 
64
- For local development, swap to:
87
+ For local development, swap to `"command": "node"` with `"args": ["/absolute/path/to/renovate-mcp/dist/index.js"]`.
65
88
 
66
- ```json
67
- {
68
- "mcpServers": {
69
- "renovate": {
70
- "command": "node",
71
- "args": ["/absolute/path/to/renovate-mcp/dist/index.js"]
72
- }
73
- }
74
- }
75
- ```
89
+ ### Claude Code
76
90
 
77
- ## Use with Claude Desktop
91
+ Config lives in `.mcp.json` (project-scoped) or `~/.claude.json` (user-scoped).
78
92
 
79
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
93
+ ### Claude Desktop
80
94
 
81
- ```json
82
- {
83
- "mcpServers": {
84
- "renovate": {
85
- "command": "npx",
86
- "args": ["-y", "renovate-mcp"]
87
- }
88
- }
89
- }
90
- ```
95
+ Config lives in `~/Library/Application Support/Claude/claude_desktop_config.json`.
96
+
97
+ ### Other MCP clients
98
+
99
+ Any client that can launch a stdio MCP server works — point it at the same command shown above.
100
+
101
+ ### Verify the wiring
102
+
103
+ Restart your client and prompt it:
104
+
105
+ > List the namespaces available under `renovate://presets`.
106
+
107
+ A response listing namespaces like `config`, `docker`, `npm`, `helpers`, … confirms the server is reachable and the resource is exposed. If the client reports the tool or resource as unavailable, re-check the config file path and command.
91
108
 
92
109
  ## Example prompts
93
110
 
@@ -157,12 +174,13 @@ npm run build # compile to dist/
157
174
  npm start # run the built server over stdio
158
175
  npm test # vitest run (builds first)
159
176
  npm run test:watch # vitest watch mode
177
+ npm run test:coverage # vitest run --coverage (writes coverage/ report)
160
178
  npm run generate:presets # regenerate src/data/presets.generated.ts from the renovate devDep
161
179
  ```
162
180
 
163
181
  The preset catalogue at `src/data/presets.generated.ts` is a committed snapshot of Renovate's built-in presets. Runtime code never imports the `renovate` package — only `scripts/generate-presets.mjs` does. Regenerate after bumping the `renovate` devDependency.
164
182
 
165
- CI runs `typecheck`, `build`, and `test` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`).
183
+ CI runs `typecheck`, `build`, and `test:coverage` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`). Coverage is uploaded as a per-run artifact; no threshold is enforced yet.
166
184
 
167
185
  ## Release flow
168
186
 
@@ -184,9 +202,13 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
184
202
  - `validate_config`, `dry_run`, and `write_config` shell out to the Renovate CLI rather than importing Renovate as a library — this decouples our Node version from Renovate's (currently Node 24).
185
203
  - `resolve_config`, `preview_custom_manager`, and `lint_config` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
186
204
  - `lint_config` is a semantic lint pass that sits alongside `validate_config` rather than replacing it: schema validation catches structural bugs, the linter catches Renovate-specific footguns that schema validation declares valid — most commonly a pattern like `"matchPackageNames": ["/devops\\/pipelines\\/.+"]` where a trailing `/` is missing and Renovate silently degrades the value to an exact-string match that never hits. The ruleset is intentionally small (two rules: `dead-regex-missing-slash`, `unwrapped-regex`), scoped to the four regex-aware fields named in the proposal, and tuned to avoid false positives on benign exact strings containing a `.`. Rule IDs are stable so findings can be suppressed by callers.
187
- - `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesScanned` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
205
+ - `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesWalked` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
206
+ - `preview_custom_manager` exposes two separate safety caps so the warning text can name which one tripped: `maxFilesWalked` (default 2000) bounds the directory walk before any `fileMatch` testing, and `maxFilesMatched` (default 500) bounds the result set after `fileMatch` is applied. Previously a single `maxFilesScanned` conflated the two, leaving the user unable to tell whether to narrow `fileMatch` or widen the walk.
207
+ - `preview_custom_manager` runs every user-supplied regex on a `worker_threads` worker with a wall-clock budget per operation (default 2 s, configurable via `matchTimeoutMs`). Catastrophic backtracking — e.g. `^(a+)+b$` against `aaaa…c`, or `(.*)*=` against a modestly sized file — would otherwise pin the MCP server's event loop indefinitely. On timeout the worker is terminated and a warning is appended identifying which `fileMatch[i]` or `matchStrings[i]` was aborted, so the user can simplify the pattern or raise the budget.
208
+ - `preview_custom_manager` also stats each matched file before reading it and skips anything larger than `maxFileBytes` (default 5 MiB) with a warning, so a stray lockfile, generated artifact, or SQL dump caught by a loose `fileMatch` can't OOM the server. `maxHitsPerFile` already bounds output size; this guards input size.
188
209
  - `resolve_config` expands `extends` against a committed snapshot of Renovate's built-in presets (`src/data/presets.generated.ts`). External `github>` / `gitlab>` fetching is opt-in, uses each platform's contents API with a 10 s timeout, and caches results per call. The `endpoint` input swaps in a custom API base for GHE / self-hosted GitLab; `platform` additionally rewrites `local>` presets to be fetched against that endpoint.
189
210
  - `resolve_config` merges preset bodies with a close approximation of Renovate's own `mergeChildConfig` — arrays concat, objects recursively merge, scalars overwrite — not a bit-identical port. Rule-specific semantics for `hostRules`, `regexManagers` / `customManagers`, and certain boolean flags aren't modeled here. Every response carries `mergeQuality: "preview"` plus a human-readable `disclaimer` so callers can't miss the limitation; run `dry_run` for authoritative output.
190
211
  - When `resolve_config` encounters template tokens outside its supported subset, it records a structured entry in `warnings`: under-argument cases (`{{arg2}}` referenced when only one arg was passed) substitute an empty string, while non-positional tokens (`{{packageRules}}`, Handlebars helpers like `{{#if …}}`) pass through verbatim.
191
212
  - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout. When a `hostRules` input is passed it's written to a mode-0600 temp file in `os.tmpdir()`, handed to Renovate via `--config-file=`, and deleted in a `finally` block. Token/password values are scrubbed from both the detected `problems` list and the `logTail` fallback before returning.
213
+ - `dry_run` emits MCP progress notifications only when the caller's `tools/call` includes `_meta.progressToken` — no-op otherwise, so legacy clients see zero overhead. A 5-second heartbeat ticks while the child runs; each tick's message is best-effort enriched with the latest Renovate JSON-log `msg` seen on stdout. Notifications are also emitted at start and completion. We deliberately don't couple to Renovate's log schema beyond reading `msg`, since that schema isn't a stable API.
192
214
  - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ const requireCliRaw = process.env.RENOVATE_MCP_REQUIRE_CLI?.trim().toLowerCase()
33
33
  const suppressBanner = requireCliRaw === "false" || requireCliRaw === "0";
34
34
  const banner = suppressBanner ? null : startupBanner(setup);
35
35
  const instructions = banner ? [BASE_INSTRUCTIONS, "", banner].join("\n") : BASE_INSTRUCTIONS;
36
- const SERVER_VERSION = "0.4.0"; // x-release-please-version
36
+ const SERVER_VERSION = "0.5.0"; // x-release-please-version
37
37
  const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
38
38
  registerCheckSetup(server);
39
39
  registerReadConfig(server);
@@ -36,11 +36,14 @@ export interface ExtractedDep {
36
36
  autoReplaceString?: string;
37
37
  }
38
38
  export interface PreviewOptions {
39
- maxFilesScanned?: number;
39
+ maxFilesWalked?: number;
40
+ maxFilesMatched?: number;
40
41
  maxHitsPerFile?: number;
42
+ matchTimeoutMs?: number;
43
+ maxFileBytes?: number;
41
44
  }
42
45
  export interface PreviewResult {
43
- filesScanned: number;
46
+ filesWalked: number;
44
47
  filesMatched: string[];
45
48
  hits: PreviewHit[];
46
49
  extractedDeps: ExtractedDep[];
@@ -1,8 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { Worker } from "node:worker_threads";
3
4
  import ignore from "ignore";
4
- const DEFAULT_MAX_FILES_SCANNED = 2000;
5
+ const DEFAULT_MAX_FILES_WALKED = 2000;
6
+ const DEFAULT_MAX_FILES_MATCHED = 500;
5
7
  const DEFAULT_MAX_HITS_PER_FILE = 100;
8
+ const DEFAULT_MATCH_TIMEOUT_MS = 2000;
9
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
6
10
  const SKIP_DIRS = new Set([".git", "node_modules"]);
7
11
  const TEMPLATE_FIELD_MAP = [
8
12
  ["depNameTemplate", "depName"],
@@ -18,29 +22,67 @@ const TEMPLATE_FIELD_MAP = [
18
22
  ];
19
23
  export async function previewCustomManager(repoPath, manager, options = {}) {
20
24
  const warnings = [];
21
- const maxFilesScanned = options.maxFilesScanned ?? DEFAULT_MAX_FILES_SCANNED;
25
+ const maxFilesWalked = options.maxFilesWalked ?? DEFAULT_MAX_FILES_WALKED;
26
+ const maxFilesMatched = options.maxFilesMatched ?? DEFAULT_MAX_FILES_MATCHED;
22
27
  const maxHitsPerFile = options.maxHitsPerFile ?? DEFAULT_MAX_HITS_PER_FILE;
28
+ const matchTimeoutMs = options.matchTimeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
29
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
23
30
  if (manager.matchStringsStrategy && manager.matchStringsStrategy !== "any") {
24
31
  warnings.push(`matchStringsStrategy='${manager.matchStringsStrategy}' is not yet supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
25
32
  }
26
- const fileMatchRes = manager.fileMatch.map((s) => compileRegex(s));
27
- const matchStringRes = manager.matchStrings.map((s) => compileRegex(s, "gm"));
28
- const filesMatched = [];
29
- let filesScanned = 0;
33
+ // Surface malformed user regexes eagerly, before we do any filesystem work.
34
+ // The Worker path otherwise reports these as generic worker errors.
35
+ for (const src of manager.fileMatch)
36
+ validateRegex(src);
37
+ for (const src of manager.matchStrings)
38
+ validateRegex(src);
39
+ // Walk first, then run fileMatch regexes in a worker so a pathological
40
+ // pattern can't pin the event loop on the path-testing phase.
41
+ const allPaths = [];
30
42
  for await (const rel of walk(repoPath)) {
31
- if (filesScanned >= maxFilesScanned) {
32
- warnings.push(`Stopped after scanning ${maxFilesScanned} files. Increase maxFilesScanned to widen the search.`);
43
+ if (allPaths.length >= maxFilesWalked) {
44
+ warnings.push(`Stopped walking the repo after ${maxFilesWalked} files; remaining files were never tested against fileMatch. Add ignores (or a .gitignore) to prune irrelevant directories, or raise maxFilesWalked.`);
33
45
  break;
34
46
  }
35
- filesScanned++;
36
- if (fileMatchRes.some((re) => re.test(rel))) {
37
- filesMatched.push(rel);
47
+ allPaths.push(rel);
48
+ }
49
+ const filesWalked = allPaths.length;
50
+ const matchedSet = new Set();
51
+ for (let i = 0; i < manager.fileMatch.length; i++) {
52
+ const source = manager.fileMatch[i];
53
+ const res = await runTestInWorker(source, "", allPaths, matchTimeoutMs);
54
+ if (res.timedOut) {
55
+ warnings.push(`fileMatch[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; no paths were matched against this pattern. Simplify the regex (e.g. avoid nested quantifiers like (a+)+) or raise matchTimeoutMs.`);
56
+ continue;
38
57
  }
58
+ for (const p of res.paths)
59
+ matchedSet.add(p);
60
+ }
61
+ // Preserve walk order so output is stable.
62
+ const allFilesMatched = allPaths.filter((p) => matchedSet.has(p));
63
+ // Distinct from the walk cap: this caps the *result set*. A broad fileMatch
64
+ // regex over a large repo can produce thousands of hits; truncate with a
65
+ // dedicated warning so the user can tell which cap tripped.
66
+ const filesMatched = allFilesMatched.slice(0, maxFilesMatched);
67
+ if (allFilesMatched.length > maxFilesMatched) {
68
+ warnings.push(`fileMatch matched ${allFilesMatched.length} files; capped result set at maxFilesMatched=${maxFilesMatched}. Narrow fileMatch to target the intended paths, or raise maxFilesMatched.`);
39
69
  }
40
70
  const hits = [];
41
71
  const extractedDeps = [];
42
72
  for (const rel of filesMatched) {
43
73
  const abs = path.join(repoPath, rel);
74
+ let stat;
75
+ try {
76
+ stat = await fs.stat(abs);
77
+ }
78
+ catch (err) {
79
+ warnings.push(`Could not stat ${rel}: ${err.message}`);
80
+ continue;
81
+ }
82
+ if (stat.size > maxFileBytes) {
83
+ warnings.push(`${rel}: skipped, ${stat.size} bytes exceeds maxFileBytes=${maxFileBytes}. Tighten fileMatch to exclude it, or raise maxFileBytes.`);
84
+ continue;
85
+ }
44
86
  let content;
45
87
  try {
46
88
  content = await fs.readFile(abs, "utf8");
@@ -50,28 +92,34 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
50
92
  continue;
51
93
  }
52
94
  let perFileHits = 0;
53
- for (let i = 0; i < matchStringRes.length; i++) {
54
- const re = matchStringRes[i];
55
- // RegExp objects with the `g` flag are stateful — reset before iterating
56
- // each file so that previous calls don't shift lastIndex.
57
- re.lastIndex = 0;
58
- for (const m of content.matchAll(re)) {
95
+ for (let i = 0; i < manager.matchStrings.length; i++) {
96
+ const source = manager.matchStrings[i];
97
+ const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
98
+ if (res.timedOut) {
99
+ warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex (e.g. avoid nested quantifiers like (.*)*) or raise matchTimeoutMs.`);
100
+ continue;
101
+ }
102
+ for (const m of res.matches) {
59
103
  if (perFileHits >= maxHitsPerFile) {
60
104
  warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
61
105
  break;
62
106
  }
63
- const groups = (m.groups ?? {});
64
- const line = lineNumberAt(content, m.index ?? 0);
65
- const matchStr = m[0];
66
- hits.push({ file: rel, matchStringIndex: i, line, match: matchStr, groups });
67
- extractedDeps.push(buildExtractedDep(rel, line, groups, manager));
107
+ const line = lineNumberAt(content, m.index);
108
+ hits.push({
109
+ file: rel,
110
+ matchStringIndex: i,
111
+ line,
112
+ match: m.match,
113
+ groups: m.groups,
114
+ });
115
+ extractedDeps.push(buildExtractedDep(rel, line, m.groups, manager));
68
116
  perFileHits++;
69
117
  }
70
118
  if (perFileHits >= maxHitsPerFile)
71
119
  break;
72
120
  }
73
121
  }
74
- return { filesScanned, filesMatched, hits, extractedDeps, warnings };
122
+ return { filesWalked, filesMatched, hits, extractedDeps, warnings };
75
123
  }
76
124
  function buildExtractedDep(file, line, groups, manager) {
77
125
  const dep = { file, line };
@@ -102,17 +150,9 @@ function buildExtractedDep(file, line, groups, manager) {
102
150
  function applyTemplate(tmpl, groups) {
103
151
  return tmpl.replace(/\{\{\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\}\}/g, (_, name) => groups[name] ?? "");
104
152
  }
105
- function compileRegex(source, extraFlags = "") {
153
+ function validateRegex(source) {
106
154
  try {
107
- // De-duplicate flags in case the source already had `g`/`m`.
108
- const seen = new Set();
109
- const flags = (extraFlags).split("").filter((c) => {
110
- if (seen.has(c))
111
- return false;
112
- seen.add(c);
113
- return true;
114
- }).join("");
115
- return new RegExp(source, flags);
155
+ new RegExp(source);
116
156
  }
117
157
  catch (err) {
118
158
  throw new Error(`Invalid regex /${source}/: ${err.message}`);
@@ -126,6 +166,82 @@ function lineNumberAt(content, index) {
126
166
  }
127
167
  return line;
128
168
  }
169
+ const WORKER_SOURCE = `
170
+ const { parentPort, workerData } = require('node:worker_threads');
171
+ try {
172
+ const { mode, pattern, flags } = workerData;
173
+ const re = new RegExp(pattern, flags);
174
+ if (mode === 'test') {
175
+ const out = [];
176
+ for (const p of workerData.paths) {
177
+ re.lastIndex = 0;
178
+ if (re.test(p)) out.push(p);
179
+ }
180
+ parentPort.postMessage({ ok: true, mode: 'test', paths: out });
181
+ } else {
182
+ const out = [];
183
+ for (const m of workerData.content.matchAll(re)) {
184
+ out.push({
185
+ index: m.index == null ? 0 : m.index,
186
+ match: m[0],
187
+ groups: m.groups == null ? {} : Object.assign({}, m.groups),
188
+ });
189
+ }
190
+ parentPort.postMessage({ ok: true, mode: 'matchAll', matches: out });
191
+ }
192
+ } catch (err) {
193
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
194
+ }
195
+ `;
196
+ async function runWorker(request, timeoutMs) {
197
+ const worker = new Worker(WORKER_SOURCE, {
198
+ eval: true,
199
+ workerData: request,
200
+ });
201
+ let timer;
202
+ try {
203
+ return await new Promise((resolve, reject) => {
204
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
205
+ worker.once("message", (msg) => resolve(msg));
206
+ worker.once("error", (err) => reject(err));
207
+ worker.once("exit", (code) => {
208
+ if (code !== 0 && code !== 1) {
209
+ // code 1 is the normal exit after terminate(); anything else is a
210
+ // crash we haven't already captured via 'error'.
211
+ reject(new Error(`Regex worker exited unexpectedly with code ${code}`));
212
+ }
213
+ });
214
+ });
215
+ }
216
+ finally {
217
+ if (timer)
218
+ clearTimeout(timer);
219
+ // terminate() is idempotent and safe to call after the worker already exited.
220
+ await worker.terminate().catch(() => { });
221
+ }
222
+ }
223
+ async function runTestInWorker(pattern, flags, paths, timeoutMs) {
224
+ const response = await runWorker({ mode: "test", pattern, flags, paths }, timeoutMs);
225
+ if (response === "timeout")
226
+ return { timedOut: true };
227
+ if (!response.ok)
228
+ throw new Error(`Regex worker error: ${response.error}`);
229
+ if (response.mode !== "test") {
230
+ throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
231
+ }
232
+ return { timedOut: false, paths: response.paths };
233
+ }
234
+ async function runMatchAllInWorker(pattern, flags, content, timeoutMs) {
235
+ const response = await runWorker({ mode: "matchAll", pattern, flags, content }, timeoutMs);
236
+ if (response === "timeout")
237
+ return { timedOut: true };
238
+ if (!response.ok)
239
+ throw new Error(`Regex worker error: ${response.error}`);
240
+ if (response.mode !== "matchAll") {
241
+ throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
242
+ }
243
+ return { timedOut: false, matches: response.matches };
244
+ }
129
245
  async function readMaybe(abs) {
130
246
  try {
131
247
  return await fs.readFile(abs, "utf8");
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure helpers for the dry_run tool's progress-notification pipeline. Kept in
3
+ * their own module so the parsing + message-building logic can be unit-tested
4
+ * without spawning an MCP server.
5
+ *
6
+ * The mapping from Renovate's JSON log output to progress messages is
7
+ * intentionally best-effort — Renovate's log schema is not a stable API, so we
8
+ * only read the `msg` field when present and fall back to an elapsed-time
9
+ * message otherwise.
10
+ */
11
+ /** Maximum length of a Renovate log `msg` copied into a progress update. */
12
+ export declare const DEFAULT_MAX_LOG_MSG_LEN = 160;
13
+ /**
14
+ * Parse a single stdout line as a Renovate JSON log entry and return its
15
+ * `msg` (trimmed and truncated) when present. Returns `undefined` for
16
+ * non-JSON lines, lines without a string `msg`, or empty messages.
17
+ */
18
+ export declare function extractRenovateLogMsg(line: string, maxLen?: number): string | undefined;
19
+ /**
20
+ * Build the message payload for a heartbeat-tick progress notification.
21
+ * Includes elapsed time and, when available, the most recently observed
22
+ * Renovate log `msg` so callers see *something* beyond "still running".
23
+ */
24
+ export declare function buildDryRunHeartbeatMessage(elapsedMs: number, lastLogMsg: string | undefined): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure helpers for the dry_run tool's progress-notification pipeline. Kept in
3
+ * their own module so the parsing + message-building logic can be unit-tested
4
+ * without spawning an MCP server.
5
+ *
6
+ * The mapping from Renovate's JSON log output to progress messages is
7
+ * intentionally best-effort — Renovate's log schema is not a stable API, so we
8
+ * only read the `msg` field when present and fall back to an elapsed-time
9
+ * message otherwise.
10
+ */
11
+ /** Maximum length of a Renovate log `msg` copied into a progress update. */
12
+ export const DEFAULT_MAX_LOG_MSG_LEN = 160;
13
+ /**
14
+ * Parse a single stdout line as a Renovate JSON log entry and return its
15
+ * `msg` (trimmed and truncated) when present. Returns `undefined` for
16
+ * non-JSON lines, lines without a string `msg`, or empty messages.
17
+ */
18
+ export function extractRenovateLogMsg(line, maxLen = DEFAULT_MAX_LOG_MSG_LEN) {
19
+ if (!line)
20
+ return undefined;
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(line);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ if (!parsed || typeof parsed !== "object")
29
+ return undefined;
30
+ const msgVal = parsed.msg;
31
+ if (typeof msgVal !== "string")
32
+ return undefined;
33
+ const trimmed = msgVal.trim();
34
+ if (!trimmed)
35
+ return undefined;
36
+ return trimmed.slice(0, maxLen);
37
+ }
38
+ /**
39
+ * Build the message payload for a heartbeat-tick progress notification.
40
+ * Includes elapsed time and, when available, the most recently observed
41
+ * Renovate log `msg` so callers see *something* beyond "still running".
42
+ */
43
+ export function buildDryRunHeartbeatMessage(elapsedMs, lastLogMsg) {
44
+ const seconds = Math.max(0, Math.round(elapsedMs / 1000));
45
+ return lastLogMsg
46
+ ? `Dry-run in progress (${seconds}s) — ${lastLogMsg}`
47
+ : `Dry-run in progress (${seconds}s elapsed)`;
48
+ }
@@ -39,18 +39,23 @@ export function collectSecrets(hostRules) {
39
39
  }
40
40
  return [...out];
41
41
  }
42
+ function escapeRegex(s) {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
42
45
  /**
43
46
  * Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
44
47
  * No-op when `secrets` is empty.
45
48
  */
46
49
  export function scrubSecrets(text, secrets) {
47
- if (!secrets.length)
50
+ // Sort by length descending so that when one secret is a substring of
51
+ // another, the longer one wins the greedy regex alternation instead of
52
+ // being partially chewed up by the shorter one.
53
+ const alternatives = secrets
54
+ .filter((s) => s.length > 0)
55
+ .sort((a, b) => b.length - a.length)
56
+ .map(escapeRegex);
57
+ if (!alternatives.length)
48
58
  return text;
49
- let result = text;
50
- for (const secret of secrets) {
51
- if (!secret)
52
- continue;
53
- result = result.split(secret).join("[REDACTED]");
54
- }
55
- return result;
59
+ const pattern = new RegExp(alternatives.join("|"), "g");
60
+ return text.replace(pattern, "[REDACTED]");
56
61
  }
@@ -69,6 +69,12 @@ export interface ParsedPreset {
69
69
  subpath?: string;
70
70
  /** Optional `#ref` (branch/tag/commit) fragment. */
71
71
  ref?: string;
72
+ /**
73
+ * Set when parsing already determined the preset cannot be resolved — e.g.
74
+ * an unknown source prefix like `xyz>foo/bar`. `loadPresetBody` short-circuits
75
+ * to the unresolved list with this reason.
76
+ */
77
+ unresolvableReason?: string;
72
78
  }
73
79
  /**
74
80
  * Resolve a Renovate config by expanding every preset in `extends`. Built-in
@@ -118,6 +118,10 @@ function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplat
118
118
  }
119
119
  }
120
120
  async function loadPresetBody(parsed, ctx, unresolvedList) {
121
+ if (parsed.unresolvableReason) {
122
+ unresolvedList.push({ preset: parsed.original, reason: parsed.unresolvableReason });
123
+ return null;
124
+ }
121
125
  if (!parsed.source) {
122
126
  const preset = PRESETS[parsed.key];
123
127
  if (!preset) {
@@ -199,9 +203,15 @@ export function parsePreset(raw) {
199
203
  return { key, original, args };
200
204
  }
201
205
  function parseExternal(rest, original, args, sourceRaw) {
202
- const source = (KNOWN_SOURCES.has(sourceRaw)
203
- ? sourceRaw
204
- : sourceRaw);
206
+ if (!KNOWN_SOURCES.has(sourceRaw)) {
207
+ return {
208
+ key: original,
209
+ original,
210
+ args,
211
+ unresolvableReason: `Unknown preset source: ${sourceRaw}`,
212
+ };
213
+ }
214
+ const source = sourceRaw;
205
215
  let spec = rest.slice(sourceRaw.length + 1);
206
216
  let ref;
207
217
  const hashIdx = spec.indexOf("#");
@@ -8,6 +8,14 @@ export interface RunOptions {
8
8
  env?: NodeJS.ProcessEnv;
9
9
  timeoutMs?: number;
10
10
  stdin?: string;
11
+ /**
12
+ * Optional line-oriented observers. Invoked once per complete line (newline
13
+ * stripped) as data arrives, and once more on process close for any trailing
14
+ * non-empty fragment. Exceptions thrown by the callbacks are swallowed so a
15
+ * buggy observer can't crash the child-process pipeline.
16
+ */
17
+ onStdoutLine?: (line: string) => void;
18
+ onStderrLine?: (line: string) => void;
11
19
  }
12
20
  /**
13
21
  * Spawn a command and capture stdout/stderr. Never throws on non-zero exit —
@@ -13,6 +13,8 @@ export function run(cmd, args, opts = {}) {
13
13
  });
14
14
  let stdout = "";
15
15
  let stderr = "";
16
+ let stdoutLineBuf = "";
17
+ let stderrLineBuf = "";
16
18
  let timer;
17
19
  let killed = false;
18
20
  if (opts.timeoutMs) {
@@ -21,11 +23,31 @@ export function run(cmd, args, opts = {}) {
21
23
  child.kill("SIGKILL");
22
24
  }, opts.timeoutMs);
23
25
  }
26
+ const emitLines = (chunk, buf, cb) => {
27
+ if (!cb)
28
+ return "";
29
+ const combined = buf + chunk;
30
+ const parts = combined.split(/\r?\n/);
31
+ const trailing = parts.pop() ?? "";
32
+ for (const line of parts) {
33
+ try {
34
+ cb(line);
35
+ }
36
+ catch {
37
+ // never let an observer crash the pipeline
38
+ }
39
+ }
40
+ return trailing;
41
+ };
24
42
  child.stdout.on("data", (d) => {
25
- stdout += d.toString();
43
+ const chunk = d.toString();
44
+ stdout += chunk;
45
+ stdoutLineBuf = emitLines(chunk, stdoutLineBuf, opts.onStdoutLine);
26
46
  });
27
47
  child.stderr.on("data", (d) => {
28
- stderr += d.toString();
48
+ const chunk = d.toString();
49
+ stderr += chunk;
50
+ stderrLineBuf = emitLines(chunk, stderrLineBuf, opts.onStderrLine);
29
51
  });
30
52
  child.on("error", (err) => {
31
53
  if (timer)
@@ -35,6 +57,23 @@ export function run(cmd, args, opts = {}) {
35
57
  child.on("close", (code) => {
36
58
  if (timer)
37
59
  clearTimeout(timer);
60
+ // Flush trailing partial lines (output that didn't end with a newline).
61
+ if (stdoutLineBuf && opts.onStdoutLine) {
62
+ try {
63
+ opts.onStdoutLine(stdoutLineBuf);
64
+ }
65
+ catch {
66
+ // ignore
67
+ }
68
+ }
69
+ if (stderrLineBuf && opts.onStderrLine) {
70
+ try {
71
+ opts.onStderrLine(stderrLineBuf);
72
+ }
73
+ catch {
74
+ // ignore
75
+ }
76
+ }
38
77
  if (killed) {
39
78
  reject(new Error(`Command timed out after ${opts.timeoutMs}ms: ${cmd} ${args.join(" ")}`));
40
79
  return;
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
7
7
  import { detectLookupProblems } from "../lib/lookupProblems.js";
8
8
  import { collectSecrets, scrubSecrets, writeHostRulesConfig, } from "../lib/hostRulesConfig.js";
9
+ import { extractRenovateLogMsg, buildDryRunHeartbeatMessage, } from "../lib/dryRunProgress.js";
9
10
  const hostRuleSchema = z
10
11
  .record(z.string(), z.unknown())
11
12
  .describe("A single Renovate hostRule (matchHost, hostType, username, password, token, …). Structure is not validated here — Renovate's own config loader checks it when the temp file is read.");
@@ -32,12 +33,38 @@ export function registerDryRun(server) {
32
33
  .optional()
33
34
  .describe("Optional per-invocation Renovate hostRules for private registry auth. Written to a mode-0600 temp file that is passed via --config-file and deleted after the run. Token/password values are scrubbed from any log output this tool returns. Appended to (not replacing) any hostRules declared in the repo's own config."),
34
35
  },
35
- }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }) => {
36
+ }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }, extra) => {
36
37
  const reportPath = path.join(tmpdir(), `renovate-mcp-report-${randomUUID()}.json`);
37
38
  const bin = resolveRenovateTool("renovate");
38
39
  const ruleList = hostRules ?? [];
39
40
  const secrets = collectSecrets(ruleList);
40
41
  let hostRulesConfigPath;
42
+ // Hybrid progress reporter: a heartbeat tick every HEARTBEAT_MS keeps
43
+ // MCP clients informed during long runs, and each tick enriches its
44
+ // message with the latest Renovate JSON-log `msg` we saw (best-effort —
45
+ // we don't couple tightly to Renovate's log schema). Skips all work if
46
+ // the caller didn't supply a progressToken.
47
+ const progressToken = extra?._meta?.progressToken;
48
+ const sendNotification = extra?.sendNotification;
49
+ const progressEnabled = progressToken !== undefined && typeof sendNotification === "function";
50
+ const HEARTBEAT_MS = 5_000;
51
+ const startedAt = Date.now();
52
+ let progressCounter = 0;
53
+ let lastLogMsg;
54
+ let heartbeat;
55
+ const emit = (message) => {
56
+ if (!progressEnabled)
57
+ return;
58
+ progressCounter += 1;
59
+ void sendNotification({
60
+ method: "notifications/progress",
61
+ params: {
62
+ progressToken,
63
+ progress: progressCounter,
64
+ message: scrubSecrets(message, secrets),
65
+ },
66
+ }).catch(() => undefined);
67
+ };
41
68
  try {
42
69
  const args = [
43
70
  "--platform=local",
@@ -49,11 +76,26 @@ export function registerDryRun(server) {
49
76
  hostRulesConfigPath = await writeHostRulesConfig(ruleList);
50
77
  args.push(`--config-file=${hostRulesConfigPath}`);
51
78
  }
79
+ if (progressEnabled) {
80
+ emit(`Starting Renovate dry-run (${dryRunMode})`);
81
+ heartbeat = setInterval(() => emit(buildDryRunHeartbeatMessage(Date.now() - startedAt, lastLogMsg)), HEARTBEAT_MS);
82
+ }
52
83
  const result = await run(bin, args, {
53
84
  cwd: repoPath,
54
85
  env: { LOG_LEVEL: logLevel, LOG_FORMAT: "json" },
55
86
  timeoutMs,
87
+ onStdoutLine: progressEnabled
88
+ ? (line) => {
89
+ const msg = extractRenovateLogMsg(line);
90
+ if (msg)
91
+ lastLogMsg = msg;
92
+ }
93
+ : undefined,
56
94
  });
95
+ if (heartbeat) {
96
+ clearInterval(heartbeat);
97
+ heartbeat = undefined;
98
+ }
57
99
  let report = null;
58
100
  try {
59
101
  const raw = await fs.readFile(reportPath, "utf8");
@@ -83,6 +125,11 @@ export function registerDryRun(server) {
83
125
  .join("\n");
84
126
  summary.logTail = tail;
85
127
  }
128
+ if (progressEnabled) {
129
+ emit(report
130
+ ? `Dry-run complete (exit ${result.exitCode})`
131
+ : `Dry-run finished without a report (exit ${result.exitCode})`);
132
+ }
86
133
  return {
87
134
  content: [
88
135
  {
@@ -105,6 +152,8 @@ export function registerDryRun(server) {
105
152
  };
106
153
  }
107
154
  finally {
155
+ if (heartbeat)
156
+ clearInterval(heartbeat);
108
157
  await fs.unlink(reportPath).catch(() => undefined);
109
158
  if (hostRulesConfigPath) {
110
159
  await fs.unlink(hostRulesConfigPath).catch(() => undefined);
@@ -35,13 +35,20 @@ export function registerPreviewCustomManager(server) {
35
35
  inputSchema: {
36
36
  repoPath: z.string().describe("Absolute path to the repository root"),
37
37
  manager: managerSchema.describe("A single Renovate customManagers entry. NOTE: `fileMatch` is an array of REGEX strings matched against POSIX-style relative paths (not globs)."),
38
- maxFilesScanned: z
38
+ maxFilesWalked: z
39
39
  .number()
40
40
  .int()
41
41
  .positive()
42
42
  .max(100_000)
43
43
  .optional()
44
- .describe("Safety cap on files walked (default 2000)"),
44
+ .describe("Safety cap on files visited during the directory walk before any fileMatch testing (default 2000). Raise this when the repo is large; prefer narrowing via `.gitignore` first."),
45
+ maxFilesMatched: z
46
+ .number()
47
+ .int()
48
+ .positive()
49
+ .max(100_000)
50
+ .optional()
51
+ .describe("Safety cap on the number of files included in the result after fileMatch is applied (default 500). Raise this only if the fileMatch regex is intentionally broad."),
45
52
  maxHitsPerFile: z
46
53
  .number()
47
54
  .int()
@@ -49,8 +56,22 @@ export function registerPreviewCustomManager(server) {
49
56
  .max(10_000)
50
57
  .optional()
51
58
  .describe("Safety cap on matches per file (default 100)"),
59
+ matchTimeoutMs: z
60
+ .number()
61
+ .int()
62
+ .positive()
63
+ .max(60_000)
64
+ .optional()
65
+ .describe("Wall-clock budget per regex operation, in milliseconds (default 2000). User-supplied patterns run in a worker thread so catastrophic backtracking can't pin the server; this cap decides how long we wait before aborting a single pattern and emitting a warning."),
66
+ maxFileBytes: z
67
+ .number()
68
+ .int()
69
+ .positive()
70
+ .max(1024 * 1024 * 1024)
71
+ .optional()
72
+ .describe("Per-file size cap in bytes (default 5242880 = 5 MiB). Files whose size exceeds this are skipped with a warning instead of being read into memory — protects against OOM when fileMatch catches a lockfile, generated artifact, or other oversized file."),
52
73
  },
53
- }, async ({ repoPath, manager, maxFilesScanned, maxHitsPerFile }) => {
74
+ }, async ({ repoPath, manager, maxFilesWalked, maxFilesMatched, maxHitsPerFile, matchTimeoutMs, maxFileBytes, }) => {
54
75
  if (manager.customType !== "regex") {
55
76
  return {
56
77
  isError: true,
@@ -64,8 +85,11 @@ export function registerPreviewCustomManager(server) {
64
85
  }
65
86
  try {
66
87
  const result = await previewCustomManager(repoPath, manager, {
67
- maxFilesScanned,
88
+ maxFilesWalked,
89
+ maxFilesMatched,
68
90
  maxHitsPerFile,
91
+ matchTimeoutMs,
92
+ maxFileBytes,
69
93
  });
70
94
  return {
71
95
  content: [
@@ -58,52 +58,58 @@ export function registerWriteConfig(server) {
58
58
  const tmp = `${target}.renovate-mcp-tmp`;
59
59
  await fs.mkdir(path.dirname(target), { recursive: true });
60
60
  await fs.writeFile(tmp, payload);
61
- let valid = false;
62
- let validationOutput = "";
63
- let validatorMissing = false;
64
61
  try {
65
- const bin = resolveRenovateTool("renovate-config-validator");
66
- const v = await run(bin, [tmp], { timeoutMs: 30_000 });
67
- validationOutput = (v.stdout + v.stderr).trim();
68
- valid = v.exitCode === 0;
69
- }
70
- catch (err) {
71
- validatorMissing = true;
72
- validationOutput = formatMissingBinaryError("renovate-config-validator", err);
73
- }
74
- if (!valid && !force) {
75
- await fs.unlink(tmp).catch(() => undefined);
62
+ let valid = false;
63
+ let validationOutput = "";
64
+ let validatorMissing = false;
65
+ try {
66
+ const bin = resolveRenovateTool("renovate-config-validator");
67
+ const v = await run(bin, [tmp], { timeoutMs: 30_000 });
68
+ validationOutput = (v.stdout + v.stderr).trim();
69
+ valid = v.exitCode === 0;
70
+ }
71
+ catch (err) {
72
+ validatorMissing = true;
73
+ validationOutput = formatMissingBinaryError("renovate-config-validator", err);
74
+ }
75
+ if (!valid && !force) {
76
+ return {
77
+ isError: true,
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: JSON.stringify({
82
+ wrote: false,
83
+ reason: validatorMissing ? "validator-unavailable" : "validation-failed",
84
+ validationOutput,
85
+ hint: validatorMissing
86
+ ? "Install renovate-config-validator (or set RENOVATE_CONFIG_VALIDATOR_BIN), then retry. Pass force=true to skip validation entirely."
87
+ : "Pass force=true to write anyway.",
88
+ }, null, 2),
89
+ },
90
+ ],
91
+ };
92
+ }
93
+ await fs.rename(tmp, target);
76
94
  return {
77
- isError: true,
78
95
  content: [
79
96
  {
80
97
  type: "text",
81
98
  text: JSON.stringify({
82
- wrote: false,
83
- reason: validatorMissing ? "validator-unavailable" : "validation-failed",
84
- validationOutput,
85
- hint: validatorMissing
86
- ? "Install renovate-config-validator (or set RENOVATE_CONFIG_VALIDATOR_BIN), then retry. Pass force=true to skip validation entirely."
87
- : "Pass force=true to write anyway.",
99
+ wrote: true,
100
+ path: rel,
101
+ bytes: payload.length,
102
+ valid,
103
+ validationOutput: valid ? undefined : validationOutput,
88
104
  }, null, 2),
89
105
  },
90
106
  ],
91
107
  };
92
108
  }
93
- await fs.rename(tmp, target);
94
- return {
95
- content: [
96
- {
97
- type: "text",
98
- text: JSON.stringify({
99
- wrote: true,
100
- path: rel,
101
- bytes: payload.length,
102
- valid,
103
- validationOutput: valid ? undefined : validationOutput,
104
- }, null, 2),
105
- },
106
- ],
107
- };
109
+ finally {
110
+ // No-op when rename already moved tmp (ENOENT); otherwise cleans up on
111
+ // validation failure or a mid-flight rename error (see #57).
112
+ await fs.unlink(tmp).catch(() => undefined);
113
+ }
108
114
  });
109
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,8 @@
20
20
  "pretest": "npm run build",
21
21
  "test": "vitest run",
22
22
  "test:watch": "vitest",
23
+ "pretest:coverage": "npm run build",
24
+ "test:coverage": "vitest run --coverage",
23
25
  "generate:presets": "node scripts/generate-presets.mjs",
24
26
  "prepack": "npm run build && chmod +x dist/index.js"
25
27
  },
@@ -56,6 +58,7 @@
56
58
  },
57
59
  "devDependencies": {
58
60
  "@types/node": "^24.0.0",
61
+ "@vitest/coverage-v8": "^4.1.0",
59
62
  "renovate": "^43.139.5",
60
63
  "typescript": "^6.0.0",
61
64
  "vitest": "^4.1.0"