renovate-mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/dist/index.js +2 -2
- package/dist/lib/customManagerPreview.js +72 -21
- package/dist/lib/externalPresetFetcher.d.ts +8 -1
- package/dist/lib/externalPresetFetcher.js +23 -23
- package/dist/lib/presetResolver.d.ts +34 -0
- package/dist/lib/presetResolver.js +56 -2
- package/dist/resources/presets.js +67 -20
- package/dist/tools/previewCustomManager.js +1 -1
- package/dist/tools/resolveConfig.js +13 -3
- package/dist/tools/writeConfig.js +27 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,12 +10,13 @@ Seven tools plus a preset reference:
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| `check_setup` | Report Renovate CLI + validator availability, versions, env overrides, and install hints. Also runs at server startup and embeds the result in the server's `instructions` when anything's missing. |
|
|
12
12
|
| `read_config` | Locate and parse `renovate.json` / `renovate.json5` / `.renovaterc*` / `.github/renovate.json` / `.gitlab/renovate.json` / `package.json#renovate` in a repo — in Renovate's priority order. |
|
|
13
|
-
| `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN`). |
|
|
13
|
+
| `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN`). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL) — and `platform` in addition to route `local>` presets through the same host. `bitbucket>`, `gitea>`, and npm presets remain in `presetsUnresolved` regardless. |
|
|
14
14
|
| `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — offline, no `renovate` invocation. Shows which files match `fileMatch`, which lines match each `matchStrings` regex with named capture groups, and what dep info the template fields produce. Intended for fast regex iteration; run `dry_run` afterwards for full-fidelity confirmation. |
|
|
15
15
|
| `validate_config` | Run `renovate-config-validator` against a file or inline object. |
|
|
16
16
|
| `dry_run` | Run Renovate with `--platform=local --dry-run`, return the structured JSON report (no PRs, no pushes). |
|
|
17
17
|
| `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
|
|
18
|
-
| `renovate://presets` (resource) |
|
|
18
|
+
| `renovate://presets` (resource) | Thin markdown index of every namespace (with preset counts) covering all 1000+ built-in presets. Snapshot from the installed `renovate` devDep. |
|
|
19
|
+
| `renovate://presets/{namespace}` (resource template) | Markdown listing of every preset in a single namespace (e.g. `renovate://presets/config`). Fetch this instead of the full index when the LLM only cares about one namespace — cuts token cost by roughly 1/N where N is the number of namespaces. |
|
|
19
20
|
| `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
|
|
20
21
|
|
|
21
22
|
## Requirements
|
|
@@ -23,7 +24,7 @@ Seven tools plus a preset reference:
|
|
|
23
24
|
- Node.js ≥ 24 (aligns with Renovate's own engine requirement).
|
|
24
25
|
- Renovate available on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` and `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`) work without it.
|
|
25
26
|
- Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
|
|
26
|
-
- Optional for `resolve_config` with `externalPresets: true`: `GITHUB_TOKEN` / `GITLAB_TOKEN` (or `RENOVATE_TOKEN` as a fallback) for fetching presets from private repos or to avoid rate limits.
|
|
27
|
+
- Optional for `resolve_config` with `externalPresets: true`: `GITHUB_TOKEN` / `GITLAB_TOKEN` (or `RENOVATE_TOKEN` as a fallback) for fetching presets from private repos or to avoid rate limits. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` if you also want `local>` presets routed there); `RENOVATE_ENDPOINT` is **not** read. Any env vars the tool does read must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell, since the MCP server runs as a child of Claude and does not inherit shell env.
|
|
27
28
|
|
|
28
29
|
## Install
|
|
29
30
|
|
|
@@ -77,6 +78,39 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
77
78
|
}
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
## Example prompts
|
|
82
|
+
|
|
83
|
+
Once the server is wired up, try prompts like these. They're written for Claude but work with any MCP-capable client.
|
|
84
|
+
|
|
85
|
+
**Understanding an existing config**
|
|
86
|
+
|
|
87
|
+
- "Read the Renovate config in this repo and summarize what it actually does — expand every preset so I can see the real effective behavior."
|
|
88
|
+
- "Resolve my config and list anything that landed in `presetsUnresolved`, with the reason for each."
|
|
89
|
+
|
|
90
|
+
**Browsing presets**
|
|
91
|
+
|
|
92
|
+
- "List the presets in the `config` namespace." (uses the `renovate://presets/config` sub-resource — cheaper than pulling the whole index)
|
|
93
|
+
- "What does `config:recommended` actually enable? Show me its expanded JSON."
|
|
94
|
+
- "Find a built-in preset that pins GitHub Actions digests."
|
|
95
|
+
|
|
96
|
+
**Self-hosted GitLab / GitHub Enterprise**
|
|
97
|
+
|
|
98
|
+
- "Resolve my config with external presets enabled, fetching `gitlab>platform/renovate-presets` from our self-hosted GitLab at `https://gitlab.example.com/api/v4`. Route `local>` presets through the same host."
|
|
99
|
+
- "Expand `github>acme/renovate-config//base` from our GitHub Enterprise at `https://github.acme.corp/api/v3`."
|
|
100
|
+
|
|
101
|
+
(Reminder: auth tokens must be set on the MCP server process — via the `env` key in `.mcp.json` / `claude_desktop_config.json`, not your shell.)
|
|
102
|
+
|
|
103
|
+
**Authoring a custom manager (regex)**
|
|
104
|
+
|
|
105
|
+
- "I have `# renovate: datasource=docker depName=...` comments above image tags in my Dockerfiles. Draft a `customManagers` regex entry and preview it against this repo so I can see what it extracts."
|
|
106
|
+
- "Here's a `customManagers` entry — preview it and tell me which files match, which lines hit each `matchStrings` regex, and what dep info gets extracted."
|
|
107
|
+
|
|
108
|
+
**Validating, previewing, saving**
|
|
109
|
+
|
|
110
|
+
- "Validate this proposed config against Renovate's schema without writing it anywhere."
|
|
111
|
+
- "Do a dry run and show me which PRs Renovate would open — no pushes."
|
|
112
|
+
- "Add `:semanticCommits` to my `extends`, validate it, and save back to `renovate.json`."
|
|
113
|
+
|
|
80
114
|
## Development
|
|
81
115
|
|
|
82
116
|
```bash
|
|
@@ -110,6 +144,7 @@ Secrets required on the repo: `RELEASE_PLEASE_TOKEN` (a PAT for release-please).
|
|
|
110
144
|
|
|
111
145
|
- `validate_config`, `dry_run`, and `write_config` shell out to the Renovate CLI rather than importing Renovate as a library — this decouples our Node version from Renovate's (currently Node 24).
|
|
112
146
|
- `resolve_config` and `preview_custom_manager` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
|
|
113
|
-
- `
|
|
147
|
+
- `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesScanned` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
|
|
148
|
+
- `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.
|
|
114
149
|
- `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout.
|
|
115
150
|
- `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
|
package/dist/index.js
CHANGED
|
@@ -22,13 +22,13 @@ const BASE_INSTRUCTIONS = [
|
|
|
22
22
|
" 6. write_config — save the agreed-upon config (validates first)",
|
|
23
23
|
"",
|
|
24
24
|
"If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
|
|
25
|
-
"Built-in preset reference
|
|
25
|
+
"Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
|
|
26
26
|
].join("\n");
|
|
27
27
|
const setup = await checkSetup();
|
|
28
28
|
const instructions = setup.ok
|
|
29
29
|
? BASE_INSTRUCTIONS
|
|
30
30
|
: [BASE_INSTRUCTIONS, "", "Startup setup check:", describeSetup(setup)].join("\n");
|
|
31
|
-
const SERVER_VERSION = "0.
|
|
31
|
+
const SERVER_VERSION = "0.3.0"; // x-release-please-version
|
|
32
32
|
const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
|
|
33
33
|
registerCheckSetup(server);
|
|
34
34
|
registerReadConfig(server);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import ignore from "ignore";
|
|
3
4
|
const DEFAULT_MAX_FILES_SCANNED = 2000;
|
|
4
5
|
const DEFAULT_MAX_HITS_PER_FILE = 100;
|
|
5
6
|
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
@@ -125,29 +126,79 @@ function lineNumberAt(content, index) {
|
|
|
125
126
|
}
|
|
126
127
|
return line;
|
|
127
128
|
}
|
|
128
|
-
async function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
async function readMaybe(abs) {
|
|
130
|
+
try {
|
|
131
|
+
return await fs.readFile(abs, "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function loadGitignore(root, relDir, levels) {
|
|
138
|
+
const content = await readMaybe(path.join(root, relDir, ".gitignore"));
|
|
139
|
+
if (!content)
|
|
140
|
+
return levels;
|
|
141
|
+
const posixPrefix = relDir === "" ? "" : `${toPosix(relDir)}/`;
|
|
142
|
+
return [...levels, { prefix: posixPrefix, ig: ignore().add(content) }];
|
|
143
|
+
}
|
|
144
|
+
function isIgnored(relPath, isDir, levels) {
|
|
145
|
+
for (const level of levels) {
|
|
146
|
+
const sub = level.prefix === ""
|
|
147
|
+
? relPath
|
|
148
|
+
: relPath.startsWith(level.prefix)
|
|
149
|
+
? relPath.slice(level.prefix.length)
|
|
150
|
+
: null;
|
|
151
|
+
if (sub === null || sub === "")
|
|
138
152
|
continue;
|
|
153
|
+
// `ignore` treats a trailing slash as "this is a directory", which is what
|
|
154
|
+
// lets patterns like `dist/` match directory paths.
|
|
155
|
+
if (level.ig.ignores(isDir ? `${sub}/` : sub))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
function toPosix(p) {
|
|
161
|
+
return p.split(path.sep).join("/");
|
|
162
|
+
}
|
|
163
|
+
async function* walk(root) {
|
|
164
|
+
// Seed the root level with `.gitignore` plus `.git/info/exclude` (the
|
|
165
|
+
// per-clone exclude file). Both are optional.
|
|
166
|
+
const rootIg = ignore();
|
|
167
|
+
const rootGitignore = await readMaybe(path.join(root, ".gitignore"));
|
|
168
|
+
if (rootGitignore)
|
|
169
|
+
rootIg.add(rootGitignore);
|
|
170
|
+
const infoExclude = await readMaybe(path.join(root, ".git", "info", "exclude"));
|
|
171
|
+
if (infoExclude)
|
|
172
|
+
rootIg.add(infoExclude);
|
|
173
|
+
const rootLevels = [{ prefix: "", ig: rootIg }];
|
|
174
|
+
yield* walkDir(root, "", rootLevels);
|
|
175
|
+
}
|
|
176
|
+
async function* walkDir(root, relDir, levels) {
|
|
177
|
+
let entries;
|
|
178
|
+
try {
|
|
179
|
+
entries = await fs.readdir(path.join(root, relDir), { withFileTypes: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// A nested `.gitignore` only affects this directory's subtree — add it to
|
|
185
|
+
// the stack on entry. (The root `.gitignore` is already in `levels`.)
|
|
186
|
+
const dirLevels = relDir === "" ? levels : await loadGitignore(root, relDir, levels);
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const relPath = relDir === "" ? entry.name : `${toPosix(relDir)}/${entry.name}`;
|
|
189
|
+
if (entry.isDirectory()) {
|
|
190
|
+
if (SKIP_DIRS.has(entry.name))
|
|
191
|
+
continue;
|
|
192
|
+
if (isIgnored(relPath, true, dirLevels))
|
|
193
|
+
continue;
|
|
194
|
+
yield* walkDir(root, path.join(relDir, entry.name), dirLevels);
|
|
139
195
|
}
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
else if (entry.isFile()) {
|
|
147
|
-
// Always emit POSIX-style paths so users on macOS/Linux/Windows write
|
|
148
|
-
// the same fileMatch regexes.
|
|
149
|
-
yield path.join(relDir, entry.name).split(path.sep).join("/");
|
|
150
|
-
}
|
|
196
|
+
else if (entry.isFile()) {
|
|
197
|
+
if (isIgnored(relPath, false, dirLevels))
|
|
198
|
+
continue;
|
|
199
|
+
// Always emit POSIX-style paths so users on macOS/Linux/Windows write
|
|
200
|
+
// the same fileMatch regexes.
|
|
201
|
+
yield toPosix(relPath);
|
|
151
202
|
}
|
|
152
203
|
}
|
|
153
204
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ParsedPreset } from "./presetResolver.js";
|
|
2
2
|
export interface FetchOptions {
|
|
3
3
|
timeoutMs?: number;
|
|
4
4
|
cache?: Map<string, Promise<FetchResult>>;
|
|
5
|
+
/**
|
|
6
|
+
* Override the API base URL. For `github>` presets, defaults to
|
|
7
|
+
* `https://api.github.com` (pass `https://ghe.example.com/api/v3` for GitHub
|
|
8
|
+
* Enterprise). For `gitlab>`, defaults to `https://gitlab.com/api/v4` (pass
|
|
9
|
+
* `https://gitlab.example.com/api/v4` for self-hosted).
|
|
10
|
+
*/
|
|
11
|
+
endpoint?: string;
|
|
5
12
|
/** Injectable for tests. Defaults to globalThis.fetch. */
|
|
6
13
|
fetchImpl?: typeof fetch;
|
|
7
14
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { classifyExternalSource } from "./presetResolver.js";
|
|
1
2
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
3
|
+
const DEFAULT_GITHUB_API_BASE = "https://api.github.com";
|
|
4
|
+
const DEFAULT_GITLAB_API_BASE = "https://gitlab.com/api/v4";
|
|
5
|
+
function trimTrailingSlash(url) {
|
|
6
|
+
return url.replace(/\/+$/, "");
|
|
7
|
+
}
|
|
2
8
|
export async function fetchExternalPreset(parsed, options = {}) {
|
|
3
9
|
const cache = options.cache;
|
|
4
10
|
const cacheKey = parsed.key;
|
|
@@ -10,43 +16,36 @@ export async function fetchExternalPreset(parsed, options = {}) {
|
|
|
10
16
|
return promise;
|
|
11
17
|
}
|
|
12
18
|
function dispatch(parsed, options) {
|
|
19
|
+
if (!parsed.source) {
|
|
20
|
+
return Promise.resolve({ ok: false, reason: "Unknown preset source: (none)" });
|
|
21
|
+
}
|
|
22
|
+
const classification = classifyExternalSource(parsed.source);
|
|
23
|
+
if (!classification.fetchable) {
|
|
24
|
+
return Promise.resolve({ ok: false, reason: classification.reason });
|
|
25
|
+
}
|
|
13
26
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
14
27
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
28
|
+
const endpoint = options.endpoint ? trimTrailingSlash(options.endpoint) : undefined;
|
|
15
29
|
switch (parsed.source) {
|
|
16
30
|
case "github":
|
|
17
|
-
return fetchGitHub(parsed, timeoutMs, fetchImpl);
|
|
31
|
+
return fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint);
|
|
18
32
|
case "gitlab":
|
|
19
|
-
return fetchGitLab(parsed, timeoutMs, fetchImpl);
|
|
20
|
-
case "bitbucket":
|
|
21
|
-
case "gitea":
|
|
22
|
-
return Promise.resolve({
|
|
23
|
-
ok: false,
|
|
24
|
-
reason: `${parsed.source}> presets are not yet supported. Track progress in issue #10.`,
|
|
25
|
-
});
|
|
26
|
-
case "local":
|
|
27
|
-
return Promise.resolve({
|
|
28
|
-
ok: false,
|
|
29
|
-
reason: "local> presets require platform/repo context that resolve_config does not have; out of scope.",
|
|
30
|
-
});
|
|
31
|
-
case "npm":
|
|
32
|
-
return Promise.resolve({
|
|
33
|
-
ok: false,
|
|
34
|
-
reason: "npm-hosted presets are not yet supported. Host the preset on GitHub or GitLab, or track progress in issue #10.",
|
|
35
|
-
});
|
|
33
|
+
return fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint);
|
|
36
34
|
default:
|
|
37
35
|
return Promise.resolve({
|
|
38
36
|
ok: false,
|
|
39
|
-
reason: `Unknown preset source: ${parsed.source
|
|
37
|
+
reason: `Unknown preset source: ${parsed.source}`,
|
|
40
38
|
});
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
|
-
async function fetchGitHub(parsed, timeoutMs, fetchImpl) {
|
|
41
|
+
async function fetchGitHub(parsed, timeoutMs, fetchImpl, endpoint) {
|
|
44
42
|
if (!parsed.repoPath || !parsed.repoPath.includes("/")) {
|
|
45
43
|
return { ok: false, reason: `Invalid github preset: ${parsed.original}` };
|
|
46
44
|
}
|
|
47
45
|
const file = presetFileName(parsed);
|
|
48
46
|
const ref = parsed.ref ?? "HEAD";
|
|
49
|
-
const
|
|
47
|
+
const apiBase = endpoint ?? DEFAULT_GITHUB_API_BASE;
|
|
48
|
+
const url = `${apiBase}/repos/${parsed.repoPath}/contents/${encodeFilePath(file)}?ref=${encodeURIComponent(ref)}`;
|
|
50
49
|
const token = process.env.GITHUB_TOKEN || process.env.RENOVATE_TOKEN;
|
|
51
50
|
const headers = {
|
|
52
51
|
Accept: "application/vnd.github.raw",
|
|
@@ -56,13 +55,14 @@ async function fetchGitHub(parsed, timeoutMs, fetchImpl) {
|
|
|
56
55
|
headers.Authorization = `Bearer ${token}`;
|
|
57
56
|
return fetchJson(url, headers, timeoutMs, parsed.original, fetchImpl);
|
|
58
57
|
}
|
|
59
|
-
async function fetchGitLab(parsed, timeoutMs, fetchImpl) {
|
|
58
|
+
async function fetchGitLab(parsed, timeoutMs, fetchImpl, endpoint) {
|
|
60
59
|
if (!parsed.repoPath) {
|
|
61
60
|
return { ok: false, reason: `Invalid gitlab preset: ${parsed.original}` };
|
|
62
61
|
}
|
|
63
62
|
const file = presetFileName(parsed);
|
|
64
63
|
const ref = parsed.ref ?? "HEAD";
|
|
65
|
-
const
|
|
64
|
+
const apiBase = endpoint ?? DEFAULT_GITLAB_API_BASE;
|
|
65
|
+
const url = `${apiBase}/projects/${encodeURIComponent(parsed.repoPath)}/repository/files/${encodeURIComponent(file)}/raw?ref=${encodeURIComponent(ref)}`;
|
|
66
66
|
const token = process.env.GITLAB_TOKEN || process.env.RENOVATE_TOKEN;
|
|
67
67
|
const headers = { "User-Agent": "renovate-mcp" };
|
|
68
68
|
if (token)
|
|
@@ -7,13 +7,47 @@ export interface ResolveResult {
|
|
|
7
7
|
presetsResolved: string[];
|
|
8
8
|
presetsUnresolved: UnresolvedPreset[];
|
|
9
9
|
}
|
|
10
|
+
export type FetchPlatform = "github" | "gitlab";
|
|
10
11
|
export interface ResolveOptions {
|
|
11
12
|
/** When true, fetch external presets (github>, gitlab>, …) over the network. */
|
|
12
13
|
fetchExternal?: boolean;
|
|
13
14
|
/** Per-request fetch timeout; forwarded to the external fetcher. */
|
|
14
15
|
timeoutMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Override the API base URL used for `github>` / `gitlab>` fetches — e.g.
|
|
18
|
+
* `https://ghe.example.com/api/v3` for GitHub Enterprise or
|
|
19
|
+
* `https://gitlab.example.com/api/v4` for self-hosted GitLab. When unset,
|
|
20
|
+
* defaults to `https://api.github.com` and `https://gitlab.com/api/v4`.
|
|
21
|
+
*/
|
|
22
|
+
endpoint?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Platform flavour of `endpoint`. Required only when it's not derivable from
|
|
25
|
+
* the preset source (e.g. to resolve `local>` presets against a self-hosted
|
|
26
|
+
* GitHub/GitLab). When set, `local>owner/repo` is fetched as if it were
|
|
27
|
+
* `<platform>>owner/repo`.
|
|
28
|
+
*/
|
|
29
|
+
platform?: FetchPlatform;
|
|
15
30
|
}
|
|
16
31
|
export type ExternalSource = "github" | "gitlab" | "bitbucket" | "gitea" | "local" | "npm";
|
|
32
|
+
export type SourceClassification = {
|
|
33
|
+
fetchable: true;
|
|
34
|
+
} | {
|
|
35
|
+
fetchable: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Single source of truth for "can resolve_config ever fetch this external
|
|
40
|
+
* preset?". Used by both the resolver (to short-circuit before branching on the
|
|
41
|
+
* `externalPresets` flag) and the fetcher (to produce the same reason once the
|
|
42
|
+
* flag is on) — so a `local>` preset, say, returns the identical unresolved
|
|
43
|
+
* reason regardless of the flag.
|
|
44
|
+
*
|
|
45
|
+
* - `github`, `gitlab`: fetchable over HTTPS.
|
|
46
|
+
* - `local`: structurally unsupported — requires platform/repo context the
|
|
47
|
+
* tool does not have.
|
|
48
|
+
* - `bitbucket`, `gitea`, `npm`: not yet implemented.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyExternalSource(source: string): SourceClassification;
|
|
17
51
|
export interface ParsedPreset {
|
|
18
52
|
/** Canonical identifier used as map key and for cycle detection. */
|
|
19
53
|
key: string;
|
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
import { PRESETS } from "../data/presets.generated.js";
|
|
2
2
|
import { fetchExternalPreset } from "./externalPresetFetcher.js";
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for "can resolve_config ever fetch this external
|
|
5
|
+
* preset?". Used by both the resolver (to short-circuit before branching on the
|
|
6
|
+
* `externalPresets` flag) and the fetcher (to produce the same reason once the
|
|
7
|
+
* flag is on) — so a `local>` preset, say, returns the identical unresolved
|
|
8
|
+
* reason regardless of the flag.
|
|
9
|
+
*
|
|
10
|
+
* - `github`, `gitlab`: fetchable over HTTPS.
|
|
11
|
+
* - `local`: structurally unsupported — requires platform/repo context the
|
|
12
|
+
* tool does not have.
|
|
13
|
+
* - `bitbucket`, `gitea`, `npm`: not yet implemented.
|
|
14
|
+
*/
|
|
15
|
+
export function classifyExternalSource(source) {
|
|
16
|
+
switch (source) {
|
|
17
|
+
case "github":
|
|
18
|
+
case "gitlab":
|
|
19
|
+
return { fetchable: true };
|
|
20
|
+
case "local":
|
|
21
|
+
return {
|
|
22
|
+
fetchable: false,
|
|
23
|
+
reason: "local> presets require platform/repo context that resolve_config does not have; out of scope.",
|
|
24
|
+
};
|
|
25
|
+
case "bitbucket":
|
|
26
|
+
case "gitea":
|
|
27
|
+
return {
|
|
28
|
+
fetchable: false,
|
|
29
|
+
reason: `${source}> presets are not yet supported. Track progress in issue #10.`,
|
|
30
|
+
};
|
|
31
|
+
case "npm":
|
|
32
|
+
return {
|
|
33
|
+
fetchable: false,
|
|
34
|
+
reason: "npm-hosted presets are not yet supported. Host the preset on GitHub or GitLab, or track progress in issue #10.",
|
|
35
|
+
};
|
|
36
|
+
default:
|
|
37
|
+
return {
|
|
38
|
+
fetchable: false,
|
|
39
|
+
reason: `Unknown preset source: ${source}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
3
43
|
/**
|
|
4
44
|
* Resolve a Renovate config by expanding every preset in `extends`. Built-in
|
|
5
45
|
* presets resolve offline against the committed catalogue; external presets
|
|
@@ -13,6 +53,8 @@ export async function resolveConfig(config, options = {}) {
|
|
|
13
53
|
const ctx = {
|
|
14
54
|
fetchExternal: options.fetchExternal ?? false,
|
|
15
55
|
timeoutMs: options.timeoutMs,
|
|
56
|
+
endpoint: options.endpoint,
|
|
57
|
+
platform: options.platform,
|
|
16
58
|
cache: new Map(),
|
|
17
59
|
};
|
|
18
60
|
const resolved = await expand(config, presetsResolved, presetsUnresolved, stack, ctx);
|
|
@@ -66,15 +108,27 @@ async function loadPresetBody(parsed, ctx, unresolvedList) {
|
|
|
66
108
|
}
|
|
67
109
|
return preset.body;
|
|
68
110
|
}
|
|
111
|
+
// If a `platform` is configured, route `local>` presets through it — the
|
|
112
|
+
// self-hosted case where the user's "local" presets actually live on their
|
|
113
|
+
// private GitHub/GitLab at `endpoint`.
|
|
114
|
+
const effective = parsed.source === "local" && ctx.platform
|
|
115
|
+
? { ...parsed, source: ctx.platform }
|
|
116
|
+
: parsed;
|
|
117
|
+
const classification = classifyExternalSource(effective.source);
|
|
118
|
+
if (!classification.fetchable) {
|
|
119
|
+
unresolvedList.push({ preset: parsed.original, reason: classification.reason });
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
69
122
|
if (!ctx.fetchExternal) {
|
|
70
123
|
unresolvedList.push({
|
|
71
124
|
preset: parsed.original,
|
|
72
|
-
reason: "External preset (github>, gitlab
|
|
125
|
+
reason: "External preset (github>, gitlab>). Fetching requires network access and potentially credentials; pass externalPresets: true to enable.",
|
|
73
126
|
});
|
|
74
127
|
return null;
|
|
75
128
|
}
|
|
76
|
-
const result = await fetchExternalPreset(
|
|
129
|
+
const result = await fetchExternalPreset(effective, {
|
|
77
130
|
timeoutMs: ctx.timeoutMs,
|
|
131
|
+
endpoint: ctx.endpoint,
|
|
78
132
|
cache: ctx.cache,
|
|
79
133
|
});
|
|
80
134
|
if (!result.ok) {
|
|
@@ -1,19 +1,60 @@
|
|
|
1
1
|
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { PRESETS, PRESET_NAMES, RENOVATE_VERSION, } from "../data/presets.generated.js";
|
|
3
|
+
const BY_NAMESPACE = (() => {
|
|
4
|
+
const map = new Map();
|
|
5
|
+
for (const name of PRESET_NAMES) {
|
|
6
|
+
const preset = PRESETS[name];
|
|
7
|
+
const bucket = map.get(preset.namespace) ?? [];
|
|
8
|
+
bucket.push({ name, description: preset.description });
|
|
9
|
+
map.set(preset.namespace, bucket);
|
|
10
|
+
}
|
|
11
|
+
return map;
|
|
12
|
+
})();
|
|
13
|
+
const NAMESPACES = Array.from(BY_NAMESPACE.keys()).sort();
|
|
3
14
|
export function registerPresetResources(server) {
|
|
4
15
|
server.registerResource("renovate-presets", "renovate://presets", {
|
|
5
|
-
title: "Renovate built-in preset index",
|
|
6
|
-
description: `
|
|
16
|
+
title: "Renovate built-in preset namespace index",
|
|
17
|
+
description: `Thin markdown index of the ${NAMESPACES.length} namespaces covering all ${PRESET_NAMES.length} built-in Renovate presets (from renovate v${RENOVATE_VERSION}). Fetch renovate://presets/{namespace} for one namespace's preset list, or renovate://preset/{name} for a single preset's expanded JSON.`,
|
|
7
18
|
mimeType: "text/markdown",
|
|
8
19
|
}, async () => ({
|
|
9
20
|
contents: [
|
|
10
21
|
{
|
|
11
22
|
uri: "renovate://presets",
|
|
12
23
|
mimeType: "text/markdown",
|
|
13
|
-
text:
|
|
24
|
+
text: renderNamespaceIndex(),
|
|
14
25
|
},
|
|
15
26
|
],
|
|
16
27
|
}));
|
|
28
|
+
server.registerResource("renovate-presets-namespace", new ResourceTemplate("renovate://presets/{namespace}", {
|
|
29
|
+
list: async () => ({
|
|
30
|
+
resources: NAMESPACES.map((ns) => ({
|
|
31
|
+
uri: `renovate://presets/${ns}`,
|
|
32
|
+
name: `renovate-presets-${ns}`,
|
|
33
|
+
description: `Markdown listing of the ${BY_NAMESPACE.get(ns).length} built-in presets in the \`${ns}\` namespace.`,
|
|
34
|
+
mimeType: "text/markdown",
|
|
35
|
+
})),
|
|
36
|
+
}),
|
|
37
|
+
}), {
|
|
38
|
+
title: "Renovate built-in presets — single namespace",
|
|
39
|
+
description: "Markdown listing of every built-in Renovate preset in a single namespace (e.g. `config`, `docker`, `default`). Fetch renovate://presets for the namespace index.",
|
|
40
|
+
mimeType: "text/markdown",
|
|
41
|
+
}, async (uri, variables) => {
|
|
42
|
+
const raw = variables.namespace;
|
|
43
|
+
const ns = typeof raw === "string" ? decodeURIComponent(raw) : "";
|
|
44
|
+
const bucket = BY_NAMESPACE.get(ns);
|
|
45
|
+
if (!bucket) {
|
|
46
|
+
throw new Error(`Unknown preset namespace: ${ns}. Fetch renovate://presets for the list of namespaces.`);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
contents: [
|
|
50
|
+
{
|
|
51
|
+
uri: uri.href,
|
|
52
|
+
mimeType: "text/markdown",
|
|
53
|
+
text: renderNamespace(ns, bucket),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
});
|
|
17
58
|
server.registerResource("renovate-preset", new ResourceTemplate("renovate://preset/{name}", {
|
|
18
59
|
list: async () => ({
|
|
19
60
|
resources: PRESET_NAMES.map((name) => {
|
|
@@ -53,30 +94,36 @@ export function registerPresetResources(server) {
|
|
|
53
94
|
};
|
|
54
95
|
});
|
|
55
96
|
}
|
|
56
|
-
function
|
|
57
|
-
const byNamespace = new Map();
|
|
58
|
-
for (const name of PRESET_NAMES) {
|
|
59
|
-
const preset = PRESETS[name];
|
|
60
|
-
const bucket = byNamespace.get(preset.namespace) ?? [];
|
|
61
|
-
bucket.push({ name, description: preset.description });
|
|
62
|
-
byNamespace.set(preset.namespace, bucket);
|
|
63
|
-
}
|
|
64
|
-
const namespaces = Array.from(byNamespace.keys()).sort();
|
|
97
|
+
function renderNamespaceIndex() {
|
|
65
98
|
const lines = [
|
|
66
99
|
"# Renovate built-in presets",
|
|
67
100
|
"",
|
|
68
|
-
`Snapshot from renovate v${RENOVATE_VERSION} — **${PRESET_NAMES.length} presets** across ${
|
|
101
|
+
`Snapshot from renovate v${RENOVATE_VERSION} — **${PRESET_NAMES.length} presets** across ${NAMESPACES.length} namespaces.`,
|
|
102
|
+
"",
|
|
103
|
+
"Fetch `renovate://presets/<namespace>` for the full list of presets in one namespace, or `renovate://preset/<name>` for a single preset's expanded JSON.",
|
|
104
|
+
"",
|
|
105
|
+
"| Namespace | Presets | Resource |",
|
|
106
|
+
"| --- | ---: | --- |",
|
|
107
|
+
];
|
|
108
|
+
for (const ns of NAMESPACES) {
|
|
109
|
+
const count = BY_NAMESPACE.get(ns).length;
|
|
110
|
+
lines.push(`| \`${ns}\` | ${count} | \`renovate://presets/${ns}\` |`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
function renderNamespace(ns, bucket) {
|
|
116
|
+
const lines = [
|
|
117
|
+
`# Renovate \`${ns}\` presets`,
|
|
118
|
+
"",
|
|
119
|
+
`Snapshot from renovate v${RENOVATE_VERSION} — **${bucket.length} preset${bucket.length === 1 ? "" : "s"}** in the \`${ns}\` namespace.`,
|
|
69
120
|
"",
|
|
70
121
|
"Reference any of these in the `extends` array of your config. Fetch `renovate://preset/<name>` for the expanded JSON of a single preset.",
|
|
71
122
|
"",
|
|
72
123
|
];
|
|
73
|
-
for (const
|
|
74
|
-
|
|
75
|
-
lines.push(`## \`${ns}\` (${items.length})`, "");
|
|
76
|
-
for (const { name, description } of items) {
|
|
77
|
-
lines.push(description ? `- \`${name}\` — ${description}` : `- \`${name}\``);
|
|
78
|
-
}
|
|
79
|
-
lines.push("");
|
|
124
|
+
for (const { name, description } of bucket) {
|
|
125
|
+
lines.push(description ? `- \`${name}\` — ${description}` : `- \`${name}\``);
|
|
80
126
|
}
|
|
127
|
+
lines.push("");
|
|
81
128
|
return lines.join("\n");
|
|
82
129
|
}
|
|
@@ -28,7 +28,7 @@ export function registerPreviewCustomManager(server) {
|
|
|
28
28
|
" - Only `customType: \"regex\"` is supported.",
|
|
29
29
|
" - `matchStringsStrategy` other than `any` (the default) is not implemented; a warning is emitted.",
|
|
30
30
|
" - Template substitution handles only `{{groupName}}` references, not full Handlebars helpers.",
|
|
31
|
-
" - `.gitignore` is
|
|
31
|
+
" - `.gitignore` (and `.git/info/exclude`, plus nested `.gitignore`s) is honored like `git` does. `node_modules/` and `.git/` are always skipped as a safety net, even without a `.gitignore`.",
|
|
32
32
|
"",
|
|
33
33
|
"Run the `dry_run` tool afterwards for full-fidelity confirmation.",
|
|
34
34
|
].join("\n"),
|
|
@@ -4,7 +4,7 @@ import { resolveConfig } from "../lib/presetResolver.js";
|
|
|
4
4
|
export function registerResolveConfig(server) {
|
|
5
5
|
server.registerTool("resolve_config", {
|
|
6
6
|
title: "Resolve Renovate config (expand presets)",
|
|
7
|
-
description: "Expand every preset referenced by `extends` and return the fully resolved config. Built-in presets resolve offline against the committed catalogue.
|
|
7
|
+
description: "Expand every preset referenced by `extends` and return the fully resolved config. Built-in presets resolve offline against the committed catalogue. Pass `externalPresets: true` to fetch `github>` and `gitlab>` presets over HTTPS (with optional `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN` for private repos). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL, e.g. `https://ghe.example.com/api/v3` or `https://gitlab.example.com/api/v4`); pass `platform` in addition to route `local>` presets through the same endpoint. `bitbucket>`, `gitea>`, and npm presets are structurally unsupported and remain in `presetsUnresolved` regardless. Endpoint and platform are **tool inputs only** — env vars like `RENOVATE_ENDPOINT` are not read, since the MCP server runs under Claude rather than in your shell. Pass either `repoPath` (reads the repo's config) or `configContent` (an inline config object).",
|
|
8
8
|
inputSchema: {
|
|
9
9
|
repoPath: z
|
|
10
10
|
.string()
|
|
@@ -17,9 +17,17 @@ export function registerResolveConfig(server) {
|
|
|
17
17
|
externalPresets: z
|
|
18
18
|
.boolean()
|
|
19
19
|
.optional()
|
|
20
|
-
.describe("When true, fetch external presets (github>, gitlab>) over HTTPS. Credentials
|
|
20
|
+
.describe("When true, fetch external presets (github>, gitlab>) over HTTPS. Credentials come from GITHUB_TOKEN / GITLAB_TOKEN / RENOVATE_TOKEN env vars set on the MCP server process — via the `env` key in claude_desktop_config.json / .mcp.json, not your shell, since the MCP server runs as a child of Claude and does not inherit shell env. Default false."),
|
|
21
|
+
endpoint: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.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."),
|
|
25
|
+
platform: z
|
|
26
|
+
.enum(["github", "gitlab"])
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Platform flavour of `endpoint`. When set, `local>owner/repo` presets are fetched as if they were `<platform>>owner/repo` — useful for self-hosted setups where a config's `local>` presets actually live on your private GitHub/GitLab."),
|
|
21
29
|
},
|
|
22
|
-
}, async ({ repoPath, configContent, externalPresets }) => {
|
|
30
|
+
}, async ({ repoPath, configContent, externalPresets, endpoint, platform }) => {
|
|
23
31
|
if (!repoPath && !configContent) {
|
|
24
32
|
return {
|
|
25
33
|
isError: true,
|
|
@@ -50,6 +58,8 @@ export function registerResolveConfig(server) {
|
|
|
50
58
|
}
|
|
51
59
|
const { resolved, presetsResolved, presetsUnresolved } = await resolveConfig(source, {
|
|
52
60
|
fetchExternal: externalPresets ?? false,
|
|
61
|
+
endpoint,
|
|
62
|
+
platform,
|
|
53
63
|
});
|
|
54
64
|
return {
|
|
55
65
|
content: [
|
|
@@ -2,6 +2,29 @@ import { z } from "zod";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import { run, resolveRenovateTool, formatMissingBinaryError } from "../lib/renovateCli.js";
|
|
5
|
+
// Resolve symlinks in `p`, walking up to the nearest existing ancestor when
|
|
6
|
+
// tail components don't exist yet (e.g. a new subdir we're about to mkdir).
|
|
7
|
+
// This matters for the escape check: `fs.rename` follows symlinks along the
|
|
8
|
+
// parent path, so the check must compare the same canonical tree.
|
|
9
|
+
async function resolveWithExistingAncestor(p) {
|
|
10
|
+
const suffixes = [];
|
|
11
|
+
let current = p;
|
|
12
|
+
while (true) {
|
|
13
|
+
try {
|
|
14
|
+
const resolved = await fs.realpath(current);
|
|
15
|
+
return suffixes.length ? path.join(resolved, ...suffixes) : resolved;
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code !== "ENOENT")
|
|
19
|
+
throw err;
|
|
20
|
+
const parent = path.dirname(current);
|
|
21
|
+
if (parent === current)
|
|
22
|
+
throw err;
|
|
23
|
+
suffixes.unshift(path.basename(current));
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
5
28
|
export function registerWriteConfig(server) {
|
|
6
29
|
server.registerTool("write_config", {
|
|
7
30
|
title: "Write Renovate config",
|
|
@@ -22,7 +45,10 @@ export function registerWriteConfig(server) {
|
|
|
22
45
|
const repoAbs = path.resolve(repoPath);
|
|
23
46
|
const target = path.resolve(repoAbs, filename);
|
|
24
47
|
const rel = path.relative(repoAbs, target);
|
|
25
|
-
|
|
48
|
+
const repoReal = await resolveWithExistingAncestor(repoAbs);
|
|
49
|
+
const parentReal = await resolveWithExistingAncestor(path.dirname(target));
|
|
50
|
+
const checkRel = path.relative(repoReal, parentReal);
|
|
51
|
+
if (checkRel.startsWith("..") || path.isAbsolute(checkRel)) {
|
|
26
52
|
return {
|
|
27
53
|
isError: true,
|
|
28
54
|
content: [{ type: "text", text: "filename escapes repoPath" }],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "renovate-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for designing Renovate configurations interactively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
53
|
+
"ignore": "^7.0.5",
|
|
53
54
|
"json5": "^2.2.3",
|
|
54
55
|
"zod": "^4.0.0"
|
|
55
56
|
},
|