renovate-mcp 0.3.0 → 0.4.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
@@ -4,27 +4,38 @@ An MCP server for designing [Renovate](https://github.com/renovatebot/renovate)
4
4
 
5
5
  ## What it does
6
6
 
7
- Seven tools plus a preset reference:
7
+ Eight tools plus a preset reference:
8
8
 
9
9
  | Tool | What it does |
10
10
  | --- | --- |
11
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
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 `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN`). 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. |
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
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. |
15
15
  | `validate_config` | Run `renovate-config-validator` against a file or inline object. |
16
- | `dry_run` | Run Renovate with `--platform=local --dry-run`, return the structured JSON report (no PRs, no pushes). |
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). |
17
18
  | `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
18
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. |
19
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. |
20
21
  | `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
21
22
 
23
+ ## What this is NOT
24
+
25
+ - **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.
26
+ - **`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`.
27
+ - **`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`.
28
+ - **`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.
29
+
22
30
  ## Requirements
23
31
 
24
32
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
25
- - 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`) work without it.
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.
26
34
  - Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
27
- - Optional for `resolve_config` with `externalPresets: true`: `GITHUB_TOKEN` / `GITLAB_TOKEN` (or `RENOVATE_TOKEN` as a fallback) for fetching presets from private repos or to avoid rate limits. 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. Any env vars the tool does read 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.
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.
28
39
 
29
40
  ## Install
30
41
 
@@ -111,6 +122,32 @@ Once the server is wired up, try prompts like these. They're written for Claude
111
122
  - "Do a dry run and show me which PRs Renovate would open — no pushes."
112
123
  - "Add `:semanticCommits` to my `extends`, validate it, and save back to `renovate.json`."
113
124
 
125
+ ## Example session
126
+
127
+ A transcript-style walkthrough: design a Dockerfile custom manager from scratch, validate it, dry-run, and save. Turns are abbreviated — your client will show the actual tool-call JSON.
128
+
129
+ > **You:** I've got `# renovate: datasource=docker depName=<image>` comments above `FROM` lines in my Dockerfiles. Draft a `customManagers` entry and preview it against this repo.
130
+ >
131
+ > **Claude** calls `preview_custom_manager` with a first-draft `fileMatch` + `matchStrings`.
132
+ > → 4 Dockerfiles matched `fileMatch`, 0 lines matched `matchStrings`. The regex anchored on `ARG`, but the Dockerfiles use `FROM`.
133
+ >
134
+ > **You:** Rewrite `matchStrings` to anchor on the renovate comment, then `FROM <image>:<version>` on the next line.
135
+ >
136
+ > **Claude** calls `preview_custom_manager` again with the fixed regex.
137
+ > → 4 files, 4 line hits. Extracted: `postgres:15.3-alpine`, `redis:7.2`, `nginx:1.25`, `node:20.11`. Named groups `depName` and `currentValue` populated on every hit.
138
+ >
139
+ > **You:** Good. Validate the full config inline, with this `customManagers` entry alongside my existing `extends`.
140
+ >
141
+ > **Claude** calls `validate_config` with the inline config. → valid.
142
+ >
143
+ > **You:** Now dry-run so I can see what Renovate would actually open.
144
+ >
145
+ > **Claude** calls `dry_run`. → 2 updates: `postgres` 15.3-alpine → 15.5-alpine, `redis` 7.2 → 7.4. No entries in `problems`.
146
+ >
147
+ > **You:** Save it.
148
+ >
149
+ > **Claude** calls `write_config` on `renovate.json`. → validated, written atomically.
150
+
114
151
  ## Development
115
152
 
116
153
  ```bash
@@ -142,9 +179,14 @@ Secrets required on the repo: `RELEASE_PLEASE_TOKEN` (a PAT for release-please).
142
179
 
143
180
  ## Design notes
144
181
 
182
+ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); this section covers implementation decisions behind the scope that *is* supported.
183
+
145
184
  - `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).
146
- - `resolve_config` and `preview_custom_manager` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
185
+ - `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
+ - `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.
147
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.
148
188
  - `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.
149
- - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout.
189
+ - `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
+ - 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
+ - `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.
150
192
  - `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
@@ -4,12 +4,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { registerCheckSetup } from "./tools/checkSetup.js";
5
5
  import { registerReadConfig } from "./tools/readConfig.js";
6
6
  import { registerValidateConfig } from "./tools/validateConfig.js";
7
+ import { registerLintConfig } from "./tools/lintConfig.js";
7
8
  import { registerResolveConfig } from "./tools/resolveConfig.js";
8
9
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
9
10
  import { registerDryRun } from "./tools/dryRun.js";
10
11
  import { registerWriteConfig } from "./tools/writeConfig.js";
11
12
  import { registerPresetResources } from "./resources/presets.js";
12
- import { checkSetup, describeSetup } from "./lib/setupCheck.js";
13
+ import { checkSetup, startupBanner } from "./lib/setupCheck.js";
13
14
  const BASE_INSTRUCTIONS = [
14
15
  "Design and debug Renovate configurations interactively.",
15
16
  "",
@@ -18,23 +19,28 @@ const BASE_INSTRUCTIONS = [
18
19
  " 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
19
20
  " 3. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
20
21
  " 4. validate_config — check a proposed config against Renovate's schema",
21
- " 5. dry_run — preview what Renovate would actually do (no PRs)",
22
- " 6. write_config — save the agreed-upon config (validates first)",
22
+ " 5. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
23
+ " 6. dry_run — preview what Renovate would actually do (no PRs)",
24
+ " 7. write_config — save the agreed-upon config (validates first)",
23
25
  "",
24
26
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
25
27
  "Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
26
28
  ].join("\n");
27
29
  const setup = await checkSetup();
28
- const instructions = setup.ok
29
- ? BASE_INSTRUCTIONS
30
- : [BASE_INSTRUCTIONS, "", "Startup setup check:", describeSetup(setup)].join("\n");
31
- const SERVER_VERSION = "0.3.0"; // x-release-please-version
30
+ // `RENOVATE_MCP_REQUIRE_CLI=false` is the opt-out for users who have
31
+ // consciously chosen the offline subset and don't want the startup notice.
32
+ const requireCliRaw = process.env.RENOVATE_MCP_REQUIRE_CLI?.trim().toLowerCase();
33
+ const suppressBanner = requireCliRaw === "false" || requireCliRaw === "0";
34
+ const banner = suppressBanner ? null : startupBanner(setup);
35
+ const instructions = banner ? [BASE_INSTRUCTIONS, "", banner].join("\n") : BASE_INSTRUCTIONS;
36
+ const SERVER_VERSION = "0.4.0"; // x-release-please-version
32
37
  const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
33
38
  registerCheckSetup(server);
34
39
  registerReadConfig(server);
35
40
  registerResolveConfig(server);
36
41
  registerPreviewCustomManager(server);
37
42
  registerValidateConfig(server);
43
+ registerLintConfig(server);
38
44
  registerDryRun(server);
39
45
  registerWriteConfig(server);
40
46
  registerPresetResources(server);
@@ -0,0 +1,8 @@
1
+ export type LintRuleId = "dead-regex-missing-slash" | "unwrapped-regex";
2
+ export interface LintFinding {
3
+ ruleId: LintRuleId;
4
+ path: string;
5
+ value: string;
6
+ message: string;
7
+ }
8
+ export declare function lintConfig(config: unknown): LintFinding[];
@@ -0,0 +1,74 @@
1
+ const REGEX_AWARE_FIELDS = new Set([
2
+ "matchPackageNames",
3
+ "matchDepNames",
4
+ "matchSourceUrls",
5
+ "matchCurrentVersion",
6
+ ]);
7
+ export function lintConfig(config) {
8
+ const findings = [];
9
+ walk(config, "", findings);
10
+ return findings;
11
+ }
12
+ function walk(node, pathStr, findings) {
13
+ if (Array.isArray(node)) {
14
+ node.forEach((item, i) => walk(item, `${pathStr}[${i}]`, findings));
15
+ return;
16
+ }
17
+ if (node && typeof node === "object") {
18
+ for (const [key, value] of Object.entries(node)) {
19
+ const childPath = pathStr ? `${pathStr}.${key}` : key;
20
+ if (REGEX_AWARE_FIELDS.has(key)) {
21
+ if (Array.isArray(value)) {
22
+ value.forEach((entry, i) => {
23
+ if (typeof entry === "string") {
24
+ checkPattern(entry, `${childPath}[${i}]`, findings);
25
+ }
26
+ });
27
+ }
28
+ else if (typeof value === "string") {
29
+ checkPattern(value, childPath, findings);
30
+ }
31
+ }
32
+ else {
33
+ walk(value, childPath, findings);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ function checkPattern(raw, path, findings) {
39
+ // `!` is Renovate's exclusion prefix; strip it before checking regex wrapping.
40
+ const stripped = raw.startsWith("!") ? raw.slice(1) : raw;
41
+ // Too short to meaningfully be a /…/ regex or trigger a metachar rule.
42
+ if (stripped.length < 2)
43
+ return;
44
+ const startsSlash = stripped.startsWith("/");
45
+ const endsSlash = stripped.endsWith("/");
46
+ if (startsSlash !== endsSlash) {
47
+ findings.push({
48
+ ruleId: "dead-regex-missing-slash",
49
+ path,
50
+ value: raw,
51
+ message: startsSlash
52
+ ? "Value starts with '/' but does not end with '/'. Renovate will treat this as an exact-match string, not a regex. Add the trailing '/' if a regex match was intended, otherwise remove the leading '/'."
53
+ : "Value ends with '/' but does not start with '/'. Renovate will treat this as an exact-match string, not a regex. Add the leading '/' if a regex match was intended, otherwise remove the trailing '/'.",
54
+ });
55
+ return;
56
+ }
57
+ if (startsSlash && endsSlash)
58
+ return;
59
+ if (hasStrongRegexSignal(stripped)) {
60
+ findings.push({
61
+ ruleId: "unwrapped-regex",
62
+ path,
63
+ value: raw,
64
+ message: `Value contains regex metacharacters but is not wrapped in '/…/'. Renovate will treat it as an exact-match string. Wrap it as '/${stripped}/' (or '!/${stripped}/' to negate) if a regex match was intended.`,
65
+ });
66
+ }
67
+ }
68
+ function hasStrongRegexSignal(s) {
69
+ return (/\\[dwsbDWSB]/.test(s) ||
70
+ /\.[+*?]/.test(s) ||
71
+ /\\\./.test(s) ||
72
+ /\(\?[:!=<]/.test(s) ||
73
+ /\[[^\]]+\]/.test(s));
74
+ }
@@ -46,14 +46,14 @@ async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
46
46
  const ref = parsed.ref ?? "HEAD";
47
47
  const apiBase = endpoint ?? DEFAULT_GITHUB_API_BASE;
48
48
  const url = `${apiBase}/repos/${parsed.repoPath}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
49
- const token = process.env.GITHUB_TOKEN || process.env.RENOVATE_TOKEN;
49
+ const credential = resolveCredential(["RENOVATE_TOKEN", "GITHUB_TOKEN"]);
50
50
  const headers = {
51
51
  Accept: "application/vnd.github.raw",
52
52
  "User-Agent": "renovate-mcp",
53
53
  };
54
- if (token)
55
- headers.Authorization = `Bearer ${token}`;
56
- return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl);
54
+ if (credential.token)
55
+ headers.Authorization = `Bearer ${credential.token}`;
56
+ return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "github", credential);
57
57
  }
58
58
  async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
59
59
  if (!parsed.repoPath) {
@@ -63,11 +63,19 @@ async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
63
63
  const ref = parsed.ref ?? "HEAD";
64
64
  const apiBase = endpoint ?? DEFAULT_GITLAB_API_BASE;
65
65
  const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
66
- const token = process.env.GITLAB_TOKEN || process.env.RENOVATE_TOKEN;
66
+ const credential = resolveCredential(["RENOVATE_TOKEN", "GITLAB_TOKEN"]);
67
67
  const headers = { "User-Agent": "renovate-mcp" };
68
- if (token)
69
- headers["PRIVATE-TOKEN"] = token;
70
- return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl);
68
+ if (credential.token)
69
+ headers["PRIVATE-TOKEN"] = credential.token;
70
+ return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "gitlab", credential);
71
+ }
72
+ function resolveCredential(vars) {
73
+ for (const envVar of vars) {
74
+ const value = process.env[envVar];
75
+ if (value)
76
+ return { envVar, token: value, triedVars: vars };
77
+ }
78
+ return { envVar: null, token: undefined, triedVars: vars };
71
79
  }
72
80
  function presetFileName(parsed) {
73
81
  if (parsed.subpath)
@@ -82,12 +90,48 @@ function ensureJson(name) {
82
90
  function encodeFilePath(file) {
83
91
  return file.split("/").map(encodeURIComponent).join("/");
84
92
  }
85
- async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl) {
93
+ function detectRateLimit(res, platform, presetName) {
94
+ if (platform === "github" && res.status === 403) {
95
+ if (res.headers.get("x-ratelimit-remaining") !== "0")
96
+ return undefined;
97
+ const resetIso = parseEpochHeader(res.headers.get("x-ratelimit-reset"));
98
+ const resetClause = resetIso ? ` (resets at ${resetIso})` : "";
99
+ return `GitHub API rate limit exceeded${resetClause} when fetching ${presetName}. Set GITHUB_TOKEN for an authenticated limit, or wait for reset.`;
100
+ }
101
+ if (platform === "gitlab" && res.status === 429) {
102
+ const resetIso = parseEpochHeader(res.headers.get("ratelimit-reset"));
103
+ const resetClause = resetIso ? ` (resets at ${resetIso})` : "";
104
+ return `GitLab API rate limit exceeded${resetClause} when fetching ${presetName}. Set GITLAB_TOKEN for an authenticated limit, or wait for reset.`;
105
+ }
106
+ return undefined;
107
+ }
108
+ function parseEpochHeader(raw) {
109
+ if (!raw)
110
+ return undefined;
111
+ const seconds = Number.parseInt(raw, 10);
112
+ if (!Number.isFinite(seconds) || seconds <= 0)
113
+ return undefined;
114
+ const date = new Date(seconds * 1000);
115
+ if (Number.isNaN(date.getTime()))
116
+ return undefined;
117
+ return date.toISOString();
118
+ }
119
+ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl, platform, credential) {
86
120
  const controller = new AbortController();
87
121
  const timer = setTimeout(() => controller.abort(), timeoutMs);
88
122
  try {
89
123
  const res = await fetchImpl(url, { headers, signal: controller.signal });
90
124
  if (!res.ok) {
125
+ const rateLimit = detectRateLimit(res, platform, presetName);
126
+ if (rateLimit)
127
+ return { ok: false, reason: rateLimit };
128
+ if (res.status === 401 || res.status === 403) {
129
+ const body = await safeReadText(res);
130
+ return {
131
+ ok: false,
132
+ reason: formatAuthFailure(res.status, presetName, url, credential, body),
133
+ };
134
+ }
91
135
  return {
92
136
  ok: false,
93
137
  reason: `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""} when fetching ${presetName}`,
@@ -128,3 +172,30 @@ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl) {
128
172
  clearTimeout(timer);
129
173
  }
130
174
  }
175
+ const AUTH_BODY_MAX = 500;
176
+ async function safeReadText(res) {
177
+ try {
178
+ return await res.text();
179
+ }
180
+ catch {
181
+ return "";
182
+ }
183
+ }
184
+ function formatAuthFailure(status, presetName, url, credential, body) {
185
+ const lines = [`HTTP ${status} when fetching ${presetName}`];
186
+ lines.push(` URL: ${url}`);
187
+ lines.push(` Credential: ${formatCredential(credential)}`);
188
+ const trimmed = body.trim();
189
+ if (trimmed) {
190
+ const snippet = trimmed.length > AUTH_BODY_MAX
191
+ ? `${trimmed.slice(0, AUTH_BODY_MAX)}… [truncated]`
192
+ : trimmed;
193
+ lines.push(` Response: ${snippet}`);
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+ function formatCredential(credential) {
198
+ if (credential.envVar)
199
+ return `${credential.envVar} (present)`;
200
+ return `none (tried ${credential.triedVars.join(", ")})`;
201
+ }
@@ -0,0 +1,24 @@
1
+ export type HostRule = Record<string, unknown>;
2
+ /**
3
+ * Write a throwaway Renovate config containing just `{ hostRules: [...] }` to
4
+ * `os.tmpdir()` with mode 0600, and return the absolute path. The caller is
5
+ * responsible for unlinking the file (in a `finally` block) once Renovate has
6
+ * consumed it — we do not keep a reference here.
7
+ *
8
+ * Renovate merges CLI `--config-file` rules with any repo-level `hostRules`
9
+ * (array concatenation, most-specific match wins), so callers can combine
10
+ * per-invocation credentials with whatever the repo itself already declares.
11
+ */
12
+ export declare function writeHostRulesConfig(hostRules: HostRule[]): Promise<string>;
13
+ /**
14
+ * Pull the token/password values out of the provided hostRules so callers can
15
+ * scrub them from log output. Usernames are intentionally not treated as
16
+ * secrets — Renovate echoes them in diagnostics and they are not typically
17
+ * sensitive on their own.
18
+ */
19
+ export declare function collectSecrets(hostRules: HostRule[]): string[];
20
+ /**
21
+ * Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
22
+ * No-op when `secrets` is empty.
23
+ */
24
+ export declare function scrubSecrets(text: string, secrets: string[]): string;
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ /**
6
+ * Write a throwaway Renovate config containing just `{ hostRules: [...] }` to
7
+ * `os.tmpdir()` with mode 0600, and return the absolute path. The caller is
8
+ * responsible for unlinking the file (in a `finally` block) once Renovate has
9
+ * consumed it — we do not keep a reference here.
10
+ *
11
+ * Renovate merges CLI `--config-file` rules with any repo-level `hostRules`
12
+ * (array concatenation, most-specific match wins), so callers can combine
13
+ * per-invocation credentials with whatever the repo itself already declares.
14
+ */
15
+ export async function writeHostRulesConfig(hostRules) {
16
+ const tmpPath = path.join(tmpdir(), `renovate-mcp-hostrules-${randomUUID()}.json`);
17
+ const body = JSON.stringify({ hostRules }, null, 2);
18
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
19
+ return tmpPath;
20
+ }
21
+ const SECRET_KEYS = ["token", "password"];
22
+ /**
23
+ * Pull the token/password values out of the provided hostRules so callers can
24
+ * scrub them from log output. Usernames are intentionally not treated as
25
+ * secrets — Renovate echoes them in diagnostics and they are not typically
26
+ * sensitive on their own.
27
+ */
28
+ export function collectSecrets(hostRules) {
29
+ const out = new Set();
30
+ for (const rule of hostRules) {
31
+ if (!rule || typeof rule !== "object")
32
+ continue;
33
+ for (const key of SECRET_KEYS) {
34
+ const value = rule[key];
35
+ if (typeof value === "string" && value.length > 0) {
36
+ out.add(value);
37
+ }
38
+ }
39
+ }
40
+ return [...out];
41
+ }
42
+ /**
43
+ * Replace every literal occurrence of any secret in `text` with `[REDACTED]`.
44
+ * No-op when `secrets` is empty.
45
+ */
46
+ export function scrubSecrets(text, secrets) {
47
+ if (!secrets.length)
48
+ 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;
56
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Scan Renovate's log output for signals that a lookup silently failed due to
3
+ * registry auth. Renovate does not always fail loudly when a private registry
4
+ * rejects a request — managers are detected, but the `updates` array comes
5
+ * back empty with no warning on the structured report. This heuristic catches
6
+ * the common cases so the caller can distinguish "no updates available" from
7
+ * "couldn't auth to the registry."
8
+ *
9
+ * We try to parse each line as a Renovate JSON log entry (emitted when
10
+ * LOG_FORMAT=json) and also fall back to plain-text scanning, since some
11
+ * errors surface as uncaught messages on stderr before the JSON logger takes
12
+ * over. Output is deduplicated and capped so this never blows up the caller's
13
+ * context.
14
+ */
15
+ export interface LookupProblem {
16
+ message: string;
17
+ context?: string;
18
+ }
19
+ export declare function detectLookupProblems(log: string): LookupProblem[];
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Scan Renovate's log output for signals that a lookup silently failed due to
3
+ * registry auth. Renovate does not always fail loudly when a private registry
4
+ * rejects a request — managers are detected, but the `updates` array comes
5
+ * back empty with no warning on the structured report. This heuristic catches
6
+ * the common cases so the caller can distinguish "no updates available" from
7
+ * "couldn't auth to the registry."
8
+ *
9
+ * We try to parse each line as a Renovate JSON log entry (emitted when
10
+ * LOG_FORMAT=json) and also fall back to plain-text scanning, since some
11
+ * errors surface as uncaught messages on stderr before the JSON logger takes
12
+ * over. Output is deduplicated and capped so this never blows up the caller's
13
+ * context.
14
+ */
15
+ const AUTH_PATTERNS = [
16
+ /(?:^|[^\d])(401|403)(?:[^\d]|$)/,
17
+ /\bunauthori[sz]ed\b/i,
18
+ /\bauthentication (?:required|failed)\b/i,
19
+ /\brequires authentication\b/i,
20
+ /\binvalid credentials\b/i,
21
+ /\bcould not authenticate\b/i,
22
+ /\baccess denied\b/i,
23
+ ];
24
+ const MAX_MESSAGE_LENGTH = 300;
25
+ const MAX_PROBLEMS = 10;
26
+ function matchesAuthPattern(text) {
27
+ return AUTH_PATTERNS.some((re) => re.test(text));
28
+ }
29
+ function truncate(s) {
30
+ if (s.length <= MAX_MESSAGE_LENGTH)
31
+ return s;
32
+ return `${s.slice(0, MAX_MESSAGE_LENGTH - 1)}…`;
33
+ }
34
+ function extractContext(parsed) {
35
+ const parts = [];
36
+ const datasource = parsed.datasource;
37
+ const packageName = parsed.packageName ?? parsed.depName;
38
+ const hostname = parsed.hostname ?? parsed.host ?? parsed.url;
39
+ if (typeof datasource === "string")
40
+ parts.push(`datasource=${datasource}`);
41
+ if (typeof packageName === "string")
42
+ parts.push(`package=${packageName}`);
43
+ if (typeof hostname === "string")
44
+ parts.push(`host=${hostname}`);
45
+ return parts.length ? parts.join(" ") : undefined;
46
+ }
47
+ export function detectLookupProblems(log) {
48
+ const problems = [];
49
+ const seen = new Set();
50
+ for (const rawLine of log.split(/\r?\n/)) {
51
+ const line = rawLine.trim();
52
+ if (!line)
53
+ continue;
54
+ if (problems.length >= MAX_PROBLEMS)
55
+ break;
56
+ let parsed = null;
57
+ if (line.startsWith("{")) {
58
+ try {
59
+ const obj = JSON.parse(line);
60
+ if (obj && typeof obj === "object") {
61
+ parsed = obj;
62
+ }
63
+ }
64
+ catch {
65
+ // Fall through to plain-text handling.
66
+ }
67
+ }
68
+ let message;
69
+ let context;
70
+ if (parsed) {
71
+ const msg = typeof parsed.msg === "string" ? parsed.msg : "";
72
+ const err = parsed.err;
73
+ const errMsg = err && typeof err.message === "string" ? err.message : "";
74
+ const combined = [msg, errMsg].filter(Boolean).join(" — ");
75
+ if (!combined || !matchesAuthPattern(combined))
76
+ continue;
77
+ message = truncate(combined);
78
+ context = extractContext(parsed);
79
+ }
80
+ else {
81
+ if (!matchesAuthPattern(line))
82
+ continue;
83
+ message = truncate(line);
84
+ }
85
+ const dedupeKey = context ? `${context}::${message}` : message;
86
+ if (seen.has(dedupeKey))
87
+ continue;
88
+ seen.add(dedupeKey);
89
+ problems.push(context ? { message, context } : { message });
90
+ }
91
+ return problems;
92
+ }
@@ -2,10 +2,15 @@ export interface UnresolvedPreset {
2
2
  preset: string;
3
3
  reason: string;
4
4
  }
5
+ export interface PresetWarning {
6
+ preset: string;
7
+ message: string;
8
+ }
5
9
  export interface ResolveResult {
6
10
  resolved: Record<string, unknown>;
7
11
  presetsResolved: string[];
8
12
  presetsUnresolved: UnresolvedPreset[];
13
+ warnings: PresetWarning[];
9
14
  }
10
15
  export type FetchPlatform = "github" | "gitlab";
11
16
  export interface ResolveOptions {
@@ -49,6 +49,7 @@ export function classifyExternalSource(source) {
49
49
  export async function resolveConfig(config, options = {}) {
50
50
  const presetsResolved = [];
51
51
  const presetsUnresolved = [];
52
+ const warnings = [];
52
53
  const stack = [];
53
54
  const ctx = {
54
55
  fetchExternal: options.fetchExternal ?? false,
@@ -57,10 +58,10 @@ export async function resolveConfig(config, options = {}) {
57
58
  platform: options.platform,
58
59
  cache: new Map(),
59
60
  };
60
- const resolved = await expand(config, presetsResolved, presetsUnresolved, stack, ctx);
61
- return { resolved, presetsResolved, presetsUnresolved };
61
+ const resolved = await expand(config, presetsResolved, presetsUnresolved, warnings, stack, ctx);
62
+ return { resolved, presetsResolved, presetsUnresolved, warnings };
62
63
  }
63
- async function expand(input, resolvedList, unresolvedList, stack, ctx) {
64
+ async function expand(input, resolvedList, unresolvedList, warningsList, stack, ctx) {
64
65
  const rawExtends = input.extends;
65
66
  if (!Array.isArray(rawExtends) || rawExtends.length === 0) {
66
67
  const { extends: _drop, ...rest } = input;
@@ -86,9 +87,11 @@ async function expand(input, resolvedList, unresolvedList, stack, ctx) {
86
87
  const body = await loadPresetBody(parsed, ctx, unresolvedList);
87
88
  if (!body)
88
89
  continue;
89
- const substituted = applyArgs(body, parsed.args);
90
+ const { value, missingArgs, unknownTemplates } = applyArgs(body, parsed.args);
91
+ recordTemplateWarnings(entry, parsed.args.length, missingArgs, unknownTemplates, warningsList);
92
+ const substituted = value;
90
93
  stack.push(parsed.key);
91
- const subResolved = await expand(substituted, resolvedList, unresolvedList, stack, ctx);
94
+ const subResolved = await expand(substituted, resolvedList, unresolvedList, warningsList, stack, ctx);
92
95
  stack.pop();
93
96
  accumulated = mergeConfig(accumulated, subResolved);
94
97
  resolvedList.push(entry);
@@ -96,6 +99,24 @@ async function expand(input, resolvedList, unresolvedList, stack, ctx) {
96
99
  const { extends: _drop, ...ownKeys } = input;
97
100
  return mergeConfig(accumulated, ownKeys);
98
101
  }
102
+ function recordTemplateWarnings(entry, suppliedArgs, missingArgs, unknownTemplates, warningsList) {
103
+ for (const idx of [...missingArgs].sort((a, b) => a - b)) {
104
+ warningsList.push({
105
+ preset: entry,
106
+ message: `Preset references {{arg${idx}}} but only ${suppliedArgs} argument(s) were supplied; ` +
107
+ `the placeholder was substituted with an empty string. Pass more arguments, ` +
108
+ `e.g. "${entry.replace(/\([^)]*\)\s*$/, "")}(arg0, arg1, ...)".`,
109
+ });
110
+ }
111
+ for (const token of [...unknownTemplates].sort()) {
112
+ warningsList.push({
113
+ preset: entry,
114
+ message: `Preset uses template "{{${token}}}" which resolve_config does not expand ` +
115
+ `(only positional {{argN}} is supported). The token was left verbatim; ` +
116
+ `run dry_run for full-fidelity expansion via the Renovate CLI.`,
117
+ });
118
+ }
119
+ }
99
120
  async function loadPresetBody(parsed, ctx, unresolvedList) {
100
121
  if (!parsed.source) {
101
122
  const preset = PRESETS[parsed.key];
@@ -211,19 +232,35 @@ function parseExternal(rest, original, args, sourceRaw) {
211
232
  return { key, original, args, source, repoPath, presetName, subpath, ref };
212
233
  }
213
234
  function applyArgs(value, args) {
235
+ const missingArgs = new Set();
236
+ const unknownTemplates = new Set();
237
+ const out = applyArgsInner(value, args, missingArgs, unknownTemplates);
238
+ return { value: out, missingArgs, unknownTemplates };
239
+ }
240
+ const TEMPLATE_TOKEN_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
241
+ const ARG_TOKEN_RE = /^arg(\d+)$/;
242
+ function applyArgsInner(value, args, missingArgs, unknownTemplates) {
214
243
  if (typeof value === "string") {
215
- return value.replace(/\{\{\s*arg(\d+)\s*\}\}/g, (_, idx) => {
216
- const i = Number(idx);
217
- return i < args.length ? args[i] : "";
244
+ return value.replace(TEMPLATE_TOKEN_RE, (match, inner) => {
245
+ const argMatch = ARG_TOKEN_RE.exec(inner);
246
+ if (argMatch) {
247
+ const i = Number(argMatch[1]);
248
+ if (i < args.length)
249
+ return args[i];
250
+ missingArgs.add(i);
251
+ return "";
252
+ }
253
+ unknownTemplates.add(inner);
254
+ return match;
218
255
  });
219
256
  }
220
257
  if (Array.isArray(value)) {
221
- return value.map((v) => applyArgs(v, args));
258
+ return value.map((v) => applyArgsInner(v, args, missingArgs, unknownTemplates));
222
259
  }
223
260
  if (value && typeof value === "object") {
224
261
  const out = {};
225
262
  for (const [k, v] of Object.entries(value)) {
226
- out[k] = applyArgs(v, args);
263
+ out[k] = applyArgsInner(v, args, missingArgs, unknownTemplates);
227
264
  }
228
265
  return out;
229
266
  }
@@ -15,4 +15,12 @@ export interface SetupStatus {
15
15
  hints: string[];
16
16
  }
17
17
  export declare function checkSetup(): Promise<SetupStatus>;
18
+ export declare const OFFLINE_TOOLS: readonly ["read_config", "resolve_config", "preview_custom_manager"];
19
+ export declare function unavailableTools(status: SetupStatus): string[];
20
+ /**
21
+ * Concise startup banner appended to the server's MCP `instructions` so the
22
+ * LLM is primed to treat CLI-missing as a partial-availability signal rather
23
+ * than a setup error. Returns `null` when every tool is available.
24
+ */
25
+ export declare function startupBanner(status: SetupStatus): string | null;
18
26
  export declare function describeSetup(status: SetupStatus): string;
@@ -56,6 +56,39 @@ export async function checkSetup() {
56
56
  hints,
57
57
  };
58
58
  }
59
+ // Tools that do not depend on the Renovate CLI and are always callable.
60
+ export const OFFLINE_TOOLS = ["read_config", "resolve_config", "preview_custom_manager"];
61
+ // Returns the tool names that cannot currently run because their backing
62
+ // binary is missing. Order is workflow-ordered (validate → dry_run → write).
63
+ export function unavailableTools(status) {
64
+ const names = [];
65
+ if (!status.renovateConfigValidator.found)
66
+ names.push("validate_config");
67
+ if (!status.renovate.found)
68
+ names.push("dry_run");
69
+ if (!status.renovateConfigValidator.found)
70
+ names.push("write_config");
71
+ return names;
72
+ }
73
+ /**
74
+ * Concise startup banner appended to the server's MCP `instructions` so the
75
+ * LLM is primed to treat CLI-missing as a partial-availability signal rather
76
+ * than a setup error. Returns `null` when every tool is available.
77
+ */
78
+ export function startupBanner(status) {
79
+ const unavailable = unavailableTools(status);
80
+ if (unavailable.length === 0)
81
+ return null;
82
+ const unavailList = unavailable.map((n) => `\`${n}\``).join(", ");
83
+ const offlineList = OFFLINE_TOOLS.map((n) => `\`${n}\``).join(", ");
84
+ return [
85
+ "Partial availability:",
86
+ ` Renovate CLI not found — only blocks: ${unavailList}.`,
87
+ ` Offline tools (${offlineList}) still work; do not flag this as a setup problem when the task only needs those.`,
88
+ " Install Renovate (`npm i -g renovate`) before calling the blocked tools, or call `check_setup` for a full diagnostic.",
89
+ " Set `RENOVATE_MCP_REQUIRE_CLI=false` to suppress this notice if you have consciously chosen the offline subset.",
90
+ ].join("\n");
91
+ }
59
92
  export function describeSetup(status) {
60
93
  const lines = [];
61
94
  lines.push(`Node: ${status.node}`);
@@ -4,10 +4,15 @@ import { promises as fs } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
7
+ import { detectLookupProblems } from "../lib/lookupProblems.js";
8
+ import { collectSecrets, scrubSecrets, writeHostRulesConfig, } from "../lib/hostRulesConfig.js";
9
+ const hostRuleSchema = z
10
+ .record(z.string(), z.unknown())
11
+ .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.");
7
12
  export function registerDryRun(server) {
8
13
  server.registerTool("dry_run", {
9
14
  title: "Dry-run Renovate",
10
- description: "Run Renovate in dry-run mode against a local repository to preview what it would do — no PRs opened, no git pushes. Uses --platform=local so no host token is required. Emits a structured JSON report of the updates Renovate would create.",
15
+ description: "Run Renovate in dry-run mode against a local repository to preview what it would do — no PRs opened, no git pushes. Uses --platform=local so no host token is required. Emits a structured JSON report of the updates Renovate would create.\n\nCredentials for private registries (e.g. `COMPOSER_AUTH` for Packagist/Satis proxies, `NPM_TOKEN` / `.npmrc` for npm, Docker registry creds, `RENOVATE_HOST_RULES` for anything else) 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. Alternatively, encode credentials as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on this tool (written to a mode-0600 temp file that is cleaned up after the run; token/password values are scrubbed from `logTail` and `problems`). Per-call `hostRules` are appended to any the repo's own config already declares. If a lookup can't auth to a registry, Renovate often reports 0 updates without a loud error; when that happens this tool surfaces detected auth failures under `problems` in the output so callers can distinguish a genuine \"no updates\" from a silent registry-auth failure.",
11
16
  inputSchema: {
12
17
  repoPath: z.string().describe("Absolute path to the repository root"),
13
18
  dryRunMode: z
@@ -22,17 +27,29 @@ export function registerDryRun(server) {
22
27
  .max(15 * 60_000)
23
28
  .default(5 * 60_000)
24
29
  .describe("Max wait time in ms (default 5 minutes, capped at 15)"),
30
+ hostRules: z
31
+ .array(hostRuleSchema)
32
+ .optional()
33
+ .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."),
25
34
  },
26
- }, async ({ repoPath, dryRunMode, logLevel, timeoutMs }) => {
35
+ }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, hostRules }) => {
27
36
  const reportPath = path.join(tmpdir(), `renovate-mcp-report-${randomUUID()}.json`);
28
37
  const bin = resolveRenovateTool("renovate");
38
+ const ruleList = hostRules ?? [];
39
+ const secrets = collectSecrets(ruleList);
40
+ let hostRulesConfigPath;
29
41
  try {
30
- const result = await run(bin, [
42
+ const args = [
31
43
  "--platform=local",
32
44
  `--dry-run=${dryRunMode}`,
33
45
  "--report-type=file",
34
46
  `--report-path=${reportPath}`,
35
- ], {
47
+ ];
48
+ if (ruleList.length > 0) {
49
+ hostRulesConfigPath = await writeHostRulesConfig(ruleList);
50
+ args.push(`--config-file=${hostRulesConfigPath}`);
51
+ }
52
+ const result = await run(bin, args, {
36
53
  cwd: repoPath,
37
54
  env: { LOG_LEVEL: logLevel, LOG_FORMAT: "json" },
38
55
  timeoutMs,
@@ -50,10 +67,16 @@ export function registerDryRun(server) {
50
67
  hasReport: report !== null,
51
68
  report,
52
69
  };
70
+ const scrubbedStdout = scrubSecrets(result.stdout, secrets);
71
+ const scrubbedStderr = scrubSecrets(result.stderr, secrets);
72
+ const problems = detectLookupProblems(`${scrubbedStderr}\n${scrubbedStdout}`);
73
+ if (problems.length > 0) {
74
+ summary.problems = problems;
75
+ }
53
76
  // If no structured report, surface the last bit of stderr so Claude can
54
77
  // debug without blowing up the context with Renovate's verbose logs.
55
78
  if (!report) {
56
- const tail = (result.stderr || result.stdout)
79
+ const tail = (scrubbedStderr || scrubbedStdout)
57
80
  .split(/\r?\n/)
58
81
  .filter(Boolean)
59
82
  .slice(-40)
@@ -76,13 +99,16 @@ export function registerDryRun(server) {
76
99
  content: [
77
100
  {
78
101
  type: "text",
79
- text: formatMissingBinaryError("renovate", err),
102
+ text: scrubSecrets(formatMissingBinaryError("renovate", err), secrets),
80
103
  },
81
104
  ],
82
105
  };
83
106
  }
84
107
  finally {
85
108
  await fs.unlink(reportPath).catch(() => undefined);
109
+ if (hostRulesConfigPath) {
110
+ await fs.unlink(hostRulesConfigPath).catch(() => undefined);
111
+ }
86
112
  }
87
113
  });
88
114
  }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerLintConfig(server: McpServer): void;
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import JSON5 from "json5";
5
+ import { lintConfig } from "../lib/configLinter.js";
6
+ export function registerLintConfig(server) {
7
+ server.registerTool("lint_config", {
8
+ title: "Lint Renovate config for semantic footguns",
9
+ description: "Run a semantic lint pass over a Renovate config. Complements validate_config: schema validation catches structural bugs, this catches Renovate-specific footguns schema validation misses — primarily malformed '/…/' regex patterns in fields like matchPackageNames, matchDepNames, matchSourceUrls, matchCurrentVersion. Offline; does not shell out. Pass either configPath (file on disk, JSON or JSON5) or configContent (inline object).",
10
+ inputSchema: {
11
+ configPath: z
12
+ .string()
13
+ .optional()
14
+ .describe("Absolute path to a config file to lint (JSON or JSON5)"),
15
+ configContent: z
16
+ .record(z.string(), z.unknown())
17
+ .optional()
18
+ .describe("Inline config object to lint"),
19
+ },
20
+ }, async ({ configPath, configContent }) => {
21
+ if (!configPath && !configContent) {
22
+ return {
23
+ isError: true,
24
+ content: [
25
+ { type: "text", text: "Provide either configPath or configContent." },
26
+ ],
27
+ };
28
+ }
29
+ let config;
30
+ if (configContent) {
31
+ config = configContent;
32
+ }
33
+ else {
34
+ try {
35
+ const raw = await fs.readFile(configPath, "utf8");
36
+ config = path.extname(configPath).toLowerCase() === ".json5"
37
+ ? JSON5.parse(raw)
38
+ : JSON.parse(raw);
39
+ }
40
+ catch (err) {
41
+ return {
42
+ isError: true,
43
+ content: [
44
+ {
45
+ type: "text",
46
+ text: `Failed to read or parse config at ${configPath}: ${err.message}`,
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ }
52
+ const findings = lintConfig(config);
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: JSON.stringify({
58
+ clean: findings.length === 0,
59
+ findings,
60
+ }, null, 2),
61
+ },
62
+ ],
63
+ };
64
+ });
65
+ }
@@ -1,10 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { locateConfig } from "../lib/configLocations.js";
3
3
  import { resolveConfig } from "../lib/presetResolver.js";
4
+ const MERGE_QUALITY = "preview";
5
+ const MERGE_DISCLAIMER = "Preset expansion uses a simplified merge (arrays concat, objects merge, scalars overwrite). Run dry_run for authoritative output — hostRules, regexManagers, and some boolean flags merge with rule-specific semantics that are not modeled here.";
4
6
  export function registerResolveConfig(server) {
5
7
  server.registerTool("resolve_config", {
6
8
  title: "Resolve Renovate config (expand presets)",
7
- description: "Expand every preset referenced by `extends` and return the fully resolved config. Built-in presets resolve offline against the committed catalogue. Pass `externalPresets: true` to fetch `github>` and `gitlab>` presets over HTTPS (with optional `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN` for private repos). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL, e.g. `https://ghe.example.com/api/v3` or `https://gitlab.example.com/api/v4`); pass `platform` in addition to route `local>` presets through the same endpoint. `bitbucket>`, `gitea>`, and npm presets are structurally unsupported and remain in `presetsUnresolved` regardless. Endpoint and platform are **tool inputs only** — env vars like `RENOVATE_ENDPOINT` are not read, since the MCP server runs under Claude rather than in your shell. Pass either `repoPath` (reads the repo's config) or `configContent` (an inline config object).",
9
+ description: "Expand every preset referenced by `extends` and return the fully resolved config. Built-in presets resolve offline against the committed catalogue. Pass `externalPresets: true` to fetch `github>` and `gitlab>` presets over HTTPS (with optional `RENOVATE_TOKEN` — or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks — for private repos). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL, e.g. `https://ghe.example.com/api/v3` or `https://gitlab.example.com/api/v4`); pass `platform` in addition to route `local>` presets through the same endpoint. `bitbucket>`, `gitea>`, and npm presets are structurally unsupported and remain in `presetsUnresolved` regardless. Endpoint and platform are **tool inputs only** — env vars like `RENOVATE_ENDPOINT` are not read, since the MCP server runs under Claude rather than in your shell. Pass either `repoPath` (reads the repo's config) or `configContent` (an inline config object). The response includes `mergeQuality: \"preview\"` plus a `disclaimer` and a `warnings` array — preset merging here is a close approximation of Renovate's rules rather than bit-identical, and Handlebars expressions other than `{{argN}}` are left verbatim; run `dry_run` for authoritative output.",
8
10
  inputSchema: {
9
11
  repoPath: z
10
12
  .string()
@@ -17,7 +19,7 @@ export function registerResolveConfig(server) {
17
19
  externalPresets: z
18
20
  .boolean()
19
21
  .optional()
20
- .describe("When true, fetch external presets (github>, gitlab>) over HTTPS. Credentials come from GITHUB_TOKEN / GITLAB_TOKEN / RENOVATE_TOKEN env vars set on the MCP server process — 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. Default false."),
22
+ .describe("When true, fetch external presets (github>, gitlab>) over HTTPS. Credentials come from RENOVATE_TOKEN (preferred, matches Renovate's own convention) or GITHUB_TOKEN / GITLAB_TOKEN as platform-specific fallbacks, set on the MCP server process — 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. Default false."),
21
23
  endpoint: z
22
24
  .string()
23
25
  .optional()
@@ -56,7 +58,7 @@ export function registerResolveConfig(server) {
56
58
  source = located.config;
57
59
  sourcePath = located.relPath;
58
60
  }
59
- const { resolved, presetsResolved, presetsUnresolved } = await resolveConfig(source, {
61
+ const { resolved, presetsResolved, presetsUnresolved, warnings } = await resolveConfig(source, {
60
62
  fetchExternal: externalPresets ?? false,
61
63
  endpoint,
62
64
  platform,
@@ -68,8 +70,11 @@ export function registerResolveConfig(server) {
68
70
  text: JSON.stringify({
69
71
  ...(sourcePath ? { path: sourcePath } : {}),
70
72
  resolved,
73
+ mergeQuality: MERGE_QUALITY,
74
+ disclaimer: MERGE_DISCLAIMER,
71
75
  presetsResolved,
72
76
  presetsUnresolved,
77
+ warnings,
73
78
  }, null, 2),
74
79
  },
75
80
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {