renovate-mcp 0.7.0 → 0.8.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
@@ -22,7 +22,7 @@ An MCP server for designing [Renovate](https://github.com/renovatebot/renovate)
22
22
 
23
23
  ## What it does
24
24
 
25
- Ten tools plus a preset reference:
25
+ Eleven tools plus a preset reference:
26
26
 
27
27
  | Tool | Purpose |
28
28
  | --- | --- |
@@ -30,6 +30,7 @@ Ten tools plus a preset reference:
30
30
  | `get_version` | Report the renovate-mcp server version and whether it's a released build (running from `node_modules`) or a local/dev build (typically launched via `command: node` against a checkout). |
31
31
  | `read_config` | Locate and parse a repo's Renovate config (`renovate.json`, `renovate.json5`, `.renovaterc*`, `package.json#renovate`, …) in priority order. |
32
32
  | `resolve_config` | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true`. |
33
+ | `explain_config` | Inverse of `resolve_config`: walk the same preset tree but annotate every leaf field with the chain of presets that touched it. Each leaf is `{ value, setBy }` where `setBy` lists every contribution in merge order — last entry wins for scalars; for arrays each entry adds its own slice. Same offline-by-default behaviour and same `externalPresets` / `endpoint` / `platform` opt-ins as `resolve_config`. |
33
34
  | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — shows file/line hits and extracted dep info. Offline. |
34
35
  | `validate_config` | Run `renovate-config-validator` against a file or inline object. |
35
36
  | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses — malformed `/…/` regex patterns plus unknown manager names in `matchManagers` / `excludeManagers`. Offline. |
@@ -46,7 +47,7 @@ See [Design notes](#design-notes) for implementation details (timeouts, safety c
46
47
 
47
48
  - **Not a Renovate replacement.** This server doesn't open PRs, run scheduled updates, or execute in CI — it's a design-time companion for a local `renovate.json`. Use the real Renovate for the actual dependency-update pipeline.
48
49
  - **`resolve_config` is preview-quality.** Preset expansion runs against a committed snapshot, and template substitution implements only positional `{{argN}}` placeholders — non-positional tokens and Handlebars helpers are flagged in `warnings` and pass through verbatim. For authoritative output, run `dry_run`.
49
- - **`preview_custom_manager` is a subset of Renovate's regex manager.** It covers `customType: "regex"`, `matchStringsStrategy: "any"`, and `{{groupName}}` template substitution only — other custom types, other strategies, and full Handlebars (helpers, conditionals) are not implemented. Use it for fast regex iteration; confirm with `dry_run`.
50
+ - **`preview_custom_manager` is a subset of Renovate's regex manager.** It covers `customType: "regex"`, `matchStringsStrategy` of `any` / `combination` / `recursive`, and `{{groupName}}` template substitution only — other custom types and full Handlebars (helpers, conditionals) are not implemented. Use it for fast regex iteration; confirm with `dry_run`.
50
51
  - **`validate_config` / `dry_run` aren't exercised in CI.** CI deliberately doesn't install Renovate, so tools that shell out to it are only covered by unit/integration tests that don't require the binary. Run them locally against the Renovate install you intend to deploy with.
51
52
 
52
53
  ## Requirements
@@ -162,6 +163,7 @@ Once the server is wired up, try prompts like these. They're written for Claude
162
163
 
163
164
  - "Read the Renovate config in this repo and summarize what it actually does — expand every preset so I can see the real effective behavior."
164
165
  - "Resolve my config and list anything that landed in `presetsUnresolved`, with the reason for each."
166
+ - "Why is my `prCreation` set to `not-pending`? Use `explain_config` to trace which preset set it."
165
167
 
166
168
  **Browsing presets**
167
169
 
@@ -230,6 +232,8 @@ The preset catalogue at `src/data/presets.generated.ts` and the manager-name lis
230
232
 
231
233
  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.
232
234
 
235
+ `.github/workflows/claude.yml` is maintainer tooling: it lets the repo owner trigger [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action) by mentioning `@claude` in an issue, issue comment, PR review, or PR review comment. It's gated on `sender.login == repository_owner`, so mentions from anyone else are ignored. The workflow needs the `CLAUDE_CODE_OAUTH_TOKEN` secret on the repo; outside contributors and forks do not need any Anthropic credentials to work on this project.
236
+
233
237
  ## Release flow
234
238
 
235
239
  Releases are automated via [release-please](https://github.com/googleapis/release-please) (see `.github/workflows/release.yaml`):
@@ -257,6 +261,7 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
257
261
  - `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.
258
262
  - `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.
259
263
  - 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.
264
+ - `explain_config` reuses the same expansion machinery as `resolve_config` (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so the two can't drift — if `resolve_config` resolves a preset, `explain_config` does too. The annotated layer threads each preset body's leaves through a parallel `mergeAnnotated` that preserves contributors instead of dropping them, then pins each contribution with the source name (literal `extends` entry, or `<own>` for the user's input config) and a `via` chain naming every parent preset traversed to reach it. Arrays accumulate every contribution in `setBy` and concat their `value`s; scalars list every contribution in merge order with the last as the winner. `merge_quality: "preview"` carries the same disclaimer as `resolve_config` — Renovate's rule-specific semantics for `hostRules`, `regexManagers`, and certain boolean flags aren't modelled.
260
265
  - `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 the `RENOVATE_CONFIG_FILE` env var (the CLI has no `--config-file` flag), and deleted in a `finally` block. Token/password values — including the platform `token` input — are scrubbed from the detected `problems` list and the `logTail` fallback before returning.
261
266
  - `dry_run` defaults to `--platform=local` so no host token is required, but that mode can't resolve `local>` presets (they have no platform context to expand against) and silently hides non-default-host GitHub/GitLab setups. When the caller doesn't pass a `platform` input, the tool first falls back to `RENOVATE_PLATFORM` from the MCP server's env (if it's one of `local`/`github`/`gitlab`) before defaulting to `local` — without that fallback, the wrapper would unconditionally pass `--platform=local` and silently override an env var the user had set in their `mcp.json`. Passing `platform` + `endpoint` + `token` + `repository` (or relying on the `RENOVATE_PLATFORM` / `RENOVATE_ENDPOINT` env vars) switches the run to a real platform client so the preset-fetch path works end-to-end; `--dry-run` is still set, so no PRs are opened. The repository is passed to Renovate as a positional argument because the CLI has no `--repository` flag. `endpoint` and `token` are also forwarded in the default `platform=local` mode so `gitlab>…` / `github>…` preset shortcuts (which are otherwise hardcoded to gitlab.com/github.com) can be redirected at a self-hosted host. As a guard against the silent-failure mode, `dry_run` preflight-checks the repo's config: if it extends any `local>…` preset while the effective platform is `local`, the tool fails fast with specific remediation rather than spawning a Renovate run that would opaquely report `config-validation`. The preflight is skipped for `dryRunMode=extract` so manifest-only extraction can still be attempted.
262
267
  - `dry_run` returns a top-level `ok` boolean that is `false` whenever the CLI exited non-zero OR the structured report contains a validation/error-level problem (`level >= 40`, `message === "config-validation"`, or a non-empty `validationError` field). Renovate frequently writes exit-code 0 alongside report-level failures — trusting the exit code alone hides runs that did nothing.
@@ -3,6 +3,6 @@ export interface GeneratedPreset {
3
3
  description: string | null;
4
4
  body: Record<string, unknown>;
5
5
  }
6
- export declare const RENOVATE_VERSION = "43.139.5";
6
+ export declare const RENOVATE_VERSION = "43.141.5";
7
7
  export declare const PRESETS: Record<string, GeneratedPreset>;
8
8
  export declare const PRESET_NAMES: readonly string[];
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED by scripts/generate-presets.mjs from renovate@43.139.5.
2
2
  // Do not edit by hand. Run `npm run generate:presets` to refresh.
3
- export const RENOVATE_VERSION = "43.139.5";
3
+ export const RENOVATE_VERSION = "43.141.5";
4
4
  export const PRESETS = {
5
5
  "abandonments:recommended": {
6
6
  "namespace": "abandonments",
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { registerReadConfig } from "./tools/readConfig.js";
6
6
  import { registerValidateConfig } from "./tools/validateConfig.js";
7
7
  import { registerLintConfig } from "./tools/lintConfig.js";
8
8
  import { registerResolveConfig } from "./tools/resolveConfig.js";
9
+ import { registerExplainConfig } from "./tools/explainConfig.js";
9
10
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
10
11
  import { registerDryRun } from "./tools/dryRun.js";
11
12
  import { registerDryRunDiff } from "./tools/dryRunDiff.js";
@@ -20,12 +21,13 @@ const BASE_INSTRUCTIONS = [
20
21
  "Workflow:",
21
22
  " 1. read_config — inspect the current config in a repo",
22
23
  " 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
23
- " 3. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
24
- " 4. validate_config — check a proposed config against Renovate's schema",
25
- " 5. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
26
- " 6. dry_run — preview what Renovate would actually do (no PRs)",
27
- " 7. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
28
- " 8. write_config — save the agreed-upon config (validates first)",
24
+ " 3. explain_config — inverse of resolve_config: trace which preset set each field (offline)",
25
+ " 4. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
26
+ " 5. validate_config — check a proposed config against Renovate's schema",
27
+ " 6. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
28
+ " 7. dry_run — preview what Renovate would actually do (no PRs)",
29
+ " 8. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
30
+ " 9. write_config — save the agreed-upon config (validates first)",
29
31
  "",
30
32
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
31
33
  "Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
@@ -41,6 +43,7 @@ const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION },
41
43
  registerCheckSetup(server);
42
44
  registerReadConfig(server);
43
45
  registerResolveConfig(server);
46
+ registerExplainConfig(server);
44
47
  registerPreviewCustomManager(server);
45
48
  registerValidateConfig(server);
46
49
  registerLintConfig(server);
@@ -0,0 +1,64 @@
1
+ import { type PresetWarning, type ResolveOptions, type UnresolvedPreset } from "./presetResolver.js";
2
+ /**
3
+ * Sentinel used as the `source` of contributions that come from the user's own
4
+ * input config (i.e. the keys siblings of `extends`, not from any preset).
5
+ * Distinguishable from any real preset name because angle brackets cannot
6
+ * appear in a Renovate preset reference.
7
+ */
8
+ export declare const OWN_SOURCE = "<own>";
9
+ export interface Contribution {
10
+ /**
11
+ * Where the value came from. `<own>` for the user's input config; otherwise
12
+ * the preset reference exactly as it appeared in `extends` (e.g.
13
+ * `config:recommended`, `:automergeAll`, `github>acme/renovate-config`).
14
+ */
15
+ source: string;
16
+ /**
17
+ * Path of presets that brought this contribution in, outermost first. Empty
18
+ * when the value comes from `<own>` or from a top-level entry in the user's
19
+ * `extends`. For nested presets, lists every parent preset between the
20
+ * user's root and `source`.
21
+ */
22
+ via: string[];
23
+ /**
24
+ * The exact value `source` contributed at the point of the merge — for
25
+ * scalars this is the value `source` set, before any later preset/own
26
+ * override; for arrays this is just the slice `source` added.
27
+ */
28
+ value: unknown;
29
+ }
30
+ export type AnnotatedNode = AnnotatedLeaf | AnnotatedObject;
31
+ export interface AnnotatedLeaf {
32
+ /**
33
+ * Final merged value of this field. Last `setBy` entry is the winner for
34
+ * scalars; for arrays, every contribution's `value` is concatenated into
35
+ * `value`.
36
+ */
37
+ value: unknown;
38
+ /**
39
+ * Contributions in merge order. The last entry is the winner for scalars;
40
+ * for arrays each entry adds its own slice to `value`.
41
+ */
42
+ setBy: Contribution[];
43
+ }
44
+ export type AnnotatedObject = {
45
+ [key: string]: AnnotatedNode;
46
+ };
47
+ export interface ExplainResult {
48
+ explanation: AnnotatedObject;
49
+ presetsResolved: string[];
50
+ presetsUnresolved: UnresolvedPreset[];
51
+ warnings: PresetWarning[];
52
+ }
53
+ /**
54
+ * Inverse of `resolveConfig`: walk the same preset tree but, instead of
55
+ * collapsing everything into a flat resolved object, annotate each leaf field
56
+ * with the chain of presets that touched it. The last entry in `setBy` is the
57
+ * winner; `value` is what `resolveConfig` would have returned.
58
+ *
59
+ * Sharing as much as possible with `presetResolver`'s expansion logic
60
+ * (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so
61
+ * the two tools stay consistent — if `resolve_config` resolves a preset,
62
+ * `explain_config` does too, by definition.
63
+ */
64
+ export declare function explainConfig(config: Record<string, unknown>, options?: ResolveOptions): Promise<ExplainResult>;
@@ -0,0 +1,132 @@
1
+ import { applyArgs, loadPresetBody, parsePreset, recordTemplateWarnings, } from "./presetResolver.js";
2
+ /**
3
+ * Sentinel used as the `source` of contributions that come from the user's own
4
+ * input config (i.e. the keys siblings of `extends`, not from any preset).
5
+ * Distinguishable from any real preset name because angle brackets cannot
6
+ * appear in a Renovate preset reference.
7
+ */
8
+ export const OWN_SOURCE = "<own>";
9
+ /**
10
+ * Inverse of `resolveConfig`: walk the same preset tree but, instead of
11
+ * collapsing everything into a flat resolved object, annotate each leaf field
12
+ * with the chain of presets that touched it. The last entry in `setBy` is the
13
+ * winner; `value` is what `resolveConfig` would have returned.
14
+ *
15
+ * Sharing as much as possible with `presetResolver`'s expansion logic
16
+ * (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so
17
+ * the two tools stay consistent — if `resolve_config` resolves a preset,
18
+ * `explain_config` does too, by definition.
19
+ */
20
+ export async function explainConfig(config, options = {}) {
21
+ const presetsResolved = [];
22
+ const presetsUnresolved = [];
23
+ const warnings = [];
24
+ const stack = [];
25
+ const ctx = {
26
+ fetchExternal: options.fetchExternal ?? false,
27
+ timeoutMs: options.timeoutMs,
28
+ endpoint: options.endpoint,
29
+ platform: options.platform,
30
+ cache: new Map(),
31
+ };
32
+ const annotated = await expandAnnotated(config, null, [], presetsResolved, presetsUnresolved, warnings, stack, ctx);
33
+ // The user's input is always a JSON object, so the root annotation is always
34
+ // an object, not a leaf. The cast is safe; falling back to `{}` is just
35
+ // defence in depth.
36
+ const explanation = isAnnotatedLeaf(annotated) ? {} : annotated;
37
+ return { explanation, presetsResolved, presetsUnresolved, warnings };
38
+ }
39
+ async function expandAnnotated(input, ownerName, viaChain, resolvedList, unresolvedList, warningsList, stack, ctx) {
40
+ let accumulated = {};
41
+ const rawExtends = input.extends;
42
+ if (Array.isArray(rawExtends) && rawExtends.length > 0) {
43
+ // Children of `input` are reached by going through `ownerName` (if any).
44
+ // The user's root has no name; for it `viaChain` and `childVia` are equal.
45
+ const childVia = ownerName === null ? viaChain : [...viaChain, ownerName];
46
+ for (const entry of rawExtends) {
47
+ if (typeof entry !== "string") {
48
+ unresolvedList.push({
49
+ preset: String(entry),
50
+ reason: "Preset entry must be a string.",
51
+ });
52
+ continue;
53
+ }
54
+ const parsed = parsePreset(entry);
55
+ if (stack.includes(parsed.key)) {
56
+ unresolvedList.push({
57
+ preset: entry,
58
+ reason: `Cycle detected: ${[...stack, parsed.key].join(" → ")}`,
59
+ });
60
+ continue;
61
+ }
62
+ const body = await loadPresetBody(parsed, ctx, unresolvedList);
63
+ if (!body)
64
+ continue;
65
+ const { value, missingArgs, unknownTemplates } = applyArgs(body, parsed.args);
66
+ recordTemplateWarnings(entry, parsed.args.length, missingArgs, unknownTemplates, warningsList);
67
+ stack.push(parsed.key);
68
+ const subNode = await expandAnnotated(value, entry, childVia, resolvedList, unresolvedList, warningsList, stack, ctx);
69
+ stack.pop();
70
+ accumulated = mergeAnnotated(accumulated, subNode);
71
+ resolvedList.push(entry);
72
+ }
73
+ }
74
+ const { extends: _drop, ...ownKeys } = input;
75
+ const source = ownerName ?? OWN_SOURCE;
76
+ const ownAnnotated = annotateValue(ownKeys, source, viaChain);
77
+ return mergeAnnotated(accumulated, ownAnnotated);
78
+ }
79
+ function annotateValue(value, source, via) {
80
+ if (isPlainObject(value)) {
81
+ const out = {};
82
+ for (const [k, v] of Object.entries(value)) {
83
+ out[k] = annotateValue(v, source, via);
84
+ }
85
+ return out;
86
+ }
87
+ return { value, setBy: [{ source, via: [...via], value }] };
88
+ }
89
+ /**
90
+ * Mirrors `mergeConfig` in `presetResolver.ts`: arrays concat, plain objects
91
+ * recurse, everything else `b` wins. The annotated layer additionally keeps
92
+ * each contribution in `setBy` so callers can see the full chain that produced
93
+ * the final value.
94
+ */
95
+ function mergeAnnotated(a, b) {
96
+ const aIsLeaf = isAnnotatedLeaf(a);
97
+ const bIsLeaf = isAnnotatedLeaf(b);
98
+ if (!aIsLeaf && !bIsLeaf) {
99
+ const out = { ...a };
100
+ for (const [k, bChild] of Object.entries(b)) {
101
+ const existing = out[k];
102
+ out[k] = existing ? mergeAnnotated(existing, bChild) : bChild;
103
+ }
104
+ return out;
105
+ }
106
+ if (aIsLeaf && bIsLeaf) {
107
+ if (Array.isArray(a.value) && Array.isArray(b.value)) {
108
+ return {
109
+ value: [...a.value, ...b.value],
110
+ setBy: [...a.setBy, ...b.setBy],
111
+ };
112
+ }
113
+ return {
114
+ value: b.value,
115
+ setBy: [...a.setBy, ...b.setBy],
116
+ };
117
+ }
118
+ // Mixed leaf / object — `b` wins entirely (matches `mergeConfig`'s "b wins
119
+ // unless both plain objects" rule). `a`'s contributions to the discarded
120
+ // shape are dropped, mirroring the resolved config losing them.
121
+ return b;
122
+ }
123
+ function isAnnotatedLeaf(node) {
124
+ if (typeof node !== "object" || node === null)
125
+ return false;
126
+ if (!("value" in node) || !("setBy" in node))
127
+ return false;
128
+ return Array.isArray(node.setBy);
129
+ }
130
+ function isPlainObject(v) {
131
+ return typeof v === "object" && v !== null && !Array.isArray(v);
132
+ }
@@ -27,9 +27,7 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
27
27
  const maxHitsPerFile = options.maxHitsPerFile ?? DEFAULT_MAX_HITS_PER_FILE;
28
28
  const matchTimeoutMs = options.matchTimeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
29
29
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
30
- if (manager.matchStringsStrategy && manager.matchStringsStrategy !== "any") {
31
- warnings.push(`matchStringsStrategy='${manager.matchStringsStrategy}' is not yet supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
32
- }
30
+ const strategy = resolveStrategy(manager.matchStringsStrategy, warnings);
33
31
  // Surface malformed user regexes eagerly, before we do any filesystem work.
34
32
  // The Worker path otherwise reports these as generic worker errors.
35
33
  for (const src of manager.fileMatch)
@@ -91,35 +89,162 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
91
89
  warnings.push(`Could not read ${rel}: ${err.message}`);
92
90
  continue;
93
91
  }
94
- let perFileHits = 0;
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) {
103
- if (perFileHits >= maxHitsPerFile) {
104
- warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
105
- break;
106
- }
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));
116
- perFileHits++;
117
- }
118
- if (perFileHits >= maxHitsPerFile)
92
+ const fileResult = await applyMatchStrings(rel, content, manager, strategy, matchTimeoutMs, maxHitsPerFile);
93
+ hits.push(...fileResult.hits);
94
+ extractedDeps.push(...fileResult.deps);
95
+ warnings.push(...fileResult.warnings);
96
+ }
97
+ return { filesWalked, filesMatched, hits, extractedDeps, warnings };
98
+ }
99
+ function resolveStrategy(raw, warnings) {
100
+ if (raw === undefined || raw === "any")
101
+ return "any";
102
+ if (raw === "combination" || raw === "recursive")
103
+ return raw;
104
+ warnings.push(`matchStringsStrategy='${raw}' is not supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
105
+ return "any";
106
+ }
107
+ async function applyMatchStrings(rel, content, manager, strategy, matchTimeoutMs, maxHitsPerFile) {
108
+ if (strategy === "combination") {
109
+ return applyCombination(rel, content, manager, matchTimeoutMs);
110
+ }
111
+ if (strategy === "recursive") {
112
+ return applyRecursive(rel, content, manager, matchTimeoutMs, maxHitsPerFile);
113
+ }
114
+ return applyAny(rel, content, manager, matchTimeoutMs, maxHitsPerFile);
115
+ }
116
+ async function applyAny(rel, content, manager, matchTimeoutMs, maxHitsPerFile) {
117
+ const hits = [];
118
+ const deps = [];
119
+ const warnings = [];
120
+ let perFileHits = 0;
121
+ for (let i = 0; i < manager.matchStrings.length; i++) {
122
+ const source = manager.matchStrings[i];
123
+ const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
124
+ if (res.timedOut) {
125
+ 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.`);
126
+ continue;
127
+ }
128
+ for (const m of res.matches) {
129
+ if (perFileHits >= maxHitsPerFile) {
130
+ warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
119
131
  break;
132
+ }
133
+ const line = lineNumberAt(content, m.index);
134
+ hits.push({
135
+ file: rel,
136
+ matchStringIndex: i,
137
+ line,
138
+ match: m.match,
139
+ groups: m.groups,
140
+ });
141
+ deps.push(buildExtractedDep(rel, line, m.groups, manager));
142
+ perFileHits++;
120
143
  }
144
+ if (perFileHits >= maxHitsPerFile)
145
+ break;
146
+ }
147
+ return { hits, deps, warnings };
148
+ }
149
+ /**
150
+ * Mirrors Renovate's `handleCombination`: every matchString must hit at least
151
+ * once for the file to produce a dep; all matches' named groups are merged
152
+ * (later matches override earlier on key conflicts) into a single dep. We still
153
+ * surface every match as a `hit` so the user can see which lines contributed,
154
+ * but `extractedDeps` collapses to one entry per file.
155
+ */
156
+ async function applyCombination(rel, content, manager, matchTimeoutMs) {
157
+ const hits = [];
158
+ const warnings = [];
159
+ const perStringMatches = [];
160
+ for (let i = 0; i < manager.matchStrings.length; i++) {
161
+ const source = manager.matchStrings[i];
162
+ const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
163
+ if (res.timedOut) {
164
+ warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; combination strategy could not produce a dep for this file. Simplify the regex or raise matchTimeoutMs.`);
165
+ return { hits: [], deps: [], warnings };
166
+ }
167
+ if (res.matches.length === 0) {
168
+ // Combination requires every matchString to hit at least once.
169
+ return { hits: [], deps: [], warnings };
170
+ }
171
+ perStringMatches.push(res.matches);
172
+ for (const m of res.matches) {
173
+ hits.push({
174
+ file: rel,
175
+ matchStringIndex: i,
176
+ line: lineNumberAt(content, m.index),
177
+ match: m.match,
178
+ groups: m.groups,
179
+ });
180
+ }
181
+ }
182
+ // Merge groups in match order across all matchStrings (later overrides earlier).
183
+ const mergedGroups = {};
184
+ for (const matches of perStringMatches) {
185
+ for (const m of matches)
186
+ Object.assign(mergedGroups, m.groups);
187
+ }
188
+ // Anchor the dep at the first match's line so the user can navigate to the file.
189
+ const firstHit = hits[0];
190
+ const deps = [buildExtractedDep(rel, firstHit.line, mergedGroups, manager)];
191
+ return { hits, deps, warnings };
192
+ }
193
+ /**
194
+ * Mirrors Renovate's `handleRecursive`: matchStrings[0] runs against the file,
195
+ * each match's text becomes the input for matchStrings[1], and so on. At the
196
+ * leaf level (after the last matchString), the merged groups from every level
197
+ * become a dep. Outer-level matches contribute groups but are not themselves
198
+ * deps; only the leaves are. `hits` reports the leaf-level matches with the
199
+ * merged groups, which matches the `any` strategy's "one hit per dep" shape.
200
+ */
201
+ async function applyRecursive(rel, content, manager, matchTimeoutMs, maxHitsPerFile) {
202
+ const state = {
203
+ rel,
204
+ manager,
205
+ originalContent: content,
206
+ matchTimeoutMs,
207
+ maxHitsPerFile,
208
+ hits: [],
209
+ deps: [],
210
+ warnings: [],
211
+ capped: false,
212
+ };
213
+ await recurse(state, content, 0, 0, {});
214
+ return { hits: state.hits, deps: state.deps, warnings: state.warnings };
215
+ }
216
+ async function recurse(state, content, baseOffset, index, combinedGroups) {
217
+ if (state.capped)
218
+ return;
219
+ const { manager, rel } = state;
220
+ if (index === manager.matchStrings.length) {
221
+ if (state.deps.length >= state.maxHitsPerFile) {
222
+ state.warnings.push(`${rel}: capped at ${state.maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
223
+ state.capped = true;
224
+ return;
225
+ }
226
+ const line = lineNumberAt(state.originalContent, baseOffset);
227
+ state.hits.push({
228
+ file: rel,
229
+ matchStringIndex: manager.matchStrings.length - 1,
230
+ line,
231
+ match: content,
232
+ groups: combinedGroups,
233
+ });
234
+ state.deps.push(buildExtractedDep(rel, line, combinedGroups, manager));
235
+ return;
236
+ }
237
+ const source = manager.matchStrings[index];
238
+ const res = await runMatchAllInWorker(source, "gm", content, state.matchTimeoutMs);
239
+ if (res.timedOut) {
240
+ state.warnings.push(`${rel}: matchStrings[${index}] /${source}/ exceeded ${state.matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex or raise matchTimeoutMs.`);
241
+ return;
242
+ }
243
+ for (const m of res.matches) {
244
+ if (state.capped)
245
+ return;
246
+ await recurse(state, m.match, baseOffset + m.index, index + 1, { ...combinedGroups, ...m.groups });
121
247
  }
122
- return { filesWalked, filesMatched, hits, extractedDeps, warnings };
123
248
  }
124
249
  function buildExtractedDep(file, line, groups, manager) {
125
250
  const dep = { file, line };
@@ -1,3 +1,4 @@
1
+ import { type FetchResult } from "./externalPresetFetcher.js";
1
2
  export interface UnresolvedPreset {
2
3
  preset: string;
3
4
  reason: string;
@@ -76,6 +77,13 @@ export interface ParsedPreset {
76
77
  */
77
78
  unresolvableReason?: string;
78
79
  }
80
+ export interface ExpandContext {
81
+ fetchExternal: boolean;
82
+ timeoutMs: number | undefined;
83
+ endpoint: string | undefined;
84
+ platform: FetchPlatform | undefined;
85
+ cache: Map<string, Promise<FetchResult>>;
86
+ }
79
87
  /**
80
88
  * Resolve a Renovate config by expanding every preset in `extends`. Built-in
81
89
  * presets resolve offline against the committed catalogue; external presets
@@ -83,4 +91,14 @@ export interface ParsedPreset {
83
91
  * otherwise they land in `presetsUnresolved` with a network reason.
84
92
  */
85
93
  export declare function resolveConfig(config: Record<string, unknown>, options?: ResolveOptions): Promise<ResolveResult>;
94
+ export declare function recordTemplateWarnings(entry: string, suppliedArgs: number, missingArgs: Set<number>, unknownTemplates: Set<string>, warningsList: PresetWarning[]): void;
95
+ export declare function loadPresetBody(parsed: ParsedPreset, ctx: ExpandContext, unresolvedList: UnresolvedPreset[]): Promise<Record<string, unknown> | null>;
86
96
  export declare function parsePreset(raw: string): ParsedPreset;
97
+ export interface ApplyArgsResult {
98
+ value: unknown;
99
+ /** Indices referenced by `{{argN}}` where N ≥ args.length. */
100
+ missingArgs: Set<number>;
101
+ /** Non-argN tokens (e.g. `{{packageRules}}`, block helpers) left verbatim. */
102
+ unknownTemplates: Set<string>;
103
+ }
104
+ export declare function applyArgs(value: unknown, args: string[]): ApplyArgsResult;
@@ -99,7 +99,7 @@ async function expand(input, resolvedList, unresolvedList, warningsList, stack,
99
99
  const { extends: _drop, ...ownKeys } = input;
100
100
  return mergeConfig(accumulated, ownKeys);
101
101
  }
102
- function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplates, warningsList) {
102
+ export function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplates, warningsList) {
103
103
  for (const idx of [...missingArgs].sort((a, b) => a - b)) {
104
104
  warningsList.push({
105
105
  preset: entry,
@@ -117,7 +117,7 @@ function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplat
117
117
  });
118
118
  }
119
119
  }
120
- async function loadPresetBody(parsed, ctx, unresolvedList) {
120
+ export async function loadPresetBody(parsed, ctx, unresolvedList) {
121
121
  if (parsed.unresolvableReason) {
122
122
  unresolvedList.push({ preset: parsed.original, reason: parsed.unresolvableReason });
123
123
  return null;
@@ -241,7 +241,7 @@ function parseExternal(rest, original, args, sourceRaw) {
241
241
  key += `#${ref}`;
242
242
  return { key, original, args, source, repoPath, presetName, subpath, ref };
243
243
  }
244
- function applyArgs(value, args) {
244
+ export function applyArgs(value, args) {
245
245
  const missingArgs = new Set();
246
246
  const unknownTemplates = new Set();
247
247
  const out = applyArgsInner(value, args, missingArgs, unknownTemplates);
@@ -1,4 +1,4 @@
1
- export declare const SERVER_VERSION = "0.7.0";
1
+ export declare const SERVER_VERSION = "0.8.0";
2
2
  export type BuildMode = "local" | "released";
3
3
  export interface VersionInfo {
4
4
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- export const SERVER_VERSION = "0.7.0"; // x-release-please-version
2
+ export const SERVER_VERSION = "0.8.0"; // x-release-please-version
3
3
  // "released" = the server is running from a `node_modules/` install (global
4
4
  // `npm i -g`, project-local `npm i`, or `npx renovate-mcp`). Anything else —
5
5
  // most commonly an MCP config of `{"command": "node", "args": [".../dist/index.js"]}`
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerExplainConfig(server: McpServer): void;
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import { locateConfig } from "../lib/configLocations.js";
3
+ import { explainConfig } from "../lib/configExplainer.js";
4
+ const MERGE_QUALITY = "preview";
5
+ const MERGE_DISCLAIMER = "Same simplified merge as resolve_config (arrays concat, objects merge, scalars overwrite). Renovate's rule-specific semantics for hostRules, regexManagers, and some boolean flags are not modelled — explanations of those keys are only as accurate as the merge.";
6
+ export function registerExplainConfig(server) {
7
+ server.registerTool("explain_config", {
8
+ title: "Explain which preset set each field",
9
+ description: "Inverse of resolve_config: walk the same preset tree, but annotate every leaf field with the chain of presets that touched it. Each leaf in `explanation` carries `{ value, setBy }` where `setBy` lists every contribution in merge order — last entry wins for scalars; for arrays each entry adds its own slice. The `<own>` source means the value came from the user's input config (siblings of `extends`); other sources are preset references as written in `extends`. Use this to trace surprises like \"why is `prCreation` set to 'not-pending'?\". Pure analysis: same offline-by-default behaviour as resolve_config, plus the same `externalPresets` / `endpoint` / `platform` opt-ins. Pass `repoPath` (reads the repo's config) or `configContent` (an inline config). For full-fidelity output, run dry_run instead.",
10
+ inputSchema: {
11
+ repoPath: z
12
+ .string()
13
+ .optional()
14
+ .describe("Absolute path to the repository root. The tool will locate the repo's renovate config automatically."),
15
+ configContent: z
16
+ .record(z.string(), z.unknown())
17
+ .optional()
18
+ .describe("Inline config object to explain — use instead of repoPath."),
19
+ externalPresets: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("When true, fetch external presets (github>, gitlab>) over HTTPS — same credentials and behaviour as resolve_config. Default false."),
23
+ endpoint: z
24
+ .string()
25
+ .optional()
26
+ .describe("API base URL for github>/gitlab> fetches. Use for GitHub Enterprise or self-hosted GitLab — same semantics as resolve_config."),
27
+ platform: z
28
+ .enum(["github", "gitlab"])
29
+ .optional()
30
+ .describe("Platform flavour of `endpoint`. When set, `local>owner/repo` presets are routed through the same endpoint — same semantics as resolve_config."),
31
+ },
32
+ }, async ({ repoPath, configContent, externalPresets, endpoint, platform }) => {
33
+ if (!repoPath && !configContent) {
34
+ return {
35
+ isError: true,
36
+ content: [
37
+ { type: "text", text: "Provide either repoPath or configContent." },
38
+ ],
39
+ };
40
+ }
41
+ let source;
42
+ let sourcePath;
43
+ if (configContent) {
44
+ source = configContent;
45
+ }
46
+ else {
47
+ const located = await locateConfig(repoPath);
48
+ if (!located) {
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: `No Renovate configuration found in ${repoPath}.`,
54
+ },
55
+ ],
56
+ };
57
+ }
58
+ source = located.config;
59
+ sourcePath = located.relPath;
60
+ }
61
+ const { explanation, presetsResolved, presetsUnresolved, warnings } = await explainConfig(source, {
62
+ fetchExternal: externalPresets ?? false,
63
+ endpoint,
64
+ platform,
65
+ });
66
+ return {
67
+ content: [
68
+ {
69
+ type: "text",
70
+ text: JSON.stringify({
71
+ ...(sourcePath ? { path: sourcePath } : {}),
72
+ explanation,
73
+ mergeQuality: MERGE_QUALITY,
74
+ disclaimer: MERGE_DISCLAIMER,
75
+ presetsResolved,
76
+ presetsUnresolved,
77
+ warnings,
78
+ }, null, 2),
79
+ },
80
+ ],
81
+ };
82
+ });
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "test:coverage": "vitest run --coverage",
25
25
  "generate:presets": "node scripts/generate-presets.mjs",
26
26
  "generate:managers": "node scripts/generate-managers.mjs",
27
- "prepack": "npm run build && chmod +x dist/index.js"
27
+ "prepack": "npm run build && node -e \"if (!require('fs').readFileSync('dist/index.js','utf8').startsWith('#!')) { console.error('dist/index.js missing shebang'); process.exit(1); }\" && chmod +x dist/index.js"
28
28
  },
29
29
  "keywords": [
30
30
  "mcp",