renovate-mcp 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@ Restart your client and try the prompt: *"List the namespaces available under `r
31
31
  ## What you can do
32
32
 
33
33
  - **Read and explain configs** — locate the active `renovate.json*`, expand every `extends` preset offline, and trace which preset set each field.
34
+ - **Discover presets from intent** — describe what you want ("automerge patches, group dev deps") and get ranked built-in (and your own local-repo) presets, plus a draft config skeleton when nothing fits.
34
35
  - **Preview custom managers** before running Renovate — regex and JSONata, with file/line hits and extracted dep info.
35
36
  - **Validate and lint** — schema validation plus a semantic lint pass for Renovate-specific footguns (unwrapped regexes, unknown manager names, deprecated keys).
36
37
  - **Dry-run** against a local checkout or a remote GitHub/GitLab — see exactly which PRs Renovate would open.
@@ -38,13 +39,14 @@ Restart your client and try the prompt: *"List the namespaces available under `r
38
39
 
39
40
  ## Tools & resources
40
41
 
41
- Thirteen tools and three resource templates. Each tool name below links to its full reference in [`docs/tools.md`](docs/tools.md).
42
+ Fourteen tools and three resource templates. Each tool name below links to its full reference in [`docs/tools.md`](docs/tools.md).
42
43
 
43
44
  | Tool | Purpose |
44
45
  | --- | --- |
45
46
  | [`check_setup`](docs/tools.md#check_setup) | Report Renovate CLI + validator availability, versions, install hints, and a `platformContext` block for env diagnosis. Also runs at startup. Pass an optional `repoPath` to add a `repoContext` block diagnosing the repo's git origin, config endpoint/platform, endpoint reachability, and token coverage. |
46
47
  | [`get_version`](docs/tools.md#get_version) | Report the renovate-mcp server version and whether it's a released or local/dev build. |
47
48
  | [`read_config`](docs/tools.md#read_config) | Locate and parse a repo's Renovate config in Renovate's own discovery order. |
49
+ | [`suggest_presets`](docs/tools.md#suggest_presets) | Search built-in (and optional local-repo) presets by natural-language intent; ranks matches and sketches an unvalidated draft config skeleton for multi-facet or weakly-covered intents. Offline. |
48
50
  | [`resolve_config`](docs/tools.md#resolve_config) | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS. |
49
51
  | [`explain_config`](docs/tools.md#explain_config) | Inverse of `resolve_config`: annotate every leaf field with the chain of presets that set it. |
50
52
  | [`resolve_config_diff`](docs/tools.md#resolve_config_diff) | Offline structural diff of two fully-resolved configs — changed fields plus an order-insensitive set diff of array keys (`packageRules`, `customManagers`, …). The refactor-friendly counterpart to `dry_run_diff`. |
@@ -64,7 +66,7 @@ Thirteen tools and three resource templates. Each tool name below links to its f
64
66
  - **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. Use WSL2 or a Linux/macOS host instead.
65
67
  - **Node.js ≥ 24** (aligns with Renovate's own engine requirement).
66
68
 
67
- Renovate ships bundled — the `renovate` package is a runtime dependency, so `validate_config`, `dry_run`, and `write_config` work out of the box with no separate install. The offline tools (`read_config`, `resolve_config`, `explain_config`, `resolve_config_diff`, `preview_custom_manager`, `lint_config`) never spawn Renovate at all.
69
+ Renovate ships bundled — the `renovate` package is a runtime dependency, so `validate_config`, `dry_run`, and `write_config` work out of the box with no separate install. The offline tools (`read_config`, `suggest_presets`, `resolve_config`, `explain_config`, `resolve_config_diff`, `preview_custom_manager`, `lint_config`) never spawn Renovate at all.
68
70
 
69
71
  **Optional env vars:**
70
72
 
@@ -125,11 +127,12 @@ Once the server is wired up, try prompts like these. Written for Claude but work
125
127
  - "Resolve my config and list anything that landed in `presetsUnresolved`, with the reason for each."
126
128
  - "Why is my `prCreation` set to `not-pending`? Use `explain_config` to trace which preset set it."
127
129
 
128
- **Browsing presets**
130
+ **Discovering & browsing presets**
129
131
 
132
+ - "I want to automerge patch and minor updates and group my dev dependencies — suggest presets for that, search my `../renovate-presets` repo too, and draft a config I can validate."
130
133
  - "List the presets in the `config` namespace." (uses the `renovate://presets/config` sub-resource — cheaper than pulling the whole index)
131
134
  - "What does `config:recommended` actually enable? Show me its expanded JSON."
132
- - "Find a built-in preset that pins GitHub Actions digests."
135
+ - "Find a built-in preset that pins GitHub Actions digests." (or use `suggest_presets` with that intent)
133
136
 
134
137
  **Self-hosted GitLab / GitHub Enterprise** (env set per [Platform setup](#platform-setup))
135
138
 
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { registerLintConfig } from "./tools/lintConfig.js";
8
8
  import { registerResolveConfig } from "./tools/resolveConfig.js";
9
9
  import { registerExplainConfig } from "./tools/explainConfig.js";
10
10
  import { registerResolveConfigDiff } from "./tools/resolveConfigDiff.js";
11
+ import { registerSuggestPresets } from "./tools/suggestPresets.js";
11
12
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
12
13
  import { registerDryRun } from "./tools/dryRun.js";
13
14
  import { registerDryRunDiff } from "./tools/dryRunDiff.js";
@@ -32,16 +33,17 @@ const BASE_INSTRUCTIONS = [
32
33
  "",
33
34
  "Workflow:",
34
35
  " 1. read_config — inspect the current config in a repo",
35
- " 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
36
- " 3. explain_config — inverse of resolve_config: trace which preset set each field (offline)",
37
- " 4. resolve_config_diff — offline structural diff of two resolved configs (changed fields + array/packageRules set diffs); use this over dry_run_diff for config refactors, especially when registries are unreachable",
38
- " 5. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
39
- " 6. validate_config — check a proposed config against Renovate's schema",
40
- " 7. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
41
- " 8. dry_run — preview what Renovate would actually do (no PRs)",
42
- " 9. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
43
- " 10. migrate_config — apply Renovate's built-in migrations (deprecated keys → current schema) and return the migrated config",
44
- " 11. write_config — save the agreed-upon config (validates first)",
36
+ " 2. suggest_presets — go from a natural-language intent to candidate built-in/local presets + an unvalidated draft skeleton (offline); then validate_config + lint_config",
37
+ " 3. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
38
+ " 4. explain_config — inverse of resolve_config: trace which preset set each field (offline)",
39
+ " 5. resolve_config_diff — offline structural diff of two resolved configs (changed fields + array/packageRules set diffs); use this over dry_run_diff for config refactors, especially when registries are unreachable",
40
+ " 6. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
41
+ " 7. validate_config — check a proposed config against Renovate's schema",
42
+ " 8. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
43
+ " 9. dry_run — preview what Renovate would actually do (no PRs)",
44
+ " 10. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
45
+ " 11. migrate_config — apply Renovate's built-in migrations (deprecated keys → current schema) and return the migrated config",
46
+ " 12. write_config — save the agreed-upon config (validates first)",
45
47
  "",
46
48
  "Before the first repo-touching tool call in a session (read_config, resolve_config, dry_run, write_config, …), call check_setup with the same repoPath. It surfaces token / endpoint / connectivity problems up front (e.g. \"set GITHUB_TOKEN or github-actions deps will be skipped\") instead of waiting for dry_run to fail. Skip if the user has already confirmed setup or is doing offline-only work that doesn't depend on git origin or registries.",
47
49
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
@@ -64,6 +66,7 @@ registerReadConfig(server);
64
66
  registerResolveConfig(server);
65
67
  registerExplainConfig(server);
66
68
  registerResolveConfigDiff(server);
69
+ registerSuggestPresets(server);
67
70
  registerPreviewCustomManager(server);
68
71
  registerValidateConfig(server);
69
72
  registerLintConfig(server);
@@ -4,6 +4,7 @@ export declare const TOKEN_MAX_BYTES = 1024;
4
4
  export declare const ENDPOINT_MAX_BYTES = 2048;
5
5
  export declare const REPOSITORY_MAX_BYTES = 256;
6
6
  export declare const FILENAME_MAX_BYTES = 512;
7
+ export declare const QUERY_MAX_BYTES = 2048;
7
8
  export declare const CONFIG_JSON_MAX_BYTES = 1000000;
8
9
  export declare const REPORT_JSON_MAX_BYTES = 10000000;
9
10
  export declare const HOST_RULES_MAX_ITEMS = 256;
@@ -13,6 +14,7 @@ export declare const tokenString: (description: string) => z.ZodString;
13
14
  export declare const endpointString: (description: string) => z.ZodString;
14
15
  export declare const repositoryString: (description: string) => z.ZodString;
15
16
  export declare const filenameString: (description: string) => z.ZodString;
17
+ export declare const queryString: (description: string) => z.ZodString;
16
18
  export declare const configRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
17
19
  export declare const reportRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
18
20
  export declare const hostRuleRecord: (description: string) => z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -9,6 +9,7 @@ export const TOKEN_MAX_BYTES = 1024;
9
9
  export const ENDPOINT_MAX_BYTES = 2048;
10
10
  export const REPOSITORY_MAX_BYTES = 256;
11
11
  export const FILENAME_MAX_BYTES = 512;
12
+ export const QUERY_MAX_BYTES = 2048;
12
13
  export const CONFIG_JSON_MAX_BYTES = 1_000_000;
13
14
  export const REPORT_JSON_MAX_BYTES = 10_000_000;
14
15
  export const HOST_RULES_MAX_ITEMS = 256;
@@ -18,6 +19,7 @@ export const tokenString = (description) => z.string().max(TOKEN_MAX_BYTES).desc
18
19
  export const endpointString = (description) => z.string().max(ENDPOINT_MAX_BYTES).describe(description);
19
20
  export const repositoryString = (description) => z.string().max(REPOSITORY_MAX_BYTES).describe(description);
20
21
  export const filenameString = (description) => z.string().max(FILENAME_MAX_BYTES).describe(description);
22
+ export const queryString = (description) => z.string().min(1).max(QUERY_MAX_BYTES).describe(description);
21
23
  const refineJsonSize = (limit) => (value) => {
22
24
  try {
23
25
  return JSON.stringify(value).length <= limit;
@@ -0,0 +1,125 @@
1
+ export interface CorpusPreset {
2
+ name: string;
3
+ namespace: string;
4
+ description: string | null;
5
+ body: Record<string, unknown>;
6
+ /** Set only for local presets — the source filename relative to presetsPath. */
7
+ file?: string;
8
+ }
9
+ export interface SuggestMatch {
10
+ name: string;
11
+ namespace: string;
12
+ description: string | null;
13
+ /** 0..1 relevance score, rounded for stable output. */
14
+ score: number;
15
+ /** Which fields a query token matched: any of name/namespace/description/body. */
16
+ matchedOn: string[];
17
+ file?: string;
18
+ body?: Record<string, unknown>;
19
+ }
20
+ export type Coverage = "strong" | "partial" | "weak";
21
+ interface CorpusEntry {
22
+ preset: CorpusPreset;
23
+ nameText: string;
24
+ namespaceText: string;
25
+ descText: string;
26
+ bodyText: string;
27
+ allText: string;
28
+ }
29
+ export type Corpus = CorpusEntry[];
30
+ /**
31
+ * Lowercase, split camelCase identifiers, split on non-alphanumeric runs, and
32
+ * drop short tokens + stopwords. Returns tokens in order (may contain dupes;
33
+ * callers dedupe as needed).
34
+ */
35
+ export declare function tokenize(text: string): string[];
36
+ export declare function buildCorpus(presets: CorpusPreset[]): Corpus;
37
+ export interface RankOptions {
38
+ namespace?: string;
39
+ limit?: number;
40
+ minScore?: number;
41
+ includeBody?: boolean;
42
+ }
43
+ /**
44
+ * Rank a corpus against a free-text query.
45
+ *
46
+ * Score = Σ (field-weight × IDF) over distinct query tokens, normalized by the
47
+ * maximum achievable (every token hitting the name). Field weighting favors
48
+ * name > namespace > description > body; the load-of-the-pool IDF downweights
49
+ * tokens that appear in many presets (e.g. "group"), so a preset matching a
50
+ * rare, specific token outranks one that only shares a ubiquitous one. Matching
51
+ * is substring-based (handles camelCase names like `automergeMinor`); this is a
52
+ * recall aid, not a stemmed IR engine.
53
+ */
54
+ export declare function rankCorpus(query: string, corpus: Corpus, opts?: RankOptions): SuggestMatch[];
55
+ export declare function coverageOf(bestScore: number): Coverage;
56
+ export interface IndexOptions {
57
+ maxFilesIndexed?: number;
58
+ maxPresetsIndexed?: number;
59
+ }
60
+ export interface IndexResult {
61
+ presets: CorpusPreset[];
62
+ warnings: string[];
63
+ }
64
+ /**
65
+ * Index a local presets repo: flat `*.json`/`*.json5` files, one preset per
66
+ * file. Name = filename without extension; namespace = "local"; description =
67
+ * top-level `description` → first `packageRules[].description` → null. Parses
68
+ * with JSON5 (parse-only side of the json5/jsonc-parser split). Malformed files
69
+ * become warnings, never crashes. Subdirectories are not recursed.
70
+ */
71
+ export declare function indexLocalPresets(presetsPath: string, opts?: IndexOptions): Promise<IndexResult>;
72
+ /** The committed built-in preset catalogue, built into a searchable corpus once. */
73
+ export declare function getBuiltInCorpus(): Corpus;
74
+ export interface DraftContribution {
75
+ extends?: string[];
76
+ packageRules?: Record<string, unknown>[];
77
+ topLevel?: Record<string, unknown>;
78
+ note?: string;
79
+ }
80
+ export interface Facet {
81
+ id: string;
82
+ /** Tested against the lowercased raw query (substring match, so phrases work). */
83
+ test: (q: string) => boolean;
84
+ build: (q: string) => DraftContribution;
85
+ }
86
+ export declare const FACETS: Facet[];
87
+ export declare function detectFacets(query: string): {
88
+ id: string;
89
+ contribution: DraftContribution;
90
+ }[];
91
+ export interface Draft {
92
+ config: Record<string, unknown>;
93
+ unvalidated: true;
94
+ hint: string;
95
+ facets: string[];
96
+ notes: string[];
97
+ }
98
+ /**
99
+ * Assemble an unvalidated draft Renovate config from the facets a query
100
+ * triggers. `config` is the pasteable/validatable Renovate config; the
101
+ * surrounding fields (`unvalidated`, `hint`, `facets`, `notes`) are metadata —
102
+ * pass `draft.config` (not the whole draft) to validate_config.
103
+ */
104
+ export declare function assembleDraft(query: string): Draft;
105
+ export interface SuggestOptions {
106
+ presetsPath?: string;
107
+ namespace?: string;
108
+ limit?: number;
109
+ minScore?: number;
110
+ includeBody?: boolean;
111
+ includeDraft?: boolean;
112
+ maxFilesIndexed?: number;
113
+ maxPresetsIndexed?: number;
114
+ }
115
+ export interface SuggestResult {
116
+ query: string;
117
+ builtIn: SuggestMatch[];
118
+ local: SuggestMatch[];
119
+ bestScore: number;
120
+ coverage: Coverage;
121
+ draft?: Draft;
122
+ warnings: string[];
123
+ }
124
+ export declare function suggestPresets(query: string, opts?: SuggestOptions): Promise<SuggestResult>;
125
+ export {};
@@ -0,0 +1,453 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import JSON5 from "json5";
4
+ import { PRESETS, PRESET_NAMES } from "../data/presets.generated.js";
5
+ // Conservative stopword set: true function words and natural-language filler
6
+ // that carry no Renovate signal. Ubiquitous *Renovate* tokens (group, config,
7
+ // all, …) are NOT listed here — the IDF weighting downweights them instead, so
8
+ // they still contribute when nothing rarer is present.
9
+ const STOPWORDS = new Set([
10
+ "the", "a", "an", "and", "or", "of", "to", "for", "with", "my", "our", "your",
11
+ "into", "on", "in", "is", "it", "that", "this", "then", "so", "but", "by", "as",
12
+ "at", "be", "i", "want", "wants", "wanted", "would", "like", "please", "need",
13
+ "needs", "should", "just", "only", "also", "when", "while", "via", "per", "do",
14
+ "does", "me", "we", "us", "can", "could",
15
+ ]);
16
+ const FIELD_WEIGHT = { name: 5, namespace: 4, description: 2, body: 1 };
17
+ const DEFAULT_LIMIT = 10;
18
+ const DEFAULT_MIN_SCORE = 0.12;
19
+ const STRONG_THRESHOLD = 0.6;
20
+ const PARTIAL_THRESHOLD = 0.3;
21
+ const DEFAULT_MAX_FILES_INDEXED = 2000;
22
+ const DEFAULT_MAX_PRESETS_INDEXED = 500;
23
+ const PRESET_EXT = /\.(json|json5)$/i;
24
+ function splitCamel(s) {
25
+ return s
26
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
27
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
28
+ }
29
+ /**
30
+ * Lowercase, split camelCase identifiers, split on non-alphanumeric runs, and
31
+ * drop short tokens + stopwords. Returns tokens in order (may contain dupes;
32
+ * callers dedupe as needed).
33
+ */
34
+ export function tokenize(text) {
35
+ const spaced = splitCamel(text).toLowerCase();
36
+ const out = [];
37
+ for (const part of spaced.split(/[^a-z0-9]+/)) {
38
+ if (part.length < 2)
39
+ continue;
40
+ if (STOPWORDS.has(part))
41
+ continue;
42
+ out.push(part);
43
+ }
44
+ return out;
45
+ }
46
+ export function buildCorpus(presets) {
47
+ return presets.map((preset) => {
48
+ const nameText = preset.name.toLowerCase();
49
+ const namespaceText = preset.namespace.toLowerCase();
50
+ const descText = (preset.description ?? "").toLowerCase();
51
+ let bodyText = "";
52
+ try {
53
+ bodyText = JSON.stringify(preset.body ?? {}).toLowerCase();
54
+ }
55
+ catch {
56
+ bodyText = "";
57
+ }
58
+ return {
59
+ preset,
60
+ nameText,
61
+ namespaceText,
62
+ descText,
63
+ bodyText,
64
+ allText: `${nameText} ${namespaceText} ${descText} ${bodyText}`,
65
+ };
66
+ });
67
+ }
68
+ function round(n) {
69
+ return Math.round(n * 10000) / 10000;
70
+ }
71
+ /**
72
+ * Rank a corpus against a free-text query.
73
+ *
74
+ * Score = Σ (field-weight × IDF) over distinct query tokens, normalized by the
75
+ * maximum achievable (every token hitting the name). Field weighting favors
76
+ * name > namespace > description > body; the load-of-the-pool IDF downweights
77
+ * tokens that appear in many presets (e.g. "group"), so a preset matching a
78
+ * rare, specific token outranks one that only shares a ubiquitous one. Matching
79
+ * is substring-based (handles camelCase names like `automergeMinor`); this is a
80
+ * recall aid, not a stemmed IR engine.
81
+ */
82
+ export function rankCorpus(query, corpus, opts = {}) {
83
+ const includeBody = opts.includeBody ?? false;
84
+ const limit = opts.limit ?? DEFAULT_LIMIT;
85
+ const minScore = opts.minScore ?? DEFAULT_MIN_SCORE;
86
+ const tokens = Array.from(new Set(tokenize(query)));
87
+ const pool = opts.namespace
88
+ ? corpus.filter((e) => e.preset.namespace === opts.namespace)
89
+ : corpus;
90
+ if (tokens.length === 0 || pool.length === 0)
91
+ return [];
92
+ const N = pool.length;
93
+ const idf = new Map();
94
+ for (const t of tokens) {
95
+ let df = 0;
96
+ for (const e of pool)
97
+ if (e.allText.includes(t))
98
+ df++;
99
+ idf.set(t, Math.log(1 + N / (1 + df)));
100
+ }
101
+ const maxPossible = tokens.reduce((sum, t) => sum + FIELD_WEIGHT.name * idf.get(t), 0);
102
+ const scored = [];
103
+ for (const e of pool) {
104
+ const matchedOn = new Set();
105
+ let raw = 0;
106
+ let nameHits = 0;
107
+ for (const t of tokens) {
108
+ let w = 0;
109
+ if (e.nameText.includes(t)) {
110
+ w = Math.max(w, FIELD_WEIGHT.name);
111
+ matchedOn.add("name");
112
+ nameHits++;
113
+ }
114
+ if (e.namespaceText.includes(t)) {
115
+ w = Math.max(w, FIELD_WEIGHT.namespace);
116
+ matchedOn.add("namespace");
117
+ }
118
+ if (e.descText.includes(t)) {
119
+ w = Math.max(w, FIELD_WEIGHT.description);
120
+ matchedOn.add("description");
121
+ }
122
+ if (e.bodyText.includes(t)) {
123
+ w = Math.max(w, FIELD_WEIGHT.body);
124
+ matchedOn.add("body");
125
+ }
126
+ raw += w * idf.get(t);
127
+ }
128
+ let score = maxPossible > 0 ? raw / maxPossible : 0;
129
+ // Name-overlap confidence boost: a preset whose NAME contains several of the
130
+ // query's terms is very likely the one the user means, even when the query
131
+ // carries filler words ("automerge minor updates" → automergeMinor). Pull the
132
+ // score toward 1 in proportion to how many distinct query tokens hit the name.
133
+ if (nameHits >= 2) {
134
+ score += (1 - score) * Math.min(0.15 * nameHits, 0.45);
135
+ }
136
+ if (score < minScore)
137
+ continue;
138
+ const match = {
139
+ name: e.preset.name,
140
+ namespace: e.preset.namespace,
141
+ description: e.preset.description,
142
+ score: round(score),
143
+ matchedOn: Array.from(matchedOn).sort(),
144
+ };
145
+ if (e.preset.file)
146
+ match.file = e.preset.file;
147
+ if (includeBody)
148
+ match.body = e.preset.body;
149
+ scored.push(match);
150
+ }
151
+ scored.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
152
+ return scored.slice(0, limit);
153
+ }
154
+ export function coverageOf(bestScore) {
155
+ if (bestScore >= STRONG_THRESHOLD)
156
+ return "strong";
157
+ if (bestScore >= PARTIAL_THRESHOLD)
158
+ return "partial";
159
+ return "weak";
160
+ }
161
+ function deriveDescription(body) {
162
+ const top = body.description;
163
+ if (typeof top === "string")
164
+ return top;
165
+ if (Array.isArray(top) && top.every((x) => typeof x === "string"))
166
+ return top.join(" ");
167
+ const rules = body.packageRules;
168
+ if (Array.isArray(rules)) {
169
+ for (const r of rules) {
170
+ if (r && typeof r === "object") {
171
+ const d = r.description;
172
+ if (typeof d === "string")
173
+ return d;
174
+ }
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ /**
180
+ * Index a local presets repo: flat `*.json`/`*.json5` files, one preset per
181
+ * file. Name = filename without extension; namespace = "local"; description =
182
+ * top-level `description` → first `packageRules[].description` → null. Parses
183
+ * with JSON5 (parse-only side of the json5/jsonc-parser split). Malformed files
184
+ * become warnings, never crashes. Subdirectories are not recursed.
185
+ */
186
+ export async function indexLocalPresets(presetsPath, opts = {}) {
187
+ const maxFiles = opts.maxFilesIndexed ?? DEFAULT_MAX_FILES_INDEXED;
188
+ const maxPresets = opts.maxPresetsIndexed ?? DEFAULT_MAX_PRESETS_INDEXED;
189
+ const warnings = [];
190
+ const entries = await readdir(presetsPath, { withFileTypes: true });
191
+ const files = entries
192
+ .filter((e) => e.isFile() && PRESET_EXT.test(e.name))
193
+ .map((e) => e.name)
194
+ .sort();
195
+ let scanned = files;
196
+ if (files.length > maxFiles) {
197
+ warnings.push(`Found ${files.length} preset files; only the first ${maxFiles} were scanned (maxFilesIndexed cap).`);
198
+ scanned = files.slice(0, maxFiles);
199
+ }
200
+ const presets = [];
201
+ for (const file of scanned) {
202
+ if (presets.length >= maxPresets) {
203
+ warnings.push(`Indexed ${maxPresets} presets; remaining files skipped (maxPresetsIndexed cap).`);
204
+ break;
205
+ }
206
+ const full = path.join(presetsPath, file);
207
+ let raw;
208
+ try {
209
+ raw = await readFile(full, "utf8");
210
+ }
211
+ catch (err) {
212
+ warnings.push(`Could not read ${file}: ${err.message}`);
213
+ continue;
214
+ }
215
+ let parsed;
216
+ try {
217
+ parsed = JSON5.parse(raw);
218
+ }
219
+ catch (err) {
220
+ warnings.push(`Could not parse ${file} as JSON/JSON5: ${err.message}`);
221
+ continue;
222
+ }
223
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
224
+ warnings.push(`Skipped ${file}: not a JSON object.`);
225
+ continue;
226
+ }
227
+ const body = parsed;
228
+ presets.push({
229
+ name: file.replace(PRESET_EXT, ""),
230
+ namespace: "local",
231
+ description: deriveDescription(body),
232
+ body,
233
+ file,
234
+ });
235
+ }
236
+ return { presets, warnings };
237
+ }
238
+ let builtInCorpus = null;
239
+ /** The committed built-in preset catalogue, built into a searchable corpus once. */
240
+ export function getBuiltInCorpus() {
241
+ if (!builtInCorpus) {
242
+ const presets = PRESET_NAMES.map((name) => {
243
+ const p = PRESETS[name];
244
+ return { name, namespace: p.namespace, description: p.description, body: p.body };
245
+ });
246
+ builtInCorpus = buildCorpus(presets);
247
+ }
248
+ return builtInCorpus;
249
+ }
250
+ function has(q, ...subs) {
251
+ return subs.some((s) => q.includes(s));
252
+ }
253
+ export const FACETS = [
254
+ {
255
+ id: "automerge",
256
+ test: (q) => has(q, "automerge", "auto-merge", "auto merge"),
257
+ build: (q) => {
258
+ if (has(q, "all", "everything"))
259
+ return { extends: [":automergeAll"] };
260
+ if (has(q, "major"))
261
+ return {
262
+ extends: [":automergeMajor"],
263
+ note: "Automerging major updates is risky — confirm with dry_run first.",
264
+ };
265
+ if (has(q, "minor"))
266
+ return { extends: [":automergeMinor"] };
267
+ if (has(q, "patch"))
268
+ return { extends: [":automergePatch"] };
269
+ return {
270
+ extends: [":automergeMinor"],
271
+ note: "Defaulted to minor+patch automerge; use :automergePatch to restrict to patch only.",
272
+ };
273
+ },
274
+ },
275
+ {
276
+ id: "groupDevDeps",
277
+ // Require "dev" adjacent to "dep" so "development"/"devops"/"developer"
278
+ // don't spuriously trigger a devDependencies grouping rule.
279
+ test: (q) => has(q, "group", "combine", "batch", "bundle") &&
280
+ has(q, "devdep", "dev dep", "dev-dep", "dev dependenc", "devdependenc"),
281
+ build: () => ({
282
+ packageRules: [
283
+ {
284
+ description: "Group devDependency updates into a single PR.",
285
+ matchDepTypes: ["devDependencies"],
286
+ groupName: "dev dependencies",
287
+ },
288
+ ],
289
+ }),
290
+ },
291
+ {
292
+ id: "groupNonMajor",
293
+ test: (q) => has(q, "group", "combine", "batch", "bundle") && has(q, "non-major", "nonmajor", "non major"),
294
+ build: () => ({ extends: ["group:allNonMajor"] }),
295
+ },
296
+ {
297
+ id: "groupMonorepo",
298
+ test: (q) => has(q, "monorepo"),
299
+ build: () => ({
300
+ extends: ["group:recommended"],
301
+ note: "group:recommended pulls in Renovate's curated monorepo groupings.",
302
+ }),
303
+ },
304
+ {
305
+ id: "schedule",
306
+ test: (q) => has(q, "schedule", "weekend", "weekly", "monthly", "off hours", "off-hours", "nonoffice", "non-office", "office hours", "business hours", "after hours", "out of hours"),
307
+ build: (q) => {
308
+ if (has(q, "monthly"))
309
+ return { extends: ["schedule:monthly"] };
310
+ // Renovate only ships schedule:nonOfficeHours (runs OUTSIDE work hours).
311
+ // Match only clearly-outside-work phrasings — bare "office hours" /
312
+ // "business hours" mean DURING work, which has no preset, so let them
313
+ // fall through to the weekly default rather than returning the opposite.
314
+ if (has(q, "non-office", "nonoffice", "off hours", "off-hours", "after hours", "out of hours", "outside office", "outside business"))
315
+ return { extends: ["schedule:nonOfficeHours"] };
316
+ if (has(q, "weekend", "weekly"))
317
+ return { extends: ["schedule:weekly"] };
318
+ return {
319
+ extends: ["schedule:weekly"],
320
+ note: "Defaulted to weekly; see the schedule:* presets for other cadences (no preset exists for 'during office hours').",
321
+ };
322
+ },
323
+ },
324
+ {
325
+ id: "pinDigests",
326
+ test: (q) => has(q, "pin") && has(q, "digest", "sha"),
327
+ build: (q) => {
328
+ if (has(q, "action", "github", "workflow"))
329
+ return { extends: ["helpers:pinGitHubActionDigests"] };
330
+ if (has(q, "docker", "image", "container"))
331
+ return { extends: ["docker:pinDigests"] };
332
+ return {
333
+ extends: ["docker:pinDigests", "helpers:pinGitHubActionDigests"],
334
+ note: "Digest pinning is datasource-specific; included Docker + GitHub Actions presets — keep the ones you need.",
335
+ };
336
+ },
337
+ },
338
+ {
339
+ id: "semanticCommits",
340
+ test: (q) => has(q, "semantic", "conventional") && has(q, "commit"),
341
+ build: () => ({ topLevel: { semanticCommits: "enabled" } }),
342
+ },
343
+ {
344
+ id: "disableMajor",
345
+ test: (q) => has(q, "disable", "without", "skip", "ignore") && has(q, "major"),
346
+ build: () => ({ extends: [":disableMajorUpdates"] }),
347
+ },
348
+ {
349
+ id: "separateMajor",
350
+ test: (q) => has(q, "separate", "split") && has(q, "major"),
351
+ build: () => ({ extends: [":separateMajorReleases"] }),
352
+ },
353
+ {
354
+ id: "lockfileMaintenance",
355
+ test: (q) => has(q, "lockfile", "lock file", "lock-file") ||
356
+ (has(q, "lock") && has(q, "maintenance", "maintain", "refresh")),
357
+ build: () => ({ extends: [":maintainLockFilesWeekly"] }),
358
+ },
359
+ {
360
+ id: "labels",
361
+ test: (q) => has(q, "label"),
362
+ build: () => ({
363
+ topLevel: { labels: ["dependencies"] },
364
+ note: "Placeholder label 'dependencies' — rename to match your repo's convention.",
365
+ }),
366
+ },
367
+ ];
368
+ export function detectFacets(query) {
369
+ const q = query.toLowerCase();
370
+ const out = [];
371
+ for (const facet of FACETS) {
372
+ if (facet.test(q))
373
+ out.push({ id: facet.id, contribution: facet.build(q) });
374
+ }
375
+ return out;
376
+ }
377
+ const DRAFT_HINT = "Unvalidated starting point. Pass draft.config to validate_config, then lint_config, before adopting it.";
378
+ /**
379
+ * Assemble an unvalidated draft Renovate config from the facets a query
380
+ * triggers. `config` is the pasteable/validatable Renovate config; the
381
+ * surrounding fields (`unvalidated`, `hint`, `facets`, `notes`) are metadata —
382
+ * pass `draft.config` (not the whole draft) to validate_config.
383
+ */
384
+ export function assembleDraft(query) {
385
+ const detected = detectFacets(query);
386
+ const extendsList = [];
387
+ const packageRules = [];
388
+ const topLevel = {};
389
+ const notes = [];
390
+ const facets = [];
391
+ for (const { id, contribution } of detected) {
392
+ facets.push(id);
393
+ if (contribution.extends) {
394
+ for (const e of contribution.extends)
395
+ if (!extendsList.includes(e))
396
+ extendsList.push(e);
397
+ }
398
+ if (contribution.packageRules)
399
+ packageRules.push(...contribution.packageRules);
400
+ if (contribution.topLevel)
401
+ Object.assign(topLevel, contribution.topLevel);
402
+ if (contribution.note)
403
+ notes.push(contribution.note);
404
+ }
405
+ const config = {};
406
+ if (extendsList.length)
407
+ config.extends = extendsList;
408
+ Object.assign(config, topLevel);
409
+ if (packageRules.length)
410
+ config.packageRules = packageRules;
411
+ // Cross-facet sanity: disabling and separating major updates are mutually
412
+ // exclusive. validate_config/lint_config won't flag this (it's schema-valid),
413
+ // so surface it in the draft notes.
414
+ if (facets.includes("disableMajor") && facets.includes("separateMajor")) {
415
+ notes.push(":disableMajorUpdates and :separateMajorReleases conflict — you can't separate major updates you've disabled. Keep one.");
416
+ }
417
+ if (facets.length === 0) {
418
+ notes.push("Couldn't infer preset structure from this query. Describe the intent in more detail (mention automerge, grouping, scheduling, digest pinning, semantic commits, etc.), or browse renovate://presets.");
419
+ }
420
+ return { config, unvalidated: true, hint: DRAFT_HINT, facets, notes };
421
+ }
422
+ export async function suggestPresets(query, opts = {}) {
423
+ const rankOpts = {
424
+ namespace: opts.namespace,
425
+ limit: opts.limit,
426
+ minScore: opts.minScore,
427
+ includeBody: opts.includeBody,
428
+ };
429
+ const warnings = [];
430
+ const builtIn = rankCorpus(query, getBuiltInCorpus(), rankOpts);
431
+ let local = [];
432
+ if (opts.presetsPath) {
433
+ const idx = await indexLocalPresets(opts.presetsPath, {
434
+ maxFilesIndexed: opts.maxFilesIndexed,
435
+ maxPresetsIndexed: opts.maxPresetsIndexed,
436
+ });
437
+ warnings.push(...idx.warnings);
438
+ local = rankCorpus(query, buildCorpus(idx.presets), rankOpts);
439
+ }
440
+ const bestScore = Math.max(builtIn[0]?.score ?? 0, local[0]?.score ?? 0);
441
+ const coverage = coverageOf(bestScore);
442
+ const result = { query, builtIn, local, bestScore, coverage, warnings };
443
+ if (opts.includeDraft ?? true) {
444
+ const draft = assembleDraft(query);
445
+ // Emit the draft when it adds something a single top match can't: a gap to
446
+ // fill (coverage not strong) OR genuine composition (>=2 facets). A single
447
+ // strongly-covered facet needs no draft — the top match already says it.
448
+ if (coverage !== "strong" || draft.facets.length >= 2) {
449
+ result.draft = draft;
450
+ }
451
+ }
452
+ return result;
453
+ }
@@ -1,4 +1,4 @@
1
- export declare const SERVER_VERSION = "1.1.1";
1
+ export declare const SERVER_VERSION = "1.2.0";
2
2
  export type BuildMode = "local" | "released";
3
3
  export interface VersionInfo {
4
4
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- export const SERVER_VERSION = "1.1.1"; // x-release-please-version
2
+ export const SERVER_VERSION = "1.2.0"; // x-release-please-version
3
3
  // "released" = the server is running from a `node_modules/` install (global
4
4
  // `npm i -g`, project-local `npm i`, or `npx renovate-mcp`). Anything else —
5
5
  // most commonly an MCP config of `{"command": "node", "args": [".../dist/index.js"]}`
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerSuggestPresets(server: McpServer): void;
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import { suggestPresets } from "../lib/presetSuggester.js";
3
+ import { pathString, queryString } from "../lib/inputLimits.js";
4
+ export function registerSuggestPresets(server) {
5
+ server.registerTool("suggest_presets", {
6
+ title: "Suggest Renovate presets from a natural-language intent",
7
+ description: [
8
+ "Search Renovate presets by intent — fast, offline, no `renovate` invocation, no network. Given a free-text goal (e.g. \"automerge patch and minor updates and group my dev dependencies\"), it ranks matching presets from the committed built-in catalogue and, optionally, a local presets repo you point at. When no strong match exists it also sketches an unvalidated draft config from recognized intent.",
9
+ "",
10
+ "Returns:",
11
+ " - `builtIn` / `local`: ranked matches `{ name, namespace, description, score, matchedOn, body? }`. Scoring is lexical (field-weighted name>namespace>description>body, with IDF rarity weighting so ubiquitous tokens like \"group\" don't drown specific matches) — a recall aid, not a stemmed search engine.",
12
+ " - `coverage` (strong | partial | weak) + `bestScore`: how well the best existing preset covers the intent.",
13
+ " - `draft` (only when coverage is not strong, unless `includeDraft:false`): `{ config, unvalidated:true, hint, facets, notes }`. `config` is a pasteable Renovate config assembled from a small curated facet taxonomy.",
14
+ "",
15
+ "This tool never validates the draft (that would require the Renovate CLI). Pass `draft.config` to `validate_config`, then `lint_config`, before adopting it — and fetch `renovate://preset/{name}` for a matched preset's full expanded body.",
16
+ ].join("\n"),
17
+ inputSchema: {
18
+ query: queryString("The intent to search for, in natural language (e.g. \"automerge patch updates and group dev dependencies\")."),
19
+ presetsPath: pathString("Absolute path to a local presets repo to index alongside the built-in catalogue (flat *.json / *.json5 files, one preset per file). Optional.").optional(),
20
+ namespace: z
21
+ .string()
22
+ .max(128)
23
+ .optional()
24
+ .describe("Restrict matches to a single namespace (e.g. \"config\", \"group\", \"schedule\"; \"local\" for local-repo presets)."),
25
+ limit: z
26
+ .number()
27
+ .int()
28
+ .positive()
29
+ .max(100)
30
+ .optional()
31
+ .describe("Max number of matches returned per corpus (default 10)."),
32
+ minScore: z
33
+ .number()
34
+ .min(0)
35
+ .max(1)
36
+ .optional()
37
+ .describe("Drop matches scoring below this 0..1 threshold (default 0.12)."),
38
+ includeBody: z
39
+ .boolean()
40
+ .optional()
41
+ .describe("Inline each matched preset's full body (default false). When false, fetch renovate://preset/{name} for a body."),
42
+ includeDraft: z
43
+ .boolean()
44
+ .optional()
45
+ .describe("Emit a draft skeleton when coverage is not strong (default true). Set false for discovery-only results."),
46
+ maxFilesIndexed: z
47
+ .number()
48
+ .int()
49
+ .positive()
50
+ .max(100_000)
51
+ .optional()
52
+ .describe("Safety cap on preset files scanned in presetsPath (default 2000)."),
53
+ maxPresetsIndexed: z
54
+ .number()
55
+ .int()
56
+ .positive()
57
+ .max(100_000)
58
+ .optional()
59
+ .describe("Safety cap on presets indexed from presetsPath (default 500)."),
60
+ },
61
+ }, async ({ query, presetsPath, namespace, limit, minScore, includeBody, includeDraft, maxFilesIndexed, maxPresetsIndexed, }) => {
62
+ try {
63
+ const result = await suggestPresets(query, {
64
+ presetsPath,
65
+ namespace,
66
+ limit,
67
+ minScore,
68
+ includeBody,
69
+ includeDraft,
70
+ maxFilesIndexed,
71
+ maxPresetsIndexed,
72
+ });
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
75
+ };
76
+ }
77
+ catch (err) {
78
+ return {
79
+ isError: true,
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: `suggest_presets failed: ${err.message}`,
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ });
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {