codex-dev-mcp-suite 1.1.0 → 1.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.
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * LLM reranker via any OpenAI-compatible /v1/chat/completions model.
3
- * Used when embeddings are unavailable: keyword prefilter -> LLM picks the
4
- * most relevant candidates. Degrades gracefully: returns null on any failure
5
- * so callers fall back to keyword ordering. Never throws.
3
+ * Tries provider chain slots in order, skipping any currently cooling down
4
+ * after a recent failure. Degrades gracefully: returns null on any failure so
5
+ * callers fall back to keyword ordering. Never throws. Never logs keys/bodies.
6
6
  */
7
7
  import http from "http";
8
8
  import https from "https";
9
9
  import { URL } from "url";
10
10
  import { deterministicEnabled } from "./env.js";
11
- import { providerChainConfig } from "./provider-chain.js";
11
+ import { providerChainConfig, isCoolingDown, recordOutcome } from "./provider-chain.js";
12
12
 
13
13
  const TIMEOUT_MS = Number(process.env.RERANK_TIMEOUT_MS || 30000);
14
14
  const ENABLED = process.env.RERANK_ENABLED !== "0";
@@ -25,10 +25,19 @@ export function rerankConfig() {
25
25
  };
26
26
  }
27
27
 
28
+ function providerKey(provider) {
29
+ return `${provider.label}|${provider.base}|${provider.model}`;
30
+ }
31
+
32
+ /**
33
+ * Resolves { content, retryable }. retryable=true means try the next provider
34
+ * (timeout, network error, 429, or 5xx). retryable=false with null content
35
+ * means a definitive non-retryable response (e.g. 4xx auth).
36
+ */
28
37
  function chatWithProvider(provider, messages) {
29
38
  return new Promise((resolve) => {
30
39
  let u;
31
- try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve(null); }
40
+ try { u = new URL("/v1/chat/completions", provider.base); } catch { return resolve({ content: null, retryable: false }); }
32
41
  const payload = Buffer.from(JSON.stringify({ model: provider.model, stream: false, temperature: 0, max_tokens: 200, messages }));
33
42
  const lib = u.protocol === "https:" ? https : http;
34
43
  const req = lib.request(
@@ -44,10 +53,14 @@ function chatWithProvider(provider, messages) {
44
53
  let body = "";
45
54
  res.on("data", (c) => (body += c));
46
55
  res.on("end", () => {
47
- if (res.statusCode !== 200) return resolve(null);
56
+ const status = res.statusCode || 0;
57
+ if (status !== 200) {
58
+ const retryable = status === 429 || status >= 500;
59
+ return resolve({ content: null, retryable });
60
+ }
48
61
  try {
49
62
  const j = JSON.parse(body);
50
- return resolve(j?.choices?.[0]?.message?.content ?? null);
63
+ return resolve({ content: j?.choices?.[0]?.message?.content ?? null, retryable: false });
51
64
  } catch { /* maybe SSE */ }
52
65
  try {
53
66
  let acc = "";
@@ -59,13 +72,13 @@ function chatWithProvider(provider, messages) {
59
72
  const j = JSON.parse(d);
60
73
  acc += j?.choices?.[0]?.delta?.content || j?.choices?.[0]?.message?.content || "";
61
74
  }
62
- return resolve(acc || null);
63
- } catch { return resolve(null); }
75
+ return resolve({ content: acc || null, retryable: false });
76
+ } catch { return resolve({ content: null, retryable: true }); }
64
77
  });
65
78
  }
66
79
  );
67
- req.on("error", () => resolve(null));
68
- req.on("timeout", () => { req.destroy(); resolve(null); });
80
+ req.on("error", () => resolve({ content: null, retryable: true }));
81
+ req.on("timeout", () => { req.destroy(); resolve({ content: null, retryable: true }); });
69
82
  req.write(payload);
70
83
  req.end();
71
84
  });
@@ -75,8 +88,11 @@ async function chat(messages) {
75
88
  if (!ENABLED || deterministicEnabled()) return null;
76
89
  const { providers } = providerChainConfig();
77
90
  for (const provider of providers) {
78
- const out = await chatWithProvider(provider, messages);
79
- if (out) return out;
91
+ const key = providerKey(provider);
92
+ if (isCoolingDown(key)) continue;
93
+ const { content, retryable } = await chatWithProvider(provider, messages);
94
+ if (content) { recordOutcome(key, true); return content; }
95
+ if (retryable) recordOutcome(key, false);
80
96
  }
81
97
  return null;
82
98
  }
@@ -73,6 +73,20 @@ MCP_PROVIDER_CHAIN3_MODEL=openai/gpt-4o-mini
73
73
 
74
74
  You can add `MCP_PROVIDER_CHAIN4_*`, `MCP_PROVIDER_CHAIN5_*`, and so on. The provider name is only a label; any OpenAI-compatible endpoint works when `BASE_URL`, `API_KEY`, and `MODEL` are set.
75
75
 
76
+ On a `429`, `5xx`, timeout, or network error, a provider is put on a short cooldown and the next slot is tried. Tune the window with `MCP_PROVIDER_COOLDOWN_MS` (default `60000`).
77
+
78
+ ## Diagnostics
79
+
80
+ Each server bin supports inspection flags that never start the server:
81
+
82
+ ```bash
83
+ project-memory-mcp --version
84
+ project-memory-mcp --doctor # storage + model/provider config, API keys redacted
85
+ project-memory-mcp --help
86
+ ```
87
+
88
+ `--doctor` reports configured providers and storage paths and shows API keys only as `set (<n> chars)` or `not set`, never the raw value.
89
+
76
90
  Example OpenRouter-style config:
77
91
 
78
92
  ```bash
package/lib/prune.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Dev MCP Suite — prune library.
3
+ *
4
+ * Identifies and removes "temp" project slugs (prefix `tmp.`) from all three
5
+ * stores. Used by `bin/prune.mjs`. No MCP, no stdio — safe to import from tests.
6
+ *
7
+ * Storage layout (matches the rest of the suite):
8
+ * <root>/vault/<slug>/
9
+ * <root>/journal/<slug>/
10
+ * <root>/checkpoints/<slug>/
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+
16
+ export const STORES = ["vault", "journal", "checkpoints"];
17
+ const TEMP_PREFIX = "tmp.";
18
+
19
+ /** Returns the list of slug names matching temp pattern across all stores. */
20
+ export function findTempSlugs({ root }) {
21
+ const set = new Set();
22
+ for (const store of STORES) {
23
+ let entries = [];
24
+ try {
25
+ entries = fs.readdirSync(path.join(root, store), { withFileTypes: true });
26
+ } catch { continue; }
27
+ for (const e of entries) {
28
+ if (e.isDirectory() && e.name.startsWith(TEMP_PREFIX)) set.add(e.name);
29
+ }
30
+ }
31
+ return [...set].sort();
32
+ }
33
+
34
+ /** Recursively sum bytes under each slug's directories across stores. */
35
+ export function estimateBytes({ root, slugs }) {
36
+ let total = 0;
37
+ for (const slug of slugs) {
38
+ for (const store of STORES) {
39
+ total += dirSize(path.join(root, store, slug));
40
+ }
41
+ }
42
+ return total;
43
+ }
44
+
45
+ function dirSize(p) {
46
+ let total = 0;
47
+ let stack = [p];
48
+ while (stack.length) {
49
+ const cur = stack.pop();
50
+ let stat;
51
+ try { stat = fs.lstatSync(cur); } catch { continue; }
52
+ if (stat.isDirectory()) {
53
+ let children = [];
54
+ try { children = fs.readdirSync(cur); } catch { continue; }
55
+ for (const c of children) stack.push(path.join(cur, c));
56
+ } else {
57
+ total += stat.size;
58
+ }
59
+ }
60
+ return total;
61
+ }
62
+
63
+ /** Validate slugs only contain temp prefix — refuse to delete otherwise. */
64
+ function assertTempSlugs(slugs) {
65
+ for (const s of slugs) {
66
+ if (!s.startsWith(TEMP_PREFIX)) {
67
+ throw new Error(`refusing to remove non-temp slug: "${s}" (must start with "${TEMP_PREFIX}")`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Compute a plan: per-slug list of stores where the dir exists + total bytes. */
73
+ export function planPrune({ root }) {
74
+ const slugs = findTempSlugs({ root });
75
+ return slugs.map((slug) => {
76
+ const stores = [];
77
+ for (const store of STORES) {
78
+ const p = path.join(root, store, slug);
79
+ try {
80
+ if (fs.existsSync(p)) stores.push(store);
81
+ } catch { /* ignore */ }
82
+ }
83
+ const bytes = estimateBytes({ root, slugs: [slug] });
84
+ return { slug, stores, bytes };
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Remove temp slugs from all stores. Default is dry-run (no deletion).
90
+ * @param {{ root: string, slugs: string[], dryRun?: boolean }} opts
91
+ * @returns {{ removed: string[], skipped: string[], errors: Array<{slug:string,store:string,error:string}> }}
92
+ */
93
+ export function applyPrune({ root, slugs, dryRun = true }) {
94
+ assertTempSlugs(slugs);
95
+ const removed = [];
96
+ const skipped = [];
97
+ const errors = [];
98
+ for (const slug of slugs) {
99
+ let anyRemoved = false;
100
+ for (const store of STORES) {
101
+ const p = path.join(root, store, slug);
102
+ if (!fs.existsSync(p)) continue;
103
+ if (dryRun) { skipped.push(slug); continue; }
104
+ try {
105
+ fs.rmSync(p, { recursive: true, force: true });
106
+ anyRemoved = true;
107
+ } catch (e) {
108
+ errors.push({ slug, store, error: String(e && e.message || e) });
109
+ }
110
+ }
111
+ if (anyRemoved) removed.push(slug);
112
+ }
113
+ return { removed, skipped, errors };
114
+ }
package/lib/stats.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dev MCP Suite — stats aggregation library.
3
+ *
4
+ * Pure functions for summarizing local memory storage across the three stores
5
+ * (vault / journal / checkpoints). No MCP, no stdio — used by bin/stats.mjs
6
+ * and safe to import from tests.
7
+ *
8
+ * Storage layout assumed (see each MCP server's server.js for source of truth):
9
+ * <root>/vault/<slug>/notes/*.md (one .md per memory_save note)
10
+ * <root>/journal/<slug>/journal.jsonl (append-only entries)
11
+ * <root>/journal/<slug>/handoff.json (optional)
12
+ * <root>/checkpoints/<slug>/snapshots/<id>/ (one dir per snapshot)
13
+ * <root>/checkpoints/<slug>/manifest.json (checkpoint index)
14
+ */
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+
19
+ const VAULT_DIRNAME = "vault";
20
+ const JOURNAL_DIRNAME = "journal";
21
+ const CHECKPOINT_DIRNAME = "checkpoints";
22
+
23
+ function safeListDirs(parent) {
24
+ try {
25
+ return fs
26
+ .readdirSync(parent, { withFileTypes: true })
27
+ .filter((d) => d.isDirectory())
28
+ .map((d) => d.name);
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function safeListFiles(parent) {
35
+ try {
36
+ return fs.readdirSync(parent);
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ function newestMtimeMs(dir) {
43
+ try {
44
+ const stat = fs.statSync(dir);
45
+ return stat.mtimeMs;
46
+ } catch {
47
+ return 0;
48
+ }
49
+ }
50
+
51
+ function isoFromMs(ms) {
52
+ if (!ms) return null;
53
+ return new Date(ms).toISOString();
54
+ }
55
+
56
+ function isTempSlug(slug) {
57
+ return /^tmp\./.test(slug);
58
+ }
59
+
60
+ function summarizeProject(slug, { root }) {
61
+ const vaultDir = path.join(root, VAULT_DIRNAME, slug);
62
+ const journalDir = path.join(root, JOURNAL_DIRNAME, slug);
63
+ const cpDir = path.join(root, CHECKPOINT_DIRNAME, slug);
64
+
65
+ const notesDir = path.join(vaultDir, "notes");
66
+ const notes = safeListFiles(notesDir).filter((f) => f.endsWith(".md")).length;
67
+
68
+ let journalEntries = 0;
69
+ let lastEntryTs = null;
70
+ const jl = path.join(journalDir, "journal.jsonl");
71
+ if (fs.existsSync(jl)) {
72
+ try {
73
+ const lines = fs.readFileSync(jl, "utf8").split("\n").filter(Boolean);
74
+ journalEntries = lines.length;
75
+ // journal entries are append-only and roughly time-ordered; parse first + last
76
+ for (const line of lines) {
77
+ try {
78
+ const obj = JSON.parse(line);
79
+ if (obj && obj.ts) {
80
+ if (!lastEntryTs || obj.ts > lastEntryTs) lastEntryTs = obj.ts;
81
+ }
82
+ } catch { /* skip malformed */ }
83
+ }
84
+ } catch { /* ignore read errors */ }
85
+ }
86
+
87
+ // checkpoints: count snapshot dirs
88
+ let checkpoints = 0;
89
+ const snapsDir = path.join(cpDir, "snapshots");
90
+ if (fs.existsSync(snapsDir)) {
91
+ checkpoints = safeListDirs(snapsDir).length;
92
+ }
93
+
94
+ // last activity across all artifacts (mtime fallback)
95
+ let lastActivityMs = 0;
96
+ for (const d of [vaultDir, journalDir, cpDir]) {
97
+ const m = newestMtimeMs(d);
98
+ if (m > lastActivityMs) lastActivityMs = m;
99
+ }
100
+ // prefer parsed entry ts if newer than filesystem mtime
101
+ if (lastEntryTs) {
102
+ const t = Date.parse(lastEntryTs);
103
+ if (!Number.isNaN(t) && t > lastActivityMs) lastActivityMs = t;
104
+ }
105
+ const lastActivityTs = isoFromMs(lastActivityMs);
106
+
107
+ return {
108
+ slug,
109
+ notes,
110
+ journalEntries,
111
+ checkpoints,
112
+ lastActivityTs,
113
+ temp: isTempSlug(slug),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Compute aggregate stats for a memory root.
119
+ * @param {object} opts
120
+ * @param {string} opts.root - absolute path to memories root (containing vault/, journal/, checkpoints/)
121
+ * @param {number} [opts.topLimit=10] - max entries in topByNotes / recentActivity
122
+ * @returns {{
123
+ * root: string,
124
+ * totals: { notes: number, journalProjects: number, checkpoints: number },
125
+ * projectCount: number,
126
+ * topByNotes: Array<{ slug: string, notes: number }>,
127
+ * recentActivity: Array<{ slug: string, ts: string }>,
128
+ * tempSlugs: string[],
129
+ * perProject: Array<{ slug: string, notes: number, journalEntries: number, checkpoints: number, lastActivityTs: string|null, temp: boolean }>,
130
+ * }}
131
+ */
132
+ export function computeStats({ root, topLimit = 10 } = {}) {
133
+ if (!root) throw new Error("computeStats: `root` is required");
134
+
135
+ // union of slugs across all three stores
136
+ const slugSet = new Set();
137
+ for (const store of [VAULT_DIRNAME, JOURNAL_DIRNAME, CHECKPOINT_DIRNAME]) {
138
+ for (const slug of safeListDirs(path.join(root, store))) slugSet.add(slug);
139
+ }
140
+
141
+ const perProject = [...slugSet]
142
+ .map((slug) => summarizeProject(slug, { root }))
143
+ .sort((a, b) => {
144
+ if (a.temp !== b.temp) return a.temp ? 1 : -1; // real first
145
+ return a.slug.localeCompare(b.slug);
146
+ });
147
+
148
+ const totals = perProject.reduce(
149
+ (acc, p) => ({
150
+ notes: acc.notes + p.notes,
151
+ journalProjects: acc.journalProjects + (p.journalEntries > 0 ? 1 : 0),
152
+ checkpoints: acc.checkpoints + p.checkpoints,
153
+ }),
154
+ { notes: 0, journalProjects: 0, checkpoints: 0 },
155
+ );
156
+
157
+ const topByNotes = [...perProject]
158
+ .filter((p) => p.notes > 0)
159
+ .sort((a, b) => b.notes - a.notes || a.slug.localeCompare(b.slug))
160
+ .slice(0, topLimit)
161
+ .map((p) => ({ slug: p.slug, notes: p.notes }));
162
+
163
+ const recentActivity = [...perProject]
164
+ .filter((p) => p.lastActivityTs && !p.temp)
165
+ .sort((a, b) => (a.lastActivityTs < b.lastActivityTs ? 1 : -1))
166
+ .slice(0, topLimit)
167
+ .map((p) => ({ slug: p.slug, ts: p.lastActivityTs }));
168
+
169
+ const tempSlugs = perProject.filter((p) => p.temp).map((p) => p.slug).sort();
170
+
171
+ return {
172
+ root,
173
+ totals,
174
+ projectCount: perProject.length,
175
+ topByNotes,
176
+ recentActivity,
177
+ tempSlugs,
178
+ perProject,
179
+ };
180
+ }
181
+
182
+ /** Right-pad a slug to a fixed display width. */
183
+ function pad(s, w) {
184
+ s = String(s);
185
+ if (s.length >= w) return s + " ";
186
+ return s + " ".repeat(w - s.length);
187
+ }
188
+ function rpadNum(n, w) {
189
+ const s = String(n);
190
+ return s.length >= w ? s : " ".repeat(w - s.length) + s;
191
+ }
192
+
193
+ function formatLocal(ts) {
194
+ if (!ts) return "—";
195
+ try {
196
+ return new Date(ts).toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
197
+ } catch {
198
+ return ts;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Human-readable text summary.
204
+ * @param {ReturnType<typeof computeStats>} stats
205
+ * @param {object} [opts]
206
+ * @param {string} [opts.timezone="UTC"] - display-only label, not used to convert
207
+ */
208
+ export function formatText(stats, opts = {}) {
209
+ const lines = [];
210
+ lines.push("Dev MCP Suite — stats");
211
+ lines.push("======================");
212
+ lines.push(`Storage root: ${stats.root}`);
213
+ lines.push("");
214
+ lines.push("Totals");
215
+ lines.push("------");
216
+ lines.push(` Notes: ${stats.totals.notes}`);
217
+ lines.push(` Journal projects:${rpadNum(stats.totals.journalProjects, 2)}`);
218
+ lines.push(` Checkpoints: ${stats.totals.checkpoints}`);
219
+ lines.push(` Distinct projects: ${stats.projectCount}`);
220
+ lines.push("");
221
+
222
+ if (stats.topByNotes.length) {
223
+ lines.push(`Top projects by notes (top ${stats.topByNotes.length})`);
224
+ lines.push("------------------------");
225
+ const slugW = Math.max(8, ...stats.topByNotes.map((p) => p.slug.length));
226
+ for (const p of stats.topByNotes) {
227
+ lines.push(` ${rpadNum(p.notes, 4)} ${pad(p.slug, slugW)}`);
228
+ }
229
+ lines.push("");
230
+ }
231
+
232
+ if (stats.recentActivity.length) {
233
+ lines.push("Most recent activity");
234
+ lines.push("--------------------");
235
+ const slugW = Math.max(8, ...stats.recentActivity.map((p) => p.slug.length));
236
+ for (const p of stats.recentActivity) {
237
+ lines.push(` ${pad(p.slug, slugW)} ${formatLocal(p.ts)}`);
238
+ }
239
+ lines.push("");
240
+ }
241
+
242
+ if (stats.tempSlugs.length) {
243
+ lines.push(`Temp/cleanup candidates (${stats.tempSlugs.length})`);
244
+ lines.push("------------------------");
245
+ for (const slug of stats.tempSlugs) lines.push(` ${slug}`);
246
+ lines.push("");
247
+ } else {
248
+ lines.push("Temp/cleanup candidates: none");
249
+ lines.push("");
250
+ }
251
+
252
+ lines.push(`Generated at ${new Date().toISOString()}`);
253
+ return lines.join("\n");
254
+ }
255
+
256
+ /** Stable JSON serialization (sorted keys for top-level + totals). */
257
+ export function formatJson(stats) {
258
+ const out = {
259
+ root: stats.root,
260
+ totals: stats.totals,
261
+ projectCount: stats.projectCount,
262
+ topByNotes: stats.topByNotes,
263
+ recentActivity: stats.recentActivity,
264
+ tempSlugs: stats.tempSlugs,
265
+ perProject: stats.perProject,
266
+ generatedAt: new Date().toISOString(),
267
+ };
268
+ return JSON.stringify(out, null, 2);
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-dev-mcp-suite",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Four local, file-based MCP servers for solo devs/vibecoders: persistent project memory, session handoff/resume, git-independent file checkpoints, and token-efficient project briefings. Works with any MCP client.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,16 +32,20 @@
32
32
  "project-memory-mcp": "bin/project-memory.mjs",
33
33
  "devjournal-mcp": "bin/devjournal.mjs",
34
34
  "checkpoint-mcp": "bin/checkpoint.mjs",
35
- "context-pack-mcp": "bin/context-pack.mjs"
35
+ "context-pack-mcp": "bin/context-pack.mjs",
36
+ "stats": "bin/stats.mjs",
37
+ "prune": "bin/prune.mjs"
36
38
  },
37
39
  "scripts": {
38
- "test": "node run-tests.mjs"
40
+ "test": "node run-tests.mjs",
41
+ "test:stats": "node tests/stats.test.mjs"
39
42
  },
40
43
  "dependencies": {
41
44
  "@modelcontextprotocol/sdk": "^1.0.0"
42
45
  },
43
46
  "files": [
44
47
  "bin/*.mjs",
48
+ "bin/_meta.mjs",
45
49
  "project-memory/*.js",
46
50
  "project-memory/server.js",
47
51
  "project-memory/embedding.js",
@@ -69,6 +73,7 @@
69
73
  "CHANGELOG.md",
70
74
  "README.md",
71
75
  "LICENSE",
72
- ".env.example"
76
+ ".env.example",
77
+ "lib/*.js"
73
78
  ]
74
79
  }
@@ -7,6 +7,11 @@ function value(name) {
7
7
  return String(process.env[name] || "").trim();
8
8
  }
9
9
 
10
+ export function redactKey(key) {
11
+ const k = String(key || "");
12
+ return k ? `set (${k.length} chars)` : "not set";
13
+ }
14
+
10
15
  function providerEnv(prefix) {
11
16
  const label = value(prefix);
12
17
  const base = value(`${prefix}_BASE_URL`);
@@ -16,20 +21,34 @@ function providerEnv(prefix) {
16
21
  return { label, base, key, model };
17
22
  }
18
23
 
19
- function numberedProviders() {
20
- const providers = [];
21
- const primary = providerEnv("MCP_PROVIDER_PRIMARY");
22
- if (primary) providers.push(primary);
24
+ function slotIssues(prefix) {
25
+ const present = ["", "_BASE_URL", "_API_KEY", "_MODEL"].some((s) => value(`${prefix}${s}`));
26
+ if (!present) return null;
27
+ const missing = [];
28
+ if (!value(prefix)) missing.push(`${prefix}`);
29
+ if (!value(`${prefix}_BASE_URL`)) missing.push(`${prefix}_BASE_URL`);
30
+ if (!value(`${prefix}_API_KEY`)) missing.push(`${prefix}_API_KEY`);
31
+ if (!value(`${prefix}_MODEL`)) missing.push(`${prefix}_MODEL`);
32
+ return missing.length ? { prefix, missing } : null;
33
+ }
23
34
 
24
- const slots = Object.keys(process.env)
25
- .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)$/)?.[1])
35
+ function numberedPrefixes() {
36
+ const prefixes = ["MCP_PROVIDER_PRIMARY"];
37
+ const nums = Object.keys(process.env)
38
+ .map((name) => name.match(/^MCP_PROVIDER_CHAIN(\d+)/)?.[1])
26
39
  .filter(Boolean)
27
40
  .map(Number)
28
- .filter((n) => n >= 2)
29
- .sort((a, b) => a - b);
41
+ .filter((n) => n >= 2);
42
+ for (const n of [...new Set(nums)].sort((a, b) => a - b)) {
43
+ prefixes.push(`MCP_PROVIDER_CHAIN${n}`);
44
+ }
45
+ return prefixes;
46
+ }
30
47
 
31
- for (const n of slots) {
32
- const provider = providerEnv(`MCP_PROVIDER_CHAIN${n}`);
48
+ function numberedProviders() {
49
+ const providers = [];
50
+ for (const prefix of numberedPrefixes()) {
51
+ const provider = providerEnv(prefix);
33
52
  if (provider) providers.push(provider);
34
53
  }
35
54
  return providers;
@@ -54,3 +73,37 @@ export function providerChainConfig() {
54
73
  }
55
74
  return { providers, enabled: providers.length > 0, deterministic };
56
75
  }
76
+
77
+ export function providerChainDiagnostics() {
78
+ const cfg = providerChainConfig();
79
+ const issues = [];
80
+ for (const prefix of numberedPrefixes()) {
81
+ const issue = slotIssues(prefix);
82
+ if (issue) issues.push(issue);
83
+ }
84
+ return {
85
+ deterministic: cfg.deterministic,
86
+ enabled: cfg.enabled,
87
+ providers: cfg.providers.map((p) => ({ label: p.label, base: p.base, model: p.model, apiKey: redactKey(p.key) })),
88
+ issues,
89
+ };
90
+ }
91
+
92
+ const COOLDOWN_MS = Number(process.env.MCP_PROVIDER_COOLDOWN_MS || 60000);
93
+ const cooldowns = new Map();
94
+
95
+ export function isCoolingDown(key, now = Date.now()) {
96
+ const until = cooldowns.get(key);
97
+ if (!until) return false;
98
+ if (now >= until) { cooldowns.delete(key); return false; }
99
+ return true;
100
+ }
101
+
102
+ export function recordOutcome(key, ok, now = Date.now()) {
103
+ if (ok) { cooldowns.delete(key); return; }
104
+ cooldowns.set(key, now + COOLDOWN_MS);
105
+ }
106
+
107
+ export function _resetCooldowns() {
108
+ cooldowns.clear();
109
+ }