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 +11 -5
- package/dist/index.js +14 -0
- package/dist/lib/endpointValidator.d.ts +17 -0
- package/dist/lib/endpointValidator.js +102 -0
- package/dist/lib/externalPresetFetcher.js +135 -10
- package/dist/lib/hostRulesConfig.js +10 -3
- package/dist/lib/inputLimits.d.ts +18 -0
- package/dist/lib/inputLimits.js +46 -0
- package/dist/lib/log.d.ts +1 -0
- package/dist/lib/log.js +21 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/dryRun.js +30 -18
- package/dist/tools/dryRunDiff.js +2 -4
- package/dist/tools/explainConfig.js +4 -12
- package/dist/tools/lintConfig.js +3 -9
- package/dist/tools/previewCustomManager.js +2 -1
- package/dist/tools/readConfig.js +2 -2
- package/dist/tools/resolveConfig.js +4 -12
- package/dist/tools/validateConfig.js +3 -8
- package/dist/tools/writeConfig.js +33 -11
- package/package.json +5 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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;
|
package/dist/lib/log.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/version.d.ts
CHANGED
package/dist/lib/version.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
export const SERVER_VERSION = "0.
|
|
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"]}`
|
package/dist/tools/dryRun.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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:
|
|
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:
|
|
113
|
-
|
|
114
|
-
|
|
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}`,
|
package/dist/tools/dryRunDiff.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { diffDryRunReports } from "../lib/dryRunDiff.js";
|
|
3
|
-
|
|
4
|
-
|
|
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:
|
|
12
|
-
|
|
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:
|
|
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()
|
package/dist/tools/lintConfig.js
CHANGED
|
@@ -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:
|
|
12
|
-
|
|
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:
|
|
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()
|
package/dist/tools/readConfig.js
CHANGED
|
@@ -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:
|
|
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:
|
|
12
|
-
|
|
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:
|
|
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:
|
|
13
|
-
|
|
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:
|
|
34
|
-
config:
|
|
35
|
-
filename:
|
|
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
|
-
|
|
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.
|
|
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",
|