renovate-mcp 0.8.0 → 0.9.2

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
@@ -36,7 +36,7 @@ Eleven tools plus a preset reference:
36
36
  | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses — malformed `/…/` regex patterns plus unknown manager names in `matchManagers` / `excludeManagers`. Offline. |
37
37
  | `dry_run` | Run Renovate with `--dry-run` and return the structured JSON report. Defaults to `--platform=local` against `repoPath`; pass `platform` + `endpoint` + `token` + `repository` to run as a real GitHub/GitLab client (needed when the config extends `local>` presets on a private host). When `platform` is not passed, the tool reads `RENOVATE_PLATFORM` from the MCP server's env before defaulting to `local`. When `token` is not passed, falls back to `RENOVATE_TOKEN` from MCP env, then to `GITLAB_TOKEN` (when `platform=gitlab`) or `GITHUB_TOKEN` (when `platform=github`) — auto-translated to `RENOVATE_TOKEN` for the spawned Renovate CLI, since Renovate itself only reads that one var. `repository` accepts GitHub-style `owner/repo` and GitLab nested-group paths like `group/subgroup/project`. `endpoint`/`token` also flow through in local mode so `gitlab>…` / `github>…` preset shortcuts can be redirected at a self-hosted host without setting up a full remote run. No PRs, no pushes. Emits MCP progress notifications during the run when the caller supplies a `progressToken`. |
38
38
  | `dry_run_diff` | Semantic diff between two `dry_run` reports — `added` / `removed` / `changed` proposed updates plus a compact text rendering. Stateless; takes both reports as inputs. Useful when iterating on a config to see exactly what each tweak did. |
39
- | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true`. |
39
+ | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true` is paired with `confirmForce: "YES_OVERRIDE_VALIDATION"` — the literal sentinel makes accidental overrides under prompt-injected tool calls harder. |
40
40
  | `renovate://presets` (resource) | Markdown index of all 1000+ built-in presets grouped by namespace. |
41
41
  | `renovate://presets/{namespace}` (resource template) | Markdown listing for a single namespace (e.g. `renovate://presets/config`) — cheaper than the full index. |
42
42
  | `renovate://preset/{name}` (resource template) | Expanded JSON body for one preset (e.g. `renovate://preset/config:recommended`). |
@@ -54,6 +54,7 @@ See [Design notes](#design-notes) for implementation details (timeouts, safety c
54
54
 
55
55
  **Required**
56
56
 
57
+ - Linux or macOS. Windows is not supported — `package.json` declares `"os": ["darwin", "linux"]`, so `npm i` surfaces an `EBADPLATFORM` warning on Windows, and the server exits with a clear stderr message at startup. Run on WSL2 or a Linux/macOS host instead.
57
58
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
58
59
  - Renovate on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` + `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`, `lint_config`) work without it.
59
60
 
@@ -230,7 +231,9 @@ npm run generate:managers # regenerate src/data/managers.generated.ts from the r
230
231
 
231
232
  The preset catalogue at `src/data/presets.generated.ts` and the manager-name list at `src/data/managers.generated.ts` are committed snapshots of Renovate's built-in presets and manager registry. Runtime code never imports the `renovate` package — only the `scripts/generate-*.mjs` scripts do. Regenerate both after bumping the `renovate` devDependency.
232
233
 
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.
234
+ CI runs `typecheck`, `build`, and `test:coverage` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`), across an OS matrix of `ubuntu-latest` and `macos-latest` with `fail-fast: false` so a platform-specific regression on either side surfaces. Coverage is uploaded as a per-run artifact from the Ubuntu job only (to avoid name collisions); no threshold is enforced yet. The Ubuntu job also re-runs `npm install --package-lock-only` and fails on a non-empty diff, catching drift between `package.json` root metadata (`os`, `engines`, `bin`, dep ranges) and `package-lock.json` that `npm ci` does not validate.
235
+
236
+ `.github/workflows/dependency-review.yml` runs [`actions/dependency-review-action`](https://github.com/actions/dependency-review-action) on every PR and fails the check when a newly introduced dependency carries a CVE of `high` severity or above. Findings also render inline in the PR's "Files changed" / Conversation tabs.
234
237
 
235
238
  `.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
239
 
@@ -241,7 +244,7 @@ Releases are automated via [release-please](https://github.com/googleapis/releas
241
244
  1. Merge Conventional Commits to `main` (`feat:`, `fix:`, etc.)
242
245
  2. release-please opens or updates a release PR that bumps the version and updates `CHANGELOG.md`
243
246
  3. Merging the release PR creates a GitHub release and tag
244
- 4. The `publish` workflow (`.github/workflows/publish.yml`) fires on the release event, runs the test suite, and runs `npm publish` with provenance
247
+ 4. The `publish` workflow (`.github/workflows/publish.yml`) fires on the release event, runs the test suite, smoke-tests the packed tarball end-to-end (install globally, then run an MCP `initialize` handshake against the resulting `renovate-mcp` binary), and only then runs `npm publish` with provenance
245
248
 
246
249
  Publishing uses [npm Trusted Publishers](https://docs.npmjs.com/trusted-publishers) — no npm token is stored in the repo. Instead the workflow authenticates to npm via OIDC using the `id-token: write` permission. Trusted Publishers must be configured on the npm package settings page before OIDC publishes will succeed; this requires the package to already exist, so the **first release must be published manually once** (`npm login && npm publish` from a clean build), after which Trusted Publishers can be wired up and all subsequent releases go through this workflow.
247
250
 
@@ -259,12 +262,15 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
259
262
  - `preview_custom_manager` runs every user-supplied regex on a `worker_threads` worker with a wall-clock budget per operation (default 2 s, configurable via `matchTimeoutMs`). Catastrophic backtracking — e.g. `^(a+)+b$` against `aaaa…c`, or `(.*)*=` against a modestly sized file — would otherwise pin the MCP server's event loop indefinitely. On timeout the worker is terminated and a warning is appended identifying which `fileMatch[i]` or `matchStrings[i]` was aborted, so the user can simplify the pattern or raise the budget.
260
263
  - `preview_custom_manager` also stats each matched file before reading it and skips anything larger than `maxFileBytes` (default 5 MiB) with a warning, so a stray lockfile, generated artifact, or SQL dump caught by a loose `fileMatch` can't OOM the server. `maxHitsPerFile` already bounds output size; this guards input size.
261
264
  - `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.
265
+ - External preset fetches refuse to follow HTTP redirects (`redirect: "manual"`) and never attach `Authorization: Bearer …` / `PRIVATE-TOKEN: …` headers to non-`https://` URLs. Following redirects with custom auth headers can leak `RENOVATE_TOKEN` / `GITHUB_TOKEN` / `GITLAB_TOKEN` cross-host (undici only strips the standard `Authorization` header on cross-*origin* redirects, not `PRIVATE-TOKEN`); rejecting them outright avoids the leak path and matches the fact that the GitHub/GitLab content APIs don't normally redirect for valid presets.
266
+ - External preset fetches cap the response body at 1 MB. A `Content-Length` over the cap is rejected up front; chunked responses without a declared length are streamed into a bounded buffer that aborts the read once the running total passes the cap. Auth-failure bodies (401/403) are read with an 8 KB cap so a misconfigured endpoint returning a giant HTML error page can't OOM the long-lived MCP process. Preset bodies are tiny in practice, so the cap is well above any legitimate value.
267
+ - The `endpoint` input on `resolve_config` / `explain_config` / `dry_run` is validated before any token-bearing request is built or forwarded to the Renovate child as `--endpoint=`. Anything that isn't an `https://` URL with a non-empty public host is refused: non-https schemes (`http:`, `file:`, `data:`, …), userinfo (`user:pass@host`), and any RFC 1918 / loopback / link-local literal (`localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` (incl. cloud-metadata), `0.0.0.0/8`, `::1`, `::`, `::ffff:*`, `fc00::/7`, `fe80::/10`). Self-hosted GitHub Enterprise / GitLab on public-DNS https URLs is unaffected. The check is string-level (no DNS) — the goal is to make a prompt-injected `endpoint` value unable to coerce a token-bearing request to an attacker-controlled or internal-only address.
262
268
  - `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.
263
269
  - 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
270
  - `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.
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.
271
+ - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout. The report path is pre-created with mode 0600 before Renovate is spawned (Renovate overwrites in place with `O_TRUNC`, preserving the mode), so the on-disk JSON is never world-readable while the run is in flight — Renovate's `problems` array can carry host-rule values that we don't want exposed under the default `umask`. The file is unlinked in a `finally` block. 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.
266
272
  - `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.
267
273
  - `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.
268
274
  - `dry_run` emits MCP progress notifications only when the caller's `tools/call` includes `_meta.progressToken` — no-op otherwise, so legacy clients see zero overhead. A 5-second heartbeat ticks while the child runs; each tick's message is best-effort enriched with the latest Renovate JSON-log `msg` seen on stdout. Notifications are also emitted at start and completion. We deliberately don't couple to Renovate's log schema beyond reading `msg`, since that schema isn't a stable API.
269
275
  - `dry_run_diff` is stateless on purpose: both reports are passed as inputs, no per-repo state is kept on the server. Updates are keyed by `(manager, packageFile, depName)` so a version bump on the same dep shows up once under `changed` rather than twice as `removed + added`. Compared per identity: `newValue`, `newVersion`, `updateType`, `branchName`, `groupName`, `schedule`. Either input can be the raw Renovate report or the full `dry_run` summary (with the `report` key) — the tool unwraps `report` automatically.
270
- - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
276
+ - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk. The `force: true` escape hatch is gated behind a separate `confirmForce: "YES_OVERRIDE_VALIDATION"` literal: a single boolean is too easy for a prompt-injected agent to flip, but emitting an unusual sentinel string is a deliberate step that's harder to navigate accidentally.
package/dist/index.js CHANGED
@@ -15,6 +15,16 @@ import { registerGetVersion } from "./tools/getVersion.js";
15
15
  import { registerPresetResources } from "./resources/presets.js";
16
16
  import { checkSetup, startupBanner } from "./lib/setupCheck.js";
17
17
  import { SERVER_VERSION } from "./lib/version.js";
18
+ import { logError } from "./lib/log.js";
19
+ // Register before any work: stdout carries JSON-RPC frames, so a stray Node-default
20
+ // log of an unhandled rejection / uncaught exception would corrupt MCP framing.
21
+ process.on("unhandledRejection", (reason) => {
22
+ logError("unhandled rejection", reason);
23
+ });
24
+ process.on("uncaughtException", (err) => {
25
+ logError("uncaught exception", err);
26
+ process.exit(1);
27
+ });
18
28
  const BASE_INSTRUCTIONS = [
19
29
  "Design and debug Renovate configurations interactively.",
20
30
  "",
@@ -32,6 +42,10 @@ const BASE_INSTRUCTIONS = [
32
42
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
33
43
  "Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
34
44
  ].join("\n");
45
+ if (process.platform === "win32") {
46
+ process.stderr.write("renovate-mcp does not support Windows. Supported platforms are Linux and macOS — see the `os` field in package.json. Run on WSL2 or a Linux/macOS host instead.\n");
47
+ process.exit(1);
48
+ }
35
49
  const setup = await checkSetup();
36
50
  // `RENOVATE_MCP_REQUIRE_CLI=false` is the opt-out for users who have
37
51
  // consciously chosen the offline subset and don't want the startup notice.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Validate a user-provided `endpoint` URL before any network call attaches
3
+ * auth headers or before it is forwarded to the Renovate child as
4
+ * `--endpoint=`. The check is intentionally string-level (no DNS): it
5
+ * refuses non-https schemes, missing/userinfo hosts, and any RFC 1918 /
6
+ * loopback / link-local literal. The point is to make a prompt-injected
7
+ * `endpoint` value unable to coerce a token-bearing request to an
8
+ * attacker-controlled or internal-only address.
9
+ *
10
+ * Throws `EndpointValidationError` with a user-facing reason on rejection.
11
+ * Call sites map that to their own error shape (`{ ok: false, reason }`,
12
+ * `isError: true`, etc.).
13
+ */
14
+ export declare class EndpointValidationError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare function validateEndpoint(endpoint: string): void;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Validate a user-provided `endpoint` URL before any network call attaches
3
+ * auth headers or before it is forwarded to the Renovate child as
4
+ * `--endpoint=`. The check is intentionally string-level (no DNS): it
5
+ * refuses non-https schemes, missing/userinfo hosts, and any RFC 1918 /
6
+ * loopback / link-local literal. The point is to make a prompt-injected
7
+ * `endpoint` value unable to coerce a token-bearing request to an
8
+ * attacker-controlled or internal-only address.
9
+ *
10
+ * Throws `EndpointValidationError` with a user-facing reason on rejection.
11
+ * Call sites map that to their own error shape (`{ ok: false, reason }`,
12
+ * `isError: true`, etc.).
13
+ */
14
+ export class EndpointValidationError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "EndpointValidationError";
18
+ }
19
+ }
20
+ export function validateEndpoint(endpoint) {
21
+ let url;
22
+ try {
23
+ url = new URL(endpoint);
24
+ }
25
+ catch {
26
+ throw new EndpointValidationError(`Invalid endpoint \`${endpoint}\`: not a parseable URL.`);
27
+ }
28
+ if (url.protocol !== "https:") {
29
+ throw new EndpointValidationError(`Invalid endpoint \`${endpoint}\`: protocol must be https: (refused ${url.protocol}). ` +
30
+ "Plain http would expose the auth token in cleartext; non-network schemes are not endpoints.");
31
+ }
32
+ if (!url.hostname) {
33
+ throw new EndpointValidationError(`Invalid endpoint \`${endpoint}\`: host is empty.`);
34
+ }
35
+ if (url.username || url.password) {
36
+ throw new EndpointValidationError(`Invalid endpoint \`${endpoint}\`: userinfo (\`user:password@host\`) is not allowed — it can mask the real authority and override credentials.`);
37
+ }
38
+ const host = stripIpv6Brackets(url.hostname).toLowerCase();
39
+ if (isPrivateOrLoopbackHost(host)) {
40
+ throw new EndpointValidationError(`Invalid endpoint \`${endpoint}\`: host \`${host}\` is in a private, loopback, or link-local range. ` +
41
+ "Refused to prevent SSRF and accidental exposure of the attached auth token to internal services. " +
42
+ "If you need a self-hosted GitHub/GitLab, use its public-DNS https URL.");
43
+ }
44
+ }
45
+ function stripIpv6Brackets(hostname) {
46
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
47
+ return hostname.slice(1, -1);
48
+ }
49
+ return hostname;
50
+ }
51
+ function isPrivateOrLoopbackHost(host) {
52
+ if (host === "localhost" || host.endsWith(".localhost"))
53
+ return true;
54
+ if (isIpv4DottedQuad(host))
55
+ return isPrivateIpv4(host);
56
+ if (host.includes(":"))
57
+ return isPrivateIpv6(host);
58
+ return false;
59
+ }
60
+ function isIpv4DottedQuad(host) {
61
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
62
+ }
63
+ function isPrivateIpv4(host) {
64
+ const octets = host.split(".").map((p) => Number.parseInt(p, 10));
65
+ if (octets.some((n) => Number.isNaN(n) || n < 0 || n > 255))
66
+ return true; // malformed → refuse
67
+ const [a, b] = octets;
68
+ if (a === 10)
69
+ return true; // 10.0.0.0/8
70
+ if (a === 127)
71
+ return true; // 127.0.0.0/8 loopback
72
+ if (a === 0)
73
+ return true; // 0.0.0.0/8 "this network"
74
+ if (a === 192 && b === 168)
75
+ return true; // 192.168.0.0/16
76
+ if (a === 169 && b === 254)
77
+ return true; // 169.254.0.0/16 link-local (incl. cloud metadata)
78
+ if (a === 172 && b >= 16 && b <= 31)
79
+ return true; // 172.16.0.0/12
80
+ return false;
81
+ }
82
+ function isPrivateIpv6(addr) {
83
+ // Anything starting with `::` is in the reserved ::/8 block: loopback (::1),
84
+ // unspecified (::), IPv4-mapped (::ffff:*), and various deprecated ranges.
85
+ // None are legitimate public endpoints, and Node's URL parser may normalise
86
+ // ::ffff:127.0.0.1 to ::ffff:7f00:1, so a literal-by-literal check is brittle.
87
+ if (addr.startsWith("::"))
88
+ return true;
89
+ // First-hextet checks: fc00::/7 (unique-local) and fe80::/10 (link-local).
90
+ const firstColon = addr.indexOf(":");
91
+ if (firstColon <= 0)
92
+ return false;
93
+ const firstHextet = addr.slice(0, firstColon);
94
+ const value = Number.parseInt(firstHextet, 16);
95
+ if (!Number.isFinite(value))
96
+ return false;
97
+ if (value >= 0xfc00 && value <= 0xfdff)
98
+ return true; // fc00::/7
99
+ if (value >= 0xfe80 && value <= 0xfebf)
100
+ return true; // fe80::/10
101
+ return false;
102
+ }
@@ -1,8 +1,11 @@
1
1
  import { resolveCredential } from "./credentialResolver.js";
2
+ import { EndpointValidationError, validateEndpoint } from "./endpointValidator.js";
2
3
  import { classifyExternalSource } from "./presetResolver.js";
3
4
  const DEFAULT_TIMEOUT_MS = 10_000;
4
5
  const DEFAULT_GITHUB_API_BASE = "https://api.github.com";
5
6
  const DEFAULT_GITLAB_API_BASE = "https://gitlab.com/api/v4";
7
+ const MAX_PRESET_BYTES = 1_000_000;
8
+ const MAX_AUTH_BODY_BYTES = 8_192;
6
9
  function trimTrailingSlash(url) {
7
10
  return url.replace(/\/+$/, "");
8
11
  }
@@ -26,7 +29,19 @@ function dispatch(parsed, options) {
26
29
  }
27
30
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
31
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
29
- const endpoint = options.endpoint ? trimTrailingSlash(options.endpoint) : undefined;
32
+ let endpoint;
33
+ if (options.endpoint) {
34
+ try {
35
+ validateEndpoint(options.endpoint);
36
+ }
37
+ catch (err) {
38
+ if (err instanceof EndpointValidationError) {
39
+ return Promise.resolve({ ok: false, reason: err.message });
40
+ }
41
+ throw err;
42
+ }
43
+ endpoint = trimTrailingSlash(options.endpoint);
44
+ }
30
45
  switch (parsed.source) {
31
46
  case "github":
32
47
  return fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint);
@@ -52,9 +67,10 @@ async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
52
67
  Accept: "application/vnd.github.raw",
53
68
  "User-Agent": "renovate-mcp",
54
69
  };
55
- if (credential.token)
70
+ const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
71
+ if (sendAuth)
56
72
  headers.Authorization = `Bearer ${credential.token}`;
57
- return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "github", credential);
73
+ return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "github", sendAuth ? credential : suppressCredential(credential));
58
74
  }
59
75
  async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
60
76
  if (!parsed.repoPath) {
@@ -66,9 +82,16 @@ async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
66
82
  const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
67
83
  const credential = resolveCredential(["RENOVATE_TOKEN", "GITLAB_TOKEN"]);
68
84
  const headers = { "User-Agent": "renovate-mcp" };
69
- if (credential.token)
85
+ const sendAuth = Boolean(credential.token) && isHttpsUrl(url);
86
+ if (sendAuth)
70
87
  headers["PRIVATE-TOKEN"] = credential.token;
71
- return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "gitlab", credential);
88
+ return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl, "gitlab", sendAuth ? credential : suppressCredential(credential));
89
+ }
90
+ function isHttpsUrl(url) {
91
+ return /^https:\/\//i.test(url);
92
+ }
93
+ function suppressCredential(credential) {
94
+ return { envVar: null, token: undefined, triedVars: credential.triedVars };
72
95
  }
73
96
  function presetFileName(parsed) {
74
97
  if (parsed.subpath)
@@ -113,13 +136,20 @@ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl, platfor
113
136
  const controller = new AbortController();
114
137
  const timer = setTimeout(() => controller.abort(), timeoutMs);
115
138
  try {
116
- const res = await fetchImpl(url, { headers, signal: controller.signal });
139
+ const res = await fetchImpl(url, {
140
+ headers,
141
+ signal: controller.signal,
142
+ redirect: "manual",
143
+ });
144
+ if (isRedirectResponse(res)) {
145
+ return { ok: false, reason: formatRedirectRefusal(presetName, url, res) };
146
+ }
117
147
  if (!res.ok) {
118
148
  const rateLimit = detectRateLimit(res, platform, presetName);
119
149
  if (rateLimit)
120
150
  return { ok: false, reason: rateLimit };
121
151
  if (res.status === 401 || res.status === 403) {
122
- const body = await safeReadText(res);
152
+ const body = await safeReadText(res, MAX_AUTH_BODY_BYTES);
123
153
  return {
124
154
  ok: false,
125
155
  reason: formatAuthFailure(res.status, presetName, url, credential, body),
@@ -130,7 +160,17 @@ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl, platfor
130
160
  reason: `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""} when fetching ${presetName}`,
131
161
  };
132
162
  }
133
- const text = await res.text();
163
+ const oversize = checkDeclaredLength(res, presetName);
164
+ if (oversize)
165
+ return oversize;
166
+ const bounded = await readBoundedText(res, MAX_PRESET_BYTES);
167
+ if (!bounded.ok) {
168
+ return {
169
+ ok: false,
170
+ reason: `Preset body for ${presetName} exceeds ${MAX_PRESET_BYTES} bytes.`,
171
+ };
172
+ }
173
+ const text = bounded.text;
134
174
  try {
135
175
  const body = JSON.parse(text);
136
176
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -166,14 +206,85 @@ async function fetchJson(url, headers, timeoutMs, presetName, fetchImpl, platfor
166
206
  }
167
207
  }
168
208
  const AUTH_BODY_MAX = 500;
169
- async function safeReadText(res) {
209
+ async function safeReadText(res, maxBytes) {
170
210
  try {
171
- return await res.text();
211
+ return await readCappedText(res, maxBytes);
172
212
  }
173
213
  catch {
174
214
  return "";
175
215
  }
176
216
  }
217
+ function checkDeclaredLength(res, presetName) {
218
+ const declared = Number.parseInt(res.headers.get("content-length") ?? "", 10);
219
+ if (Number.isFinite(declared) && declared > MAX_PRESET_BYTES) {
220
+ return {
221
+ ok: false,
222
+ reason: `Preset body for ${presetName} exceeds ${MAX_PRESET_BYTES} bytes (declared ${declared}).`,
223
+ };
224
+ }
225
+ return undefined;
226
+ }
227
+ async function readBoundedText(res, maxBytes) {
228
+ const collected = await collectBytes(res, maxBytes, "reject");
229
+ if (collected.overflow)
230
+ return { ok: false };
231
+ return { ok: true, text: new TextDecoder("utf-8").decode(collected.bytes) };
232
+ }
233
+ async function readCappedText(res, maxBytes) {
234
+ const collected = await collectBytes(res, maxBytes, "truncate");
235
+ return new TextDecoder("utf-8").decode(collected.bytes);
236
+ }
237
+ async function collectBytes(res, maxBytes, onOverflow) {
238
+ if (!res.body) {
239
+ const text = await res.text();
240
+ const encoded = new TextEncoder().encode(text);
241
+ if (encoded.byteLength > maxBytes) {
242
+ return onOverflow === "reject"
243
+ ? { bytes: new Uint8Array(0), overflow: true }
244
+ : { bytes: encoded.slice(0, maxBytes), overflow: true };
245
+ }
246
+ return { bytes: encoded, overflow: false };
247
+ }
248
+ const reader = res.body.getReader();
249
+ const chunks = [];
250
+ let total = 0;
251
+ try {
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done)
255
+ break;
256
+ if (!value)
257
+ continue;
258
+ const remaining = maxBytes - total;
259
+ if (value.byteLength > remaining) {
260
+ await reader.cancel().catch(() => undefined);
261
+ if (onOverflow === "reject") {
262
+ return { bytes: new Uint8Array(0), overflow: true };
263
+ }
264
+ if (remaining > 0) {
265
+ chunks.push(value.slice(0, remaining));
266
+ total += remaining;
267
+ }
268
+ return { bytes: concat(chunks, total), overflow: true };
269
+ }
270
+ chunks.push(value);
271
+ total += value.byteLength;
272
+ }
273
+ }
274
+ finally {
275
+ reader.releaseLock?.();
276
+ }
277
+ return { bytes: concat(chunks, total), overflow: false };
278
+ }
279
+ function concat(chunks, total) {
280
+ const out = new Uint8Array(total);
281
+ let offset = 0;
282
+ for (const chunk of chunks) {
283
+ out.set(chunk, offset);
284
+ offset += chunk.byteLength;
285
+ }
286
+ return out;
287
+ }
177
288
  function formatAuthFailure(status, presetName, url, credential, body) {
178
289
  const lines = [`HTTP ${status} when fetching ${presetName}`];
179
290
  lines.push(` URL: ${url}`);
@@ -192,3 +303,17 @@ function formatCredential(credential) {
192
303
  return `${credential.envVar} (present)`;
193
304
  return `none (tried ${credential.triedVars.join(", ")})`;
194
305
  }
306
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
307
+ function isRedirectResponse(res) {
308
+ return res.type === "opaqueredirect" || REDIRECT_STATUSES.has(res.status);
309
+ }
310
+ function formatRedirectRefusal(presetName, url, res) {
311
+ const status = res.status > 0 ? String(res.status) : "redirect";
312
+ const lines = [
313
+ `Redirect refused while fetching ${presetName}`,
314
+ ` URL: ${url}`,
315
+ ` Status: ${status}`,
316
+ ` Reason: Following redirects is disabled to prevent leaking auth tokens to a different host. If your endpoint legitimately redirects, fetch the final URL directly.`,
317
+ ];
318
+ return lines.join("\n");
319
+ }
@@ -51,9 +51,16 @@ function escapeRegex(s) {
51
51
  export function scrubSecrets(text, secrets) {
52
52
  // Sort by length descending so that when one secret is a substring of
53
53
  // another, the longer one wins the greedy regex alternation instead of
54
- // being partially chewed up by the shorter one.
55
- const alternatives = secrets
56
- .filter((s) => s.length > 0)
54
+ // being partially chewed up by the shorter one. Each secret is also matched
55
+ // in its `encodeURIComponent` form so a token that ends up embedded in a URL
56
+ // (e.g. as a query parameter in a redirect Renovate logs) still gets
57
+ // redacted; when a secret has no encodable characters, the two forms
58
+ // collapse and the dedup below drops the duplicate.
59
+ const alternatives = [
60
+ ...new Set(secrets
61
+ .filter((s) => s.length > 0)
62
+ .flatMap((s) => [s, encodeURIComponent(s)])),
63
+ ]
57
64
  .sort((a, b) => b.length - a.length)
58
65
  .map(escapeRegex);
59
66
  if (!alternatives.length)
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ export declare const PATH_MAX_BYTES = 4096;
3
+ export declare const TOKEN_MAX_BYTES = 1024;
4
+ export declare const ENDPOINT_MAX_BYTES = 2048;
5
+ export declare const REPOSITORY_MAX_BYTES = 256;
6
+ export declare const FILENAME_MAX_BYTES = 512;
7
+ export declare const CONFIG_JSON_MAX_BYTES = 1000000;
8
+ export declare const REPORT_JSON_MAX_BYTES = 10000000;
9
+ export declare const HOST_RULES_MAX_ITEMS = 256;
10
+ export declare const HOST_RULE_JSON_MAX_BYTES = 64000;
11
+ export declare const pathString: (description: string) => z.ZodString;
12
+ export declare const tokenString: (description: string) => z.ZodString;
13
+ export declare const endpointString: (description: string) => z.ZodString;
14
+ export declare const repositoryString: (description: string) => z.ZodString;
15
+ export declare const filenameString: (description: string) => z.ZodString;
16
+ export declare const configRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
17
+ export declare const reportRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
18
+ export declare const hostRuleRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ // Centralized input-size caps for every tool schema. These exist to refuse
3
+ // pathologically large inputs (DoS via 100 MB blobs) before any business
4
+ // logic runs. Caps are intentionally generous — real-world inputs are orders
5
+ // of magnitude smaller — so the only way to trip them is intent or a buggy
6
+ // caller.
7
+ export const PATH_MAX_BYTES = 4096;
8
+ export const TOKEN_MAX_BYTES = 1024;
9
+ export const ENDPOINT_MAX_BYTES = 2048;
10
+ export const REPOSITORY_MAX_BYTES = 256;
11
+ export const FILENAME_MAX_BYTES = 512;
12
+ export const CONFIG_JSON_MAX_BYTES = 1_000_000;
13
+ export const REPORT_JSON_MAX_BYTES = 10_000_000;
14
+ export const HOST_RULES_MAX_ITEMS = 256;
15
+ export const HOST_RULE_JSON_MAX_BYTES = 64_000;
16
+ export const pathString = (description) => z.string().max(PATH_MAX_BYTES).describe(description);
17
+ export const tokenString = (description) => z.string().max(TOKEN_MAX_BYTES).describe(description);
18
+ export const endpointString = (description) => z.string().max(ENDPOINT_MAX_BYTES).describe(description);
19
+ export const repositoryString = (description) => z.string().max(REPOSITORY_MAX_BYTES).describe(description);
20
+ export const filenameString = (description) => z.string().max(FILENAME_MAX_BYTES).describe(description);
21
+ const refineJsonSize = (limit) => (value) => {
22
+ try {
23
+ return JSON.stringify(value).length <= limit;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ };
29
+ export const configRecord = (description) => z
30
+ .record(z.string(), z.unknown())
31
+ .refine(refineJsonSize(CONFIG_JSON_MAX_BYTES), {
32
+ message: `Config object exceeds ${CONFIG_JSON_MAX_BYTES} bytes when serialized`,
33
+ })
34
+ .describe(description);
35
+ export const reportRecord = (description) => z
36
+ .record(z.string(), z.unknown())
37
+ .refine(refineJsonSize(REPORT_JSON_MAX_BYTES), {
38
+ message: `Report object exceeds ${REPORT_JSON_MAX_BYTES} bytes when serialized`,
39
+ })
40
+ .describe(description);
41
+ export const hostRuleRecord = (description) => z
42
+ .record(z.string(), z.unknown())
43
+ .refine(refineJsonSize(HOST_RULE_JSON_MAX_BYTES), {
44
+ message: `hostRule entry exceeds ${HOST_RULE_JSON_MAX_BYTES} bytes when serialized`,
45
+ })
46
+ .describe(description);
@@ -0,0 +1 @@
1
+ export declare function logError(message: string, reason?: unknown): void;
@@ -0,0 +1,21 @@
1
+ // stderr-only logging for the MCP stdio server.
2
+ // stdout carries JSON-RPC frames — anything written there corrupts the next
3
+ // message the client receives, so error output must never go through console.log.
4
+ const PREFIX = "[renovate-mcp]";
5
+ function formatReason(reason) {
6
+ if (reason instanceof Error) {
7
+ return reason.stack ?? `${reason.name}: ${reason.message}`;
8
+ }
9
+ if (typeof reason === "string")
10
+ return reason;
11
+ try {
12
+ return JSON.stringify(reason);
13
+ }
14
+ catch {
15
+ return String(reason);
16
+ }
17
+ }
18
+ export function logError(message, reason) {
19
+ const suffix = reason === undefined ? "" : `: ${formatReason(reason)}`;
20
+ process.stderr.write(`${PREFIX} ${message}${suffix}\n`);
21
+ }
@@ -1,4 +1,4 @@
1
- export declare const SERVER_VERSION = "0.8.0";
1
+ export declare const SERVER_VERSION = "0.9.2";
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.8.0"; // x-release-please-version
2
+ export const SERVER_VERSION = "0.9.2"; // 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"]}`
@@ -5,13 +5,13 @@ import { tmpdir } from "node:os";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
7
7
  import { resolveCredential } from "../lib/credentialResolver.js";
8
+ import { EndpointValidationError, validateEndpoint } from "../lib/endpointValidator.js";
8
9
  import { locateConfig } from "../lib/configLocations.js";
9
10
  import { detectLookupProblems } from "../lib/lookupProblems.js";
10
11
  import { collectSecrets, scrubSecrets, writeHostRulesConfig, } from "../lib/hostRulesConfig.js";
11
12
  import { extractRenovateLogMsg, buildDryRunHeartbeatMessage, } from "../lib/dryRunProgress.js";
12
- const hostRuleSchema = z
13
- .record(z.string(), z.unknown())
14
- .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.");
13
+ import { HOST_RULES_MAX_ITEMS, endpointString, hostRuleRecord, pathString, repositoryString, tokenString, } from "../lib/inputLimits.js";
14
+ const hostRuleSchema = hostRuleRecord("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.");
15
15
  /**
16
16
  * Walk Renovate's JSON report looking for `problems` arrays and collect the
17
17
  * ones that represent failed runs — not informational notices. Criteria:
@@ -90,9 +90,7 @@ export function registerDryRun(server) {
90
90
  title: "Dry-run Renovate",
91
91
  description: "Run Renovate in dry-run mode to preview what it would do — no PRs opened, no git pushes. Returns a structured JSON report plus a top-level `ok` boolean (false when the CLI failed OR the report records a validation/error-level problem, even if the exit code was 0).\n\nDefault mode runs `--platform=local` against the filesystem at `repoPath`. If your config extends `local>…` presets, pass `platform` (`github` or `gitlab`), `endpoint` (API base URL), `token`, and `repository` to run as a real platform client that can actually fetch those presets — Renovate still runs with `--dry-run`, so no PRs are opened. If you only need `gitlab>…` / `github>…` presets resolved against a self-hosted host (not a full remote run), pass just `endpoint` (and `token` if needed) while leaving `platform` unset — both flow through to Renovate in local mode too, which is enough to redirect those preset shortcuts away from the public defaults. The tool preflight-checks for `local>` presets under `--platform=local` (in `lookup` and `full` modes) and fails fast with remediation guidance rather than spawning a Renovate run that would fail opaquely with `config-validation`. The preflight is skipped for `dryRunMode=extract` so manifest-only extraction can be attempted regardless.\n\nWhen the `token` input is omitted, the tool falls back to `RENOVATE_TOKEN` from the MCP server's env, then to `GITLAB_TOKEN` (when `platform=gitlab`) or `GITHUB_TOKEN` (when `platform=github`) — whichever is auto-translated to `RENOVATE_TOKEN` for the spawned Renovate CLI. This matches the precedence `resolve_config` already uses, so a single `GITLAB_TOKEN` in `.mcp.json` works for both tools. For a remote-platform run, an actionable preflight error is returned before spawning Renovate when no token can be resolved at all.\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 — including the platform `token` input — 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.",
92
92
  inputSchema: {
93
- repoPath: z
94
- .string()
95
- .describe("Absolute path to the repository root. Required for the default `platform=local` mode. When `platform` is overridden (e.g. `gitlab` / `github`), Renovate runs against the remote `repository` instead — `repoPath` is still used as the child's working directory but its manifest files are ignored."),
93
+ repoPath: pathString("Absolute path to the repository root. Required for the default `platform=local` mode. When `platform` is overridden (e.g. `gitlab` / `github`), Renovate runs against the remote `repository` instead — `repoPath` is still used as the child's working directory but its manifest files are ignored."),
96
94
  dryRunMode: z
97
95
  .enum(["extract", "lookup", "full"])
98
96
  .default("full")
@@ -109,24 +107,30 @@ export function registerDryRun(server) {
109
107
  .enum(["local", "github", "gitlab"])
110
108
  .optional()
111
109
  .describe("Renovate platform to run as. When unset, falls back to `RENOVATE_PLATFORM` from the MCP server's env (if it's one of `local`/`github`/`gitlab`), then to `local`. Default `local` runs against the filesystem at `repoPath`. Set `github` or `gitlab` (with `endpoint` + `token` + `repository`) to run a full remote dry-run — this is what you need when your config extends `local>` presets that live on a private GitHub Enterprise / self-hosted GitLab. Still no PRs opened because `--dry-run` is always set."),
112
- endpoint: z
113
- .string()
114
- .optional()
115
- .describe("Custom API base URL (e.g. `https://gitlab.example.com/api/v4/` or `https://ghe.example.com/api/v3/`). Forwarded as `--endpoint` regardless of `platform`: required for non-default GitHub/GitLab hosts when `platform=github`/`gitlab`, and also useful in the default `platform=local` mode to point `gitlab>…` / `github>…` preset shortcuts at a self-hosted host instead of the public defaults."),
116
- token: z
117
- .string()
118
- .optional()
119
- .describe("Auth token, exported as `RENOVATE_TOKEN` to the child. Scrubbed from any log output this tool returns. Used both for the platform connection (when `platform` is `github`/`gitlab`) and for preset resolution against private repos (e.g. when fetching a `gitlab>…` preset from a private project while in local mode). When omitted, falls back to `RENOVATE_TOKEN` from MCP env, then to `GITLAB_TOKEN` (when `platform=gitlab`) or `GITHUB_TOKEN` (when `platform=github`) — same precedence as `resolve_config`. Platform-specific fallbacks only kick in when the matching remote platform is selected; in `platform=local` they're ignored."),
120
- repository: z
121
- .string()
122
- .optional()
123
- .describe("Identifier of the repository Renovate should operate on, passed as a positional argument (Renovate has no `--repository` flag). For GitHub: `owner/repo`. For GitLab: `group/project` or a nested-group path like `group/subgroup/project` — both are accepted. Required when `platform` is `github`/`gitlab`. Ignored when `platform=local`."),
110
+ endpoint: endpointString("Custom API base URL (e.g. `https://gitlab.example.com/api/v4/` or `https://ghe.example.com/api/v3/`). Forwarded as `--endpoint` regardless of `platform`: required for non-default GitHub/GitLab hosts when `platform=github`/`gitlab`, and also useful in the default `platform=local` mode to point `gitlab>…` / `github>…` preset shortcuts at a self-hosted host instead of the public defaults.").optional(),
111
+ token: tokenString("Auth token, exported as `RENOVATE_TOKEN` to the child. Scrubbed from any log output this tool returns. Used both for the platform connection (when `platform` is `github`/`gitlab`) and for preset resolution against private repos (e.g. when fetching a `gitlab>…` preset from a private project while in local mode). When omitted, falls back to `RENOVATE_TOKEN` from MCP env, then to `GITLAB_TOKEN` (when `platform=gitlab`) or `GITHUB_TOKEN` (when `platform=github`) — same precedence as `resolve_config`. Platform-specific fallbacks only kick in when the matching remote platform is selected; in `platform=local` they're ignored.").optional(),
112
+ repository: repositoryString("Identifier of the repository Renovate should operate on, passed as a positional argument (Renovate has no `--repository` flag). For GitHub: `owner/repo`. For GitLab: `group/project` or a nested-group path like `group/subgroup/project` — both are accepted. Required when `platform` is `github`/`gitlab`. Ignored when `platform=local`.").optional(),
124
113
  hostRules: z
125
114
  .array(hostRuleSchema)
115
+ .max(HOST_RULES_MAX_ITEMS)
126
116
  .optional()
127
117
  .describe("Optional per-invocation Renovate hostRules for private registry auth. Written to a mode-0600 temp file that is passed via the RENOVATE_CONFIG_FILE env var 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."),
128
118
  },
129
119
  }, async ({ repoPath, dryRunMode, logLevel, timeoutMs, platform, endpoint, token, repository, hostRules }, extra) => {
120
+ if (endpoint !== undefined) {
121
+ try {
122
+ validateEndpoint(endpoint);
123
+ }
124
+ catch (err) {
125
+ if (err instanceof EndpointValidationError) {
126
+ return {
127
+ isError: true,
128
+ content: [{ type: "text", text: err.message }],
129
+ };
130
+ }
131
+ throw err;
132
+ }
133
+ }
130
134
  const reportPath = path.join(tmpdir(), `renovate-mcp-report-${randomUUID()}.json`);
131
135
  const bin = resolveRenovateTool("renovate");
132
136
  const ruleList = hostRules ?? [];
@@ -246,6 +250,14 @@ export function registerDryRun(server) {
246
250
  }).catch(() => undefined);
247
251
  };
248
252
  try {
253
+ // Pre-create the report file with mode 0600 so the on-disk JSON is
254
+ // not world-readable while Renovate is running. Renovate's report
255
+ // writer calls fs.writeFile with O_WRONLY|O_CREAT|O_TRUNC, which
256
+ // preserves the existing file's mode — so the 0600 we set here
257
+ // sticks across Renovate's overwrite. The `wx` flag fails fast if
258
+ // the path already exists (e.g. UUID collision), which is the safer
259
+ // default than silently inheriting whatever mode it had.
260
+ await fs.writeFile(reportPath, "", { flag: "wx", mode: 0o600 });
249
261
  const args = [
250
262
  `--platform=${effectivePlatform}`,
251
263
  `--dry-run=${dryRunMode}`,
@@ -1,8 +1,6 @@
1
- import { z } from "zod";
2
1
  import { diffDryRunReports } from "../lib/dryRunDiff.js";
3
- const reportShape = z
4
- .record(z.string(), z.unknown())
5
- .describe("A Renovate dry-run report. Pass either the raw report (an object with a `repositories` key) or the full `dry_run` tool summary (an object with a `report` key); the tool unwraps `report` automatically.");
2
+ import { reportRecord } from "../lib/inputLimits.js";
3
+ const reportShape = reportRecord("A Renovate dry-run report. Pass either the raw report (an object with a `repositories` key) or the full `dry_run` tool summary (an object with a `report` key); the tool unwraps `report` automatically.");
6
4
  export function registerDryRunDiff(server) {
7
5
  server.registerTool("dry_run_diff", {
8
6
  title: "Diff two Renovate dry-run reports",
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { locateConfig } from "../lib/configLocations.js";
3
3
  import { explainConfig } from "../lib/configExplainer.js";
4
+ import { configRecord, endpointString, pathString } from "../lib/inputLimits.js";
4
5
  const MERGE_QUALITY = "preview";
5
6
  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
7
  export function registerExplainConfig(server) {
@@ -8,22 +9,13 @@ export function registerExplainConfig(server) {
8
9
  title: "Explain which preset set each field",
9
10
  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
11
  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."),
12
+ repoPath: pathString("Absolute path to the repository root. The tool will locate the repo's renovate config automatically.").optional(),
13
+ configContent: configRecord("Inline config object to explain — use instead of repoPath.").optional(),
19
14
  externalPresets: z
20
15
  .boolean()
21
16
  .optional()
22
17
  .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."),
18
+ endpoint: endpointString("API base URL for github>/gitlab> fetches. Use for GitHub Enterprise or self-hosted GitLab — same semantics as resolve_config.").optional(),
27
19
  platform: z
28
20
  .enum(["github", "gitlab"])
29
21
  .optional()
@@ -1,21 +1,15 @@
1
- import { z } from "zod";
2
1
  import { promises as fs } from "node:fs";
3
2
  import path from "node:path";
4
3
  import JSON5 from "json5";
5
4
  import { lintConfig } from "../lib/configLinter.js";
5
+ import { configRecord, pathString } from "../lib/inputLimits.js";
6
6
  export function registerLintConfig(server) {
7
7
  server.registerTool("lint_config", {
8
8
  title: "Lint Renovate config for semantic footguns",
9
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 — malformed '/…/' regex patterns in fields like matchPackageNames, matchDepNames, matchSourceUrls, matchCurrentVersion, plus unknown manager names in matchManagers / excludeManagers (typos that Renovate silently ignores). Offline; does not shell out. Pass either configPath (file on disk, JSON or JSON5) or configContent (inline object).",
10
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"),
11
+ configPath: pathString("Absolute path to a config file to lint (JSON or JSON5)").optional(),
12
+ configContent: configRecord("Inline config object to lint").optional(),
19
13
  },
20
14
  }, async ({ configPath, configContent }) => {
21
15
  if (!configPath && !configContent) {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { previewCustomManager, } from "../lib/customManagerPreview.js";
3
+ import { pathString } from "../lib/inputLimits.js";
3
4
  const managerSchema = z
4
5
  .object({
5
6
  customType: z.string(),
@@ -33,7 +34,7 @@ export function registerPreviewCustomManager(server) {
33
34
  "Run the `dry_run` tool afterwards for full-fidelity confirmation.",
34
35
  ].join("\n"),
35
36
  inputSchema: {
36
- repoPath: z.string().describe("Absolute path to the repository root"),
37
+ repoPath: pathString("Absolute path to the repository root"),
37
38
  manager: managerSchema.describe("A single Renovate customManagers entry. NOTE: `fileMatch` is an array of REGEX strings matched against POSIX-style relative paths (not globs)."),
38
39
  maxFilesWalked: z
39
40
  .number()
@@ -1,11 +1,11 @@
1
- import { z } from "zod";
2
1
  import { locateConfig } from "../lib/configLocations.js";
2
+ import { pathString } from "../lib/inputLimits.js";
3
3
  export function registerReadConfig(server) {
4
4
  server.registerTool("read_config", {
5
5
  title: "Read Renovate config",
6
6
  description: "Locate and parse the Renovate configuration in a repository. Searches for renovate.json, renovate.json5, .renovaterc(.json|.json5), .github/renovate.json, .gitlab/renovate.json, and the 'renovate' field of package.json — in that priority order.",
7
7
  inputSchema: {
8
- repoPath: z.string().describe("Absolute path to the repository root"),
8
+ repoPath: pathString("Absolute path to the repository root"),
9
9
  },
10
10
  }, async ({ repoPath }) => {
11
11
  const located = await locateConfig(repoPath);
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { locateConfig } from "../lib/configLocations.js";
3
3
  import { resolveConfig } from "../lib/presetResolver.js";
4
+ import { configRecord, endpointString, pathString } from "../lib/inputLimits.js";
4
5
  const MERGE_QUALITY = "preview";
5
6
  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.";
6
7
  export function registerResolveConfig(server) {
@@ -8,22 +9,13 @@ export function registerResolveConfig(server) {
8
9
  title: "Resolve Renovate config (expand presets)",
9
10
  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.",
10
11
  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 resolve — use instead of repoPath."),
12
+ repoPath: pathString("Absolute path to the repository root. The tool will locate the repo's renovate config automatically.").optional(),
13
+ configContent: configRecord("Inline config object to resolve — use instead of repoPath.").optional(),
19
14
  externalPresets: z
20
15
  .boolean()
21
16
  .optional()
22
17
  .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."),
23
- endpoint: z
24
- .string()
25
- .optional()
26
- .describe("API base URL for github>/gitlab> fetches. Use for GitHub Enterprise (e.g. https://ghe.example.com/api/v3) or self-hosted GitLab (e.g. https://gitlab.example.com/api/v4). Defaults to https://api.github.com and https://gitlab.com/api/v4."),
18
+ endpoint: endpointString("API base URL for github>/gitlab> fetches. Use for GitHub Enterprise (e.g. https://ghe.example.com/api/v3) or self-hosted GitLab (e.g. https://gitlab.example.com/api/v4). Defaults to https://api.github.com and https://gitlab.com/api/v4.").optional(),
27
19
  platform: z
28
20
  .enum(["github", "gitlab"])
29
21
  .optional()
@@ -4,19 +4,14 @@ 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 { configRecord, pathString } from "../lib/inputLimits.js";
7
8
  export function registerValidateConfig(server) {
8
9
  server.registerTool("validate_config", {
9
10
  title: "Validate Renovate config",
10
11
  description: "Validate a Renovate configuration against the official schema using renovate-config-validator. Pass either configPath (file on disk) or configContent (inline JSON object). Returns validation output and a boolean `valid`.",
11
12
  inputSchema: {
12
- configPath: z
13
- .string()
14
- .optional()
15
- .describe("Absolute path to a config file to validate"),
16
- configContent: z
17
- .record(z.string(), z.unknown())
18
- .optional()
19
- .describe("Inline config object to validate (written to a temp file)"),
13
+ configPath: pathString("Absolute path to a config file to validate").optional(),
14
+ configContent: configRecord("Inline config object to validate (written to a temp file)").optional(),
20
15
  strict: z
21
16
  .boolean()
22
17
  .optional()
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import path from "node:path";
3
3
  import { promises as fs } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
6
+ import { configRecord, filenameString, pathString } from "../lib/inputLimits.js";
5
7
  // Resolve symlinks in `p`, walking up to the nearest existing ancestor when
6
8
  // tail components don't exist yet (e.g. a new subdir we're about to mkdir).
7
9
  // This matters for the escape check: `fs.rename` follows symlinks along the
@@ -25,23 +27,40 @@ async function resolveWithExistingAncestor(p) {
25
27
  }
26
28
  }
27
29
  }
30
+ const FORCE_CONFIRMATION_TOKEN = "YES_OVERRIDE_VALIDATION";
28
31
  export function registerWriteConfig(server) {
29
32
  server.registerTool("write_config", {
30
33
  title: "Write Renovate config",
31
- description: "Write a Renovate config to disk. Runs renovate-config-validator first — refuses to write if validation fails unless force=true.",
34
+ description: "Write a Renovate config to disk. Runs renovate-config-validator first — refuses to write if validation fails unless force=true (which additionally requires confirmForce).",
32
35
  inputSchema: {
33
- repoPath: z.string().describe("Absolute path to the repository root"),
34
- config: z.record(z.string(), z.unknown()).describe("The Renovate config object"),
35
- filename: z
36
- .string()
37
- .default("renovate.json")
38
- .describe("Target filename relative to repoPath (default renovate.json)"),
36
+ repoPath: pathString("Absolute path to the repository root"),
37
+ config: configRecord("The Renovate config object"),
38
+ filename: filenameString("Target filename relative to repoPath (default renovate.json)").default("renovate.json"),
39
39
  force: z
40
40
  .boolean()
41
41
  .default(false)
42
- .describe("Write even if validation fails"),
42
+ .describe("Write even if validation fails. Requires confirmForce."),
43
+ confirmForce: z
44
+ .literal(FORCE_CONFIRMATION_TOKEN)
45
+ .optional()
46
+ .describe(`Must be set to the literal '${FORCE_CONFIRMATION_TOKEN}' when force is true. The unusual literal exists to make accidental overrides harder under prompt-injected tool calls.`),
43
47
  },
44
- }, async ({ repoPath, config, filename, force }) => {
48
+ }, async ({ repoPath, config, filename, force, confirmForce }) => {
49
+ if (force && confirmForce !== FORCE_CONFIRMATION_TOKEN) {
50
+ return {
51
+ isError: true,
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: JSON.stringify({
56
+ wrote: false,
57
+ reason: "force-confirmation-missing",
58
+ hint: `force=true requires confirmForce='${FORCE_CONFIRMATION_TOKEN}'. This guard exists to make accidental overrides harder under prompt-injected tool calls.`,
59
+ }, null, 2),
60
+ },
61
+ ],
62
+ };
63
+ }
45
64
  const repoAbs = path.resolve(repoPath);
46
65
  const target = path.resolve(repoAbs, filename);
47
66
  const rel = path.relative(repoAbs, target);
@@ -55,9 +74,12 @@ export function registerWriteConfig(server) {
55
74
  };
56
75
  }
57
76
  const payload = JSON.stringify(config, null, 2) + "\n";
58
- const tmp = `${target}.renovate-mcp-tmp`;
77
+ // Randomize the suffix so two concurrent writes don't collide, and use
78
+ // `flag: "wx"` (O_CREAT|O_EXCL) so a pre-existing symlink at the temp
79
+ // path is refused with EEXIST instead of silently followed (issue #129).
80
+ const tmp = `${target}.renovate-mcp-tmp-${randomUUID()}`;
59
81
  await fs.mkdir(path.dirname(target), { recursive: true });
60
- await fs.writeFile(tmp, payload);
82
+ await fs.writeFile(tmp, payload, { flag: "wx", mode: 0o600 });
61
83
  try {
62
84
  let valid = false;
63
85
  let validationOutput = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.2",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,6 +51,10 @@
51
51
  "engines": {
52
52
  "node": ">=24"
53
53
  },
54
+ "os": [
55
+ "darwin",
56
+ "linux"
57
+ ],
54
58
  "dependencies": {
55
59
  "@modelcontextprotocol/sdk": "^1.29.0",
56
60
  "ignore": "^7.0.5",