@mushi-mushi/mcp 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -3,14 +3,14 @@ import {
3
3
  TDD_TOOL_CATALOG,
4
4
  TOOL_CATALOG,
5
5
  USE_MUSHI_INTENTS
6
- } from "./chunk-OZJEY3QR.js";
6
+ } from "./chunk-RVZIKP4X.js";
7
7
  import {
8
8
  MUSHI_SERVER_METADATA
9
9
  } from "./chunk-EANW5YTI.js";
10
10
  import {
11
11
  DEPRECATED_TOOL_ALIASES,
12
12
  toolMatchesFeatures
13
- } from "./chunk-VBXXUK5G.js";
13
+ } from "./chunk-ZSVRLSW5.js";
14
14
 
15
15
  // src/server.ts
16
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -143,8 +143,107 @@ function wrapUntrustedJson(value, role) {
143
143
  return wrapUntrusted(serialised, role);
144
144
  }
145
145
 
146
+ // src/stdout-guard.ts
147
+ import { Console } from "console";
148
+ var GUARDED_CONSOLE_METHODS = [
149
+ "log",
150
+ "info",
151
+ "debug",
152
+ "dir",
153
+ "dirxml",
154
+ "table",
155
+ "group",
156
+ "groupCollapsed",
157
+ "groupEnd",
158
+ "count",
159
+ "countReset",
160
+ "time",
161
+ "timeEnd",
162
+ "timeLog",
163
+ "trace",
164
+ "assert",
165
+ "warn",
166
+ "error"
167
+ ];
168
+ var installed = false;
169
+ function installStdoutGuard() {
170
+ if (installed) return false;
171
+ if (typeof process !== "undefined" && process.env?.MUSHI_MCP_STDOUT_GUARD === "off") {
172
+ return false;
173
+ }
174
+ if (typeof process === "undefined" || typeof process.stderr?.write !== "function" || typeof console === "undefined") {
175
+ return false;
176
+ }
177
+ try {
178
+ const stderrConsole = new Console({
179
+ stdout: process.stderr,
180
+ stderr: process.stderr,
181
+ // Colour codes would land in the client's log pane as escape noise.
182
+ colorMode: false
183
+ });
184
+ const target = console;
185
+ const source = stderrConsole;
186
+ for (const method of GUARDED_CONSOLE_METHODS) {
187
+ const fn = source[method];
188
+ if (typeof fn === "function") {
189
+ target[method] = fn.bind(stderrConsole);
190
+ }
191
+ }
192
+ } catch {
193
+ const write = (...args) => process.stderr.write(
194
+ args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ") + "\n"
195
+ );
196
+ const target = console;
197
+ for (const method of GUARDED_CONSOLE_METHODS) {
198
+ target[method] = write;
199
+ }
200
+ }
201
+ installed = true;
202
+ return true;
203
+ }
204
+ function safeStringify(value) {
205
+ try {
206
+ return JSON.stringify(value) ?? String(value);
207
+ } catch {
208
+ return String(value);
209
+ }
210
+ }
211
+
146
212
  // src/server.ts
213
+ installStdoutGuard();
147
214
  var NO_ARG_INPUT = {};
215
+ var DEFAULT_API_TIMEOUT_MS = 15e3;
216
+ var MIN_API_TIMEOUT_MS = 250;
217
+ var MAX_API_TIMEOUT_MS = 3e5;
218
+ function resolveTimeoutMsFromEnv() {
219
+ const raw = process.env?.MUSHI_MCP_TIMEOUT_MS?.trim();
220
+ if (!raw) return DEFAULT_API_TIMEOUT_MS;
221
+ const parsed = Number(raw);
222
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_TIMEOUT_MS;
223
+ return Math.min(Math.max(parsed, MIN_API_TIMEOUT_MS), MAX_API_TIMEOUT_MS);
224
+ }
225
+ function bareMessage(err) {
226
+ const raw = err instanceof Error ? err.message : String(err);
227
+ return raw.replace(/^\[[A-Z0-9_]+\]\s*/, "");
228
+ }
229
+ var IDEMPOTENCY_NAMESPACE = "mushi.mcp.submit_fix_result.v1";
230
+ async function stableUuidFrom(...parts) {
231
+ const input = parts.join("");
232
+ const digest = new Uint8Array(
233
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input))
234
+ );
235
+ const bytes = digest.slice(0, 16);
236
+ bytes[6] = bytes[6] & 15 | 80;
237
+ bytes[8] = bytes[8] & 63 | 128;
238
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
239
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
240
+ }
241
+ function withTimeoutSignal(callerSignal, timeoutSignal) {
242
+ if (!callerSignal) return timeoutSignal;
243
+ const anyFn = AbortSignal.any;
244
+ if (typeof anyFn === "function") return anyFn.call(AbortSignal, [callerSignal, timeoutSignal]);
245
+ return timeoutSignal;
246
+ }
148
247
  var MushiApiError = class extends Error {
149
248
  constructor(status, code, message) {
150
249
  super(`[${code}] ${message}`);
@@ -158,6 +257,7 @@ var MushiApiError = class extends Error {
158
257
  function createMushiServer(config) {
159
258
  const { version, apiEndpoint, apiKey, projectId } = config;
160
259
  const doFetch = config.fetch ?? globalThis.fetch;
260
+ const timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : resolveTimeoutMsFromEnv();
161
261
  const apiLog = createLogger({
162
262
  scope: "mushi:mcp:api",
163
263
  level: process.env.MUSHI_LOG_LEVEL ?? "info",
@@ -175,22 +275,66 @@ function createMushiServer(config) {
175
275
  async function apiCall(path, options) {
176
276
  const requestId = crypto.randomUUID().slice(0, 12);
177
277
  const started = Date.now();
178
- const res = await doFetch(`${apiEndpoint}${path}`, {
179
- ...options,
180
- headers: {
181
- "Content-Type": "application/json",
182
- // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
183
- // still send it for endpoints still behind plain `jwtAuth` (legacy)
184
- // and for transparent proxies that strip X-Mushi-* headers.
185
- "Authorization": `Bearer ${apiKey}`,
186
- "X-Mushi-Api-Key": apiKey,
187
- "X-Request-Id": requestId,
188
- ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
189
- ...options?.headers ?? {}
278
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
279
+ let res;
280
+ try {
281
+ res = await doFetch(`${apiEndpoint}${path}`, {
282
+ ...options,
283
+ headers: {
284
+ "Content-Type": "application/json",
285
+ // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
286
+ // still send it for endpoints still behind plain `jwtAuth` (legacy)
287
+ // and for transparent proxies that strip X-Mushi-* headers.
288
+ Authorization: `Bearer ${apiKey}`,
289
+ "X-Mushi-Api-Key": apiKey,
290
+ "X-Request-Id": requestId,
291
+ ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
292
+ ...options?.headers ?? {}
293
+ },
294
+ // After the spread so a caller-supplied `signal` is merged, not lost.
295
+ signal: withTimeoutSignal(options?.signal, timeoutSignal)
296
+ });
297
+ } catch (err) {
298
+ throw classifyTransportError(err);
299
+ }
300
+ let text;
301
+ try {
302
+ text = await res.text();
303
+ } catch (err) {
304
+ throw classifyTransportError(err);
305
+ }
306
+ function classifyTransportError(err) {
307
+ const durationMs2 = Date.now() - started;
308
+ if (timeoutSignal.aborted) {
309
+ apiLog.warn("api.timeout", { path, requestId, durationMs: durationMs2, timeoutMs });
310
+ return new MushiApiError(
311
+ 504,
312
+ "MUSHI_TIMEOUT",
313
+ `Timed out after ${timeoutMs}ms waiting for ${path}. The Mushi API accepted the connection but did not answer in time \u2014 retry, or raise the budget with MUSHI_MCP_TIMEOUT_MS (ms) in your MCP env block if you self-host and cold starts are slow. Endpoint: ${apiEndpoint}`
314
+ );
190
315
  }
191
- });
316
+ const name = err instanceof Error ? err.name : "";
317
+ if (name === "AbortError") {
318
+ apiLog.warn("api.aborted", { path, requestId, durationMs: durationMs2 });
319
+ return new MushiApiError(
320
+ 499,
321
+ "REQUEST_ABORTED",
322
+ `The request to ${path} was aborted by the caller after ${durationMs2}ms.`
323
+ );
324
+ }
325
+ apiLog.warn("api.unreachable", {
326
+ path,
327
+ requestId,
328
+ durationMs: durationMs2,
329
+ err: bareMessage(err)
330
+ });
331
+ return new MushiApiError(
332
+ 503,
333
+ "NETWORK_ERROR",
334
+ `Could not reach the Mushi API at ${apiEndpoint}${path} \u2014 ${bareMessage(err)}. This is a connectivity failure, not an API rejection: check that you are online, that MUSHI_API_ENDPOINT points at your \`.../functions/v1/api\` URL, and that no proxy/VPN is blocking it.`
335
+ );
336
+ }
192
337
  const durationMs = Date.now() - started;
193
- const text = await res.text();
194
338
  let body = null;
195
339
  if (text) {
196
340
  try {
@@ -220,6 +364,7 @@ function createMushiServer(config) {
220
364
  apiLog.info("api.done", { path, requestId, status: res.status, durationMs });
221
365
  return body;
222
366
  }
367
+ let resolvedAccountProjectId = null;
223
368
  async function resolveProjectId(explicitId) {
224
369
  if (explicitId) {
225
370
  if (projectId && explicitId !== projectId) {
@@ -232,12 +377,12 @@ function createMushiServer(config) {
232
377
  return explicitId;
233
378
  }
234
379
  if (projectId) return projectId;
380
+ if (resolvedAccountProjectId) return resolvedAccountProjectId;
235
381
  try {
236
- const data = await apiCall(
237
- "/v1/admin/mcp/account-overview"
238
- );
382
+ const data = await apiCall("/v1/admin/mcp/account-overview");
239
383
  if (data.total === 1 && data.projects[0]) {
240
- return data.projects[0].id;
384
+ resolvedAccountProjectId = data.projects[0].id;
385
+ return resolvedAccountProjectId;
241
386
  }
242
387
  if (data.total === 0) {
243
388
  throw new MushiApiError(
@@ -253,11 +398,27 @@ function createMushiServer(config) {
253
398
  `Multiple projects are accessible with this key: ${projectList}. Pass projectId explicitly to target a specific project, or set MUSHI_PROJECT_ID in your MCP env config.`
254
399
  );
255
400
  } catch (err) {
256
- if (err instanceof MushiApiError) throw err;
401
+ if (err instanceof MushiApiError) {
402
+ if (err.code === "MUSHI_TIMEOUT" || err.code === "NETWORK_ERROR") {
403
+ throw new MushiApiError(
404
+ err.status,
405
+ err.code,
406
+ `Could not resolve which project to use because the Mushi API is unreachable: ${bareMessage(err)} This is a connectivity problem \u2014 setting MUSHI_PROJECT_ID would only skip the lookup, not fix the tool call that follows it.`
407
+ );
408
+ }
409
+ if (err.status === 401 || err.status === 403) {
410
+ throw new MushiApiError(
411
+ err.status,
412
+ err.code,
413
+ `The API key was rejected while resolving the project: ${bareMessage(err)} Mint a fresh key in the console (Settings \u2192 API keys) and update MUSHI_API_KEY, or re-run \`mushi login\`. This is an authentication problem, not a missing project id.`
414
+ );
415
+ }
416
+ throw err;
417
+ }
257
418
  throw new MushiApiError(
258
419
  400,
259
420
  "MISSING_PROJECT_ID",
260
- "projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly."
421
+ `projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly. (Project lookup failed with: ${bareMessage(err)})`
261
422
  );
262
423
  }
263
424
  }
@@ -346,9 +507,12 @@ function createMushiServer(config) {
346
507
  params.set("limit", String(Math.min(args.limit ?? 20, 100)));
347
508
  const pid = await resolveProjectId(args.project_id);
348
509
  const extraHeaders = pid !== projectId ? { "X-Mushi-Project-Id": pid } : {};
349
- const data = await apiCall(`/v1/admin/reports?${params}`, {
350
- headers: extraHeaders
351
- });
510
+ const data = await apiCall(
511
+ `/v1/admin/reports?${params}`,
512
+ {
513
+ headers: extraHeaders
514
+ }
515
+ );
352
516
  return jsonResult(data);
353
517
  }
354
518
  );
@@ -371,7 +535,10 @@ function createMushiServer(config) {
371
535
  return {
372
536
  ...res,
373
537
  resource_links: [
374
- { uri: `project://reports/${args.reportId}`, title: `Report ${args.reportId.slice(0, 8)}\u2026` }
538
+ {
539
+ uri: `project://reports/${args.reportId}`,
540
+ title: `Report ${args.reportId.slice(0, 8)}\u2026`
541
+ }
375
542
  ]
376
543
  };
377
544
  }
@@ -475,17 +642,20 @@ function createMushiServer(config) {
475
642
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`, {
476
643
  headers
477
644
  });
478
- return wrappedJsonResult({
479
- report,
480
- // Paste-ready fix prompt composed server-side by composeFixPacket()
481
- // (diagnosis + repro + suggested fix + relevant code + blast radius).
482
- // Hand this straight to the editing agent — no second LLM key needed.
483
- fixPrompt: report.fix_packet ?? null,
484
- reproductionSteps: report.reproduction_steps ?? [],
485
- component: report.component,
486
- rootCause: report.stage2_analysis?.rootCause,
487
- bugOntologyTags: report.bug_ontology_tags
488
- }, "fix context");
645
+ return wrappedJsonResult(
646
+ {
647
+ report,
648
+ // Paste-ready fix prompt composed server-side by composeFixPacket()
649
+ // (diagnosis + repro + suggested fix + relevant code + blast radius).
650
+ // Hand this straight to the editing agent — no second LLM key needed.
651
+ fixPrompt: report.fix_packet ?? null,
652
+ reproductionSteps: report.reproduction_steps ?? [],
653
+ component: report.component,
654
+ rootCause: report.stage2_analysis?.rootCause,
655
+ bugOntologyTags: report.bug_ontology_tags
656
+ },
657
+ "fix context"
658
+ );
489
659
  }
490
660
  );
491
661
  server.registerTool(
@@ -534,7 +704,9 @@ function createMushiServer(config) {
534
704
  seed: args.seed,
535
705
  depth: String(Math.min(args.depth ?? 2, 4))
536
706
  });
537
- const data = await apiCall(`/v1/admin/graph/traverse?${params}`);
707
+ const data = await apiCall(
708
+ `/v1/admin/graph/traverse?${params}`
709
+ );
538
710
  return jsonResult(data);
539
711
  }
540
712
  );
@@ -545,7 +717,9 @@ function createMushiServer(config) {
545
717
  description: descOf("get_graph_neighborhood"),
546
718
  annotations: annotationsFor("get_graph_neighborhood"),
547
719
  inputSchema: {
548
- seed: z.string().describe("Starting graph node id or label to expand around (e.g. an inventory Action id or component name)"),
720
+ seed: z.string().describe(
721
+ "Starting graph node id or label to expand around (e.g. an inventory Action id or component name)"
722
+ ),
549
723
  depth: z.number().int().min(1).max(4).optional().describe("BFS hops to traverse outward. Default 2; clamped to a max of 4.")
550
724
  },
551
725
  outputSchema: {
@@ -558,7 +732,9 @@ function createMushiServer(config) {
558
732
  seed: args.seed,
559
733
  depth: String(Math.min(args.depth ?? 2, 4))
560
734
  });
561
- const data = await apiCall(`/v1/admin/graph/traverse?${params}`);
735
+ const data = await apiCall(
736
+ `/v1/admin/graph/traverse?${params}`
737
+ );
562
738
  return jsonResult(data);
563
739
  }
564
740
  );
@@ -568,7 +744,9 @@ function createMushiServer(config) {
568
744
  title: titleOf("get_graph_node"),
569
745
  description: descOf("get_graph_node"),
570
746
  annotations: annotationsFor("get_graph_node"),
571
- inputSchema: { nodeId: z.string().describe("The graph_nodes.id of the node to fetch (UUID).") },
747
+ inputSchema: {
748
+ nodeId: z.string().describe("The graph_nodes.id of the node to fetch (UUID).")
749
+ },
572
750
  outputSchema: {
573
751
  node: z.unknown().describe("Single graph_nodes row including metadata (Action nodes carry v2 status)")
574
752
  }
@@ -590,7 +768,8 @@ function createMushiServer(config) {
590
768
  },
591
769
  async (args) => {
592
770
  const pid = args.projectId ?? projectId;
593
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for get_inventory");
771
+ if (!pid)
772
+ throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for get_inventory");
594
773
  return jsonText(await apiCall(`/v1/admin/inventory/${pid}`));
595
774
  }
596
775
  );
@@ -608,7 +787,8 @@ function createMushiServer(config) {
608
787
  },
609
788
  async (args) => {
610
789
  const pid = args.projectId ?? projectId;
611
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for diff_inventory");
790
+ if (!pid)
791
+ throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for diff_inventory");
612
792
  const q = new URLSearchParams({ from: args.fromSha, to: args.toSha });
613
793
  return jsonText(await apiCall(`/v1/admin/inventory/${pid}/diff?${q}`));
614
794
  }
@@ -631,12 +811,19 @@ function createMushiServer(config) {
631
811
  },
632
812
  async (args) => {
633
813
  const pid = args.projectId ?? projectId;
634
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for list_gate_findings");
814
+ if (!pid)
815
+ throw new MushiApiError(
816
+ 400,
817
+ "MISSING_PROJECT",
818
+ "projectId is required for list_gate_findings"
819
+ );
635
820
  const q = new URLSearchParams();
636
821
  if (args.gate) q.set("gate", args.gate);
637
822
  if (args.severity) q.set("severity", args.severity);
638
823
  const suffix = q.toString() ? `?${q}` : "";
639
- const data = await apiCall(`/v1/admin/inventory/${pid}/findings${suffix}`);
824
+ const data = await apiCall(
825
+ `/v1/admin/inventory/${pid}/findings${suffix}`
826
+ );
640
827
  return jsonResult(data);
641
828
  }
642
829
  );
@@ -646,19 +833,33 @@ function createMushiServer(config) {
646
833
  title: titleOf("suggest_fix"),
647
834
  description: descOf("suggest_fix"),
648
835
  annotations: annotationsFor("suggest_fix"),
649
- inputSchema: { reportId: z.string().describe("Report UUID to read the Stage-2 suggested-fix slice for") },
836
+ inputSchema: {
837
+ reportId: z.string().describe("Report UUID to read the Stage-2 suggested-fix slice for")
838
+ },
650
839
  outputSchema: {
651
840
  reportId: z.string().describe("The report this slice was read from"),
652
841
  rootCause: z.unknown().describe("Stage-2 root-cause hint, or null if not yet classified"),
653
842
  suggestedFix: z.unknown().describe("Stage-2 suggested fix, or null if not yet classified"),
654
843
  reproductionSteps: z.unknown().describe("Reproduction steps recorded on the report (array; [] if none)"),
655
844
  summary: z.unknown().describe("One-line report summary, or null"),
656
- component: z.unknown().describe("Component/page the bug was attributed to, or null")
845
+ component: z.unknown().describe("Component/page the bug was attributed to, or null"),
846
+ note: z.string().optional().describe("Present when no Stage-2 analysis exists \u2014 explains why and what to call instead")
657
847
  }
658
848
  },
659
849
  async (args) => {
660
850
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`);
661
851
  const s2 = report.stage2_analysis;
852
+ if (!s2 || s2.rootCause == null && s2.suggestedFix == null) {
853
+ return jsonResult({
854
+ reportId: args.reportId,
855
+ rootCause: null,
856
+ suggestedFix: null,
857
+ reproductionSteps: report.reproduction_steps ?? [],
858
+ summary: report.summary ?? null,
859
+ component: report.component ?? null,
860
+ note: "No Stage-2 analysis exists for this report (it was classified by the fast Stage-1 path, which does not produce root-cause/fix hints). Use triage_issue for the full review packet, or dispatch_fix \u2014 the fix worker does its own grounded analysis."
861
+ });
862
+ }
662
863
  return jsonResult({
663
864
  reportId: args.reportId,
664
865
  rootCause: s2?.rootCause ?? null,
@@ -675,7 +876,11 @@ function createMushiServer(config) {
675
876
  title: titleOf("run_nl_query"),
676
877
  description: descOf("run_nl_query"),
677
878
  annotations: annotationsFor("run_nl_query"),
678
- inputSchema: { question: z.string().describe('Question in plain English, e.g. "Which components had the most critical bugs this week?"') }
879
+ inputSchema: {
880
+ question: z.string().describe(
881
+ 'Question in plain English, e.g. "Which components had the most critical bugs this week?"'
882
+ )
883
+ }
679
884
  },
680
885
  async (args) => {
681
886
  const data = await apiCall("/v1/admin/query", {
@@ -692,7 +897,9 @@ function createMushiServer(config) {
692
897
  description: descOf("diagnose_setup"),
693
898
  annotations: annotationsFor("diagnose_setup"),
694
899
  inputSchema: {
695
- mode: z.enum(["full", "ingest", "dispatch"]).optional().describe("full (default) = ingest + dispatch; ingest = SDK pipeline only; dispatch = fix preflight only."),
900
+ mode: z.enum(["full", "ingest", "dispatch"]).optional().describe(
901
+ "full (default) = ingest + dispatch; ingest = SDK pipeline only; dispatch = fix preflight only."
902
+ ),
696
903
  project_id: z.string().optional().describe("Project UUID for dispatch checks (defaults to configured project)."),
697
904
  projectId: z.string().optional().describe("Alias for project_id.")
698
905
  },
@@ -730,7 +937,11 @@ function createMushiServer(config) {
730
937
  }
731
938
  if (mode === "dispatch") {
732
939
  if (!resolvedId) {
733
- throw new MushiApiError(400, "MISSING_PROJECT", "project_id is required for dispatch mode");
940
+ throw new MushiApiError(
941
+ 400,
942
+ "MISSING_PROJECT",
943
+ "project_id is required for dispatch mode"
944
+ );
734
945
  }
735
946
  const data = await apiCall(`/v1/admin/projects/${resolvedId}/preflight`);
736
947
  const failed = data.checks.filter((c) => !c.ready);
@@ -864,7 +1075,12 @@ function createMushiServer(config) {
864
1075
  async (args) => {
865
1076
  const query = args.query ?? "";
866
1077
  const hits = searchMushiDocs(query, args.limit ?? 8);
867
- const results = hits.map(({ title, path, excerpt, score }) => ({ title, path, excerpt, score }));
1078
+ const results = hits.map(({ title, path, excerpt, score }) => ({
1079
+ title,
1080
+ path,
1081
+ excerpt,
1082
+ score
1083
+ }));
868
1084
  return jsonResult({ query, results });
869
1085
  }
870
1086
  );
@@ -1014,7 +1230,9 @@ function createMushiServer(config) {
1014
1230
  } : { error: String(reportRes.reason) };
1015
1231
  return jsonText({
1016
1232
  evidence,
1017
- reporter_thread: timeline ?? { error: String(timelineRes.reason) }
1233
+ reporter_thread: timeline ?? {
1234
+ error: String(timelineRes.reason)
1235
+ }
1018
1236
  });
1019
1237
  }
1020
1238
  );
@@ -1049,27 +1267,54 @@ function createMushiServer(config) {
1049
1267
  const includeLogs = args.include_logs !== false;
1050
1268
  const [reportRes, evidenceRes, similarRes, fixCtxRes, blastRes, logsRes] = await Promise.allSettled([
1051
1269
  apiCall(`/v1/admin/reports/${args.report_id}`, { headers }),
1052
- apiCall(`/v1/admin/reports/${args.report_id}/timeline`, { headers }),
1270
+ apiCall(`/v1/admin/reports/${args.report_id}/timeline`, {
1271
+ headers
1272
+ }),
1053
1273
  apiCall(`/v1/admin/reports/similarity`, {
1054
1274
  method: "POST",
1055
1275
  headers,
1056
1276
  body: JSON.stringify({ report_id: args.report_id })
1057
1277
  }).catch(() => null),
1058
- apiCall(`/v1/admin/reports/${args.report_id}/fix-context`, { headers }).catch(() => null),
1059
- apiCall(`/v1/admin/reports/${args.report_id}/blast-radius`, { headers }).catch(() => null),
1060
- includeLogs ? apiCall(`/v1/admin/mcp/logs/${pid}?limit=20&level=warn`, { headers }).catch(() => null) : Promise.resolve(null)
1278
+ apiCall(`/v1/admin/reports/${args.report_id}/fix-context`, { headers }).catch(
1279
+ () => null
1280
+ ),
1281
+ apiCall(`/v1/admin/reports/${args.report_id}/blast-radius`, { headers }).catch(
1282
+ () => null
1283
+ ),
1284
+ includeLogs ? apiCall(`/v1/admin/mcp/logs/${pid}?limit=20&level=warn`, { headers }).catch(
1285
+ () => null
1286
+ ) : Promise.resolve(null)
1061
1287
  ]);
1062
1288
  const report = reportRes.status === "fulfilled" ? reportRes.value : null;
1063
1289
  const severity = report ? report.severity : "unknown";
1064
1290
  const category = report ? report.category : "unknown";
1065
1291
  const status = report ? report.status : "unknown";
1066
1292
  const actions = [];
1067
- if (status === "new" || status === "classified") {
1293
+ const processingError = report ? report.processing_error : null;
1294
+ const attempts = report ? report.fix_attempts ?? [] : [];
1295
+ const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
1296
+ const lastAttemptStatus = lastAttempt ? String(lastAttempt.status ?? "") : "";
1297
+ const autofixBlocked = typeof processingError === "string" && processingError.startsWith("autofix_blocked:") || lastAttemptStatus.startsWith("skipped") || lastAttemptStatus === "failed";
1298
+ if (autofixBlocked && status !== "fixing" && status !== "fixed") {
1299
+ const blockReason = typeof processingError === "string" && processingError || String(lastAttempt?.error ?? "previous fix attempt was skipped or failed");
1300
+ actions.push({
1301
+ action: "unblock_autofix",
1302
+ reason: `Auto-fix is blocked: ${blockReason}. Resolve the blocker (settings/integration/budget), then re-dispatch.`,
1303
+ tool: "diagnose_setup",
1304
+ args: { mode: "dispatch" }
1305
+ });
1306
+ actions.push({
1307
+ action: "redispatch_after_unblock",
1308
+ reason: "Once the blocker above is resolved, dispatch a fresh fix attempt.",
1309
+ tool: "dispatch_fix",
1310
+ args: { reportId: args.report_id }
1311
+ });
1312
+ } else if (status === "new" || status === "classified") {
1068
1313
  actions.push({
1069
1314
  action: "dispatch_fix",
1070
- reason: "Report is classified but no fix has been attempted",
1315
+ reason: "Report is classified and no fix has been attempted",
1071
1316
  tool: "dispatch_fix",
1072
- args: { reportId: args.report_id, agent: "cursor_cloud" }
1317
+ args: { reportId: args.report_id }
1073
1318
  });
1074
1319
  } else if (status === "fixing") {
1075
1320
  actions.push({
@@ -1124,7 +1369,10 @@ function createMushiServer(config) {
1124
1369
  return {
1125
1370
  ...res,
1126
1371
  resource_links: [
1127
- { uri: `project://reports/${args.report_id}`, title: `Report ${args.report_id.slice(0, 8)}\u2026` },
1372
+ {
1373
+ uri: `project://reports/${args.report_id}`,
1374
+ title: `Report ${args.report_id.slice(0, 8)}\u2026`
1375
+ },
1128
1376
  ...pid ? [{ uri: `project://dashboard`, title: "Project dashboard" }] : []
1129
1377
  ]
1130
1378
  };
@@ -1143,7 +1391,9 @@ function createMushiServer(config) {
1143
1391
  filesChanged: z.array(z.string()).describe("Repo-relative paths modified by the fix"),
1144
1392
  linesChanged: z.number().describe("Total lines added + removed across filesChanged"),
1145
1393
  summary: z.string().describe("One-line human summary of what the fix changed"),
1146
- idempotencyKey: z.string().uuid().optional().describe("Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows")
1394
+ idempotencyKey: z.string().uuid().optional().describe(
1395
+ "Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows. Omit it and a stable key is derived from reportId + branch + prUrl, so a retried submission carries the identical key instead of a fresh random one."
1396
+ )
1147
1397
  },
1148
1398
  outputSchema: {
1149
1399
  ok: z.boolean().describe("True when the fix_attempt row was created and marked completed"),
@@ -1151,27 +1401,123 @@ function createMushiServer(config) {
1151
1401
  }
1152
1402
  },
1153
1403
  async (args) => {
1154
- const idemKey = args.idempotencyKey ?? crypto.randomUUID();
1404
+ const idemKey = args.idempotencyKey ?? await stableUuidFrom(
1405
+ IDEMPOTENCY_NAMESPACE,
1406
+ args.reportId,
1407
+ args.branch,
1408
+ args.prUrl ?? ""
1409
+ );
1155
1410
  const created = await apiCall("/v1/admin/fixes", {
1156
1411
  method: "POST",
1157
1412
  headers: { "Idempotency-Key": idemKey },
1158
1413
  body: JSON.stringify({ reportId: args.reportId, agent: "mcp" })
1159
1414
  });
1160
- await apiCall(`/v1/admin/fixes/${created.fixId}`, {
1161
- method: "PATCH",
1162
- body: JSON.stringify({
1163
- status: "completed",
1164
- branch: args.branch,
1165
- pr_url: args.prUrl,
1166
- files_changed: args.filesChanged,
1167
- lines_changed: args.linesChanged,
1168
- summary: args.summary,
1169
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
1170
- })
1171
- });
1415
+ try {
1416
+ await apiCall(`/v1/admin/fixes/${created.fixId}`, {
1417
+ method: "PATCH",
1418
+ body: JSON.stringify({
1419
+ status: "completed",
1420
+ branch: args.branch,
1421
+ pr_url: args.prUrl,
1422
+ files_changed: args.filesChanged,
1423
+ lines_changed: args.linesChanged,
1424
+ summary: args.summary,
1425
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1426
+ })
1427
+ });
1428
+ } catch (err) {
1429
+ const status = err instanceof MushiApiError ? err.status : 500;
1430
+ throw new MushiApiError(
1431
+ status,
1432
+ "FIX_PATCH_FAILED",
1433
+ `Fix row ${created.fixId} was created for report ${args.reportId}, but marking it completed failed: ${bareMessage(err)} \u2014 the fix row exists and is still in its initial (pending) state, so no work is lost. Preferred repair: PATCH /v1/admin/fixes/${created.fixId} with {"status":"completed"} to finish this exact row. Re-running submit_fix_result re-sends the same derived Idempotency-Key (reportId + branch + prUrl) rather than a fresh one, so it dedupes wherever the API honours that header \u2014 retry only with identical arguments.`
1434
+ );
1435
+ }
1172
1436
  return jsonResult({ ok: true, fixId: created.fixId });
1173
1437
  }
1174
1438
  );
1439
+ server.registerTool(
1440
+ "triage_next_steps",
1441
+ {
1442
+ title: titleOf("triage_next_steps"),
1443
+ description: descOf("triage_next_steps"),
1444
+ annotations: annotationsFor("triage_next_steps"),
1445
+ inputSchema: {
1446
+ project_id: z.string().uuid().optional().describe("Project UUID (defaults to the configured project).")
1447
+ },
1448
+ outputSchema: {
1449
+ steps: z.array(
1450
+ z.object({
1451
+ priority: z.number(),
1452
+ action: z.string(),
1453
+ reason: z.string(),
1454
+ tool: z.string().optional(),
1455
+ args: z.record(z.string(), z.unknown()).optional()
1456
+ })
1457
+ ),
1458
+ summary: z.string()
1459
+ }
1460
+ },
1461
+ async (args) => {
1462
+ const pid = await resolveProjectId(args.project_id);
1463
+ const extraHeaders = pid !== projectId ? { "X-Mushi-Project-Id": pid } : {};
1464
+ const data = await apiCall(
1465
+ "/v1/admin/reports?limit=100",
1466
+ { headers: extraHeaders }
1467
+ );
1468
+ const reports = data.reports ?? [];
1469
+ const isCron = (r) => typeof r.reporter_token_hash === "string" && r.reporter_token_hash.startsWith("cron:");
1470
+ const isBlocked = (r) => typeof r.processing_error === "string" && r.processing_error.startsWith("autofix_blocked:");
1471
+ const label = (r) => String(r.title ?? r.summary ?? r.description ?? "").slice(0, 90);
1472
+ const steps = [];
1473
+ let priority = 1;
1474
+ const blocked = reports.filter(isBlocked);
1475
+ if (blocked.length > 0) {
1476
+ steps.push({
1477
+ priority: priority++,
1478
+ action: `Unblock auto-fix (${blocked.length} report${blocked.length === 1 ? "" : "s"} blocked)`,
1479
+ reason: String(blocked[0].processing_error).slice(0, 200),
1480
+ tool: "diagnose_setup",
1481
+ args: { mode: "dispatch" }
1482
+ });
1483
+ }
1484
+ const fixing = reports.filter((r) => r.status === "fixing");
1485
+ for (const r of fixing.slice(0, 2)) {
1486
+ steps.push({
1487
+ priority: priority++,
1488
+ action: `Shepherd in-flight fix: ${label(r)}`,
1489
+ reason: "A fix branch/PR is open \u2014 check CI and merge when green.",
1490
+ tool: "get_report_timeline",
1491
+ args: { report_id: r.id }
1492
+ });
1493
+ }
1494
+ const openUserReports = reports.filter((r) => (r.status === "new" || r.status === "classified") && !isCron(r) && !isBlocked(r)).sort((a, b) => {
1495
+ const rank = { critical: 0, high: 1, medium: 2, low: 3 };
1496
+ return (rank[String(a.severity)] ?? 4) - (rank[String(b.severity)] ?? 4);
1497
+ });
1498
+ for (const r of openUserReports.slice(0, Math.max(0, 4 - steps.length))) {
1499
+ steps.push({
1500
+ priority: priority++,
1501
+ action: `Triage [${r.severity}] ${label(r)}`,
1502
+ reason: "User-felt report awaiting triage \u2014 review the packet, then dispatch or dismiss.",
1503
+ tool: "triage_issue",
1504
+ args: { report_id: r.id }
1505
+ });
1506
+ }
1507
+ const chores = reports.filter((r) => r.status === "classified" && isCron(r) && !isBlocked(r));
1508
+ if (chores.length > 0 && steps.length < 5) {
1509
+ steps.push({
1510
+ priority: priority++,
1511
+ action: `Batch ${chores.length} maintenance chore${chores.length === 1 ? "" : "s"} (dependency bumps)`,
1512
+ reason: "Robot-filed modernization reports \u2014 batch-dispatch or dismiss in one sitting; do not let them crowd out user bugs.",
1513
+ tool: "get_recent_reports",
1514
+ args: { status: "classified" }
1515
+ });
1516
+ }
1517
+ const summary = steps.length === 0 ? "Inbox is clear \u2014 no blocked fixes, no open user reports, no chores. Nothing needs your attention." : `${steps.length} prioritised step${steps.length === 1 ? "" : "s"}: ` + steps.map((s) => s.action).join(" \u2192 ");
1518
+ return jsonResult({ steps, summary });
1519
+ }
1520
+ );
1175
1521
  server.registerTool(
1176
1522
  "dispatch_fix",
1177
1523
  {
@@ -1181,8 +1527,12 @@ function createMushiServer(config) {
1181
1527
  inputSchema: {
1182
1528
  reportId: z.string().uuid().describe("Report UUID to fix"),
1183
1529
  agent: z.enum(["claude_code", "codex", "rest_worker", "mcp"]).optional().describe("Override the agent adapter"),
1184
- idempotencyKey: z.string().uuid().optional().describe("Optional RFC 4122 UUID. Resend the same key to safely retry without dispatching a duplicate fix job (Idempotency-Key IETF draft)."),
1185
- inventoryActionNodeId: z.string().uuid().optional().describe("Optional inventory Action node UUID for spec-traceability (\xA72.10). When provided, the fix-worker embeds the expected_outcome contract in the LLM prompt and runs validateAgainstSpec before opening the PR.")
1530
+ idempotencyKey: z.string().uuid().optional().describe(
1531
+ "Optional RFC 4122 UUID. Resend the same key to safely retry without dispatching a duplicate fix job (Idempotency-Key IETF draft)."
1532
+ ),
1533
+ inventoryActionNodeId: z.string().uuid().optional().describe(
1534
+ "Optional inventory Action node UUID for spec-traceability (\xA72.10). When provided, the fix-worker embeds the expected_outcome contract in the LLM prompt and runs validateAgainstSpec before opening the PR."
1535
+ )
1186
1536
  },
1187
1537
  outputSchema: {
1188
1538
  fixId: z.string(),
@@ -1204,7 +1554,7 @@ function createMushiServer(config) {
1204
1554
  } catch {
1205
1555
  }
1206
1556
  }
1207
- const data = await apiCall("/v1/admin/fixes/dispatch", {
1557
+ const dispatch = await apiCall("/v1/admin/fixes/dispatch", {
1208
1558
  method: "POST",
1209
1559
  headers: args.idempotencyKey ? { "Idempotency-Key": args.idempotencyKey } : void 0,
1210
1560
  body: JSON.stringify({
@@ -1214,7 +1564,12 @@ function createMushiServer(config) {
1214
1564
  ...projectId ? { projectId } : {}
1215
1565
  })
1216
1566
  });
1217
- return jsonResult(data);
1567
+ if (!dispatch?.dispatchId) {
1568
+ throw new Error(
1569
+ `Dispatch response missing dispatchId \u2014 got: ${JSON.stringify(dispatch).slice(0, 300)}`
1570
+ );
1571
+ }
1572
+ return jsonResult({ fixId: dispatch.dispatchId, status: dispatch.status ?? "queued" });
1218
1573
  }
1219
1574
  );
1220
1575
  server.registerTool(
@@ -1255,11 +1610,19 @@ function createMushiServer(config) {
1255
1610
  },
1256
1611
  async (args) => {
1257
1612
  const pid = args.projectId ?? projectId;
1258
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for test_gen_from_report");
1259
- const data = await apiCall(`/v1/admin/inventory/${pid}/test-gen/from-report/${args.reportId}`, {
1260
- method: "POST",
1261
- body: JSON.stringify({})
1262
- });
1613
+ if (!pid)
1614
+ throw new MushiApiError(
1615
+ 400,
1616
+ "MISSING_PROJECT",
1617
+ "projectId is required for test_gen_from_report"
1618
+ );
1619
+ const data = await apiCall(
1620
+ `/v1/admin/inventory/${pid}/test-gen/from-report/${args.reportId}`,
1621
+ {
1622
+ method: "POST",
1623
+ body: JSON.stringify({})
1624
+ }
1625
+ );
1263
1626
  return jsonText(data);
1264
1627
  }
1265
1628
  );
@@ -1338,7 +1701,22 @@ function createMushiServer(config) {
1338
1701
  annotations: annotationsFor("transition_status"),
1339
1702
  inputSchema: {
1340
1703
  reportId: z.string().describe("Report UUID"),
1341
- status: z.enum(["pending", "classified", "grouped", "fixing", "fixed", "resolved", "verified", "reopened", "dismissed"]).describe("Target status"),
1704
+ status: z.enum([
1705
+ "pending",
1706
+ "classified",
1707
+ // Workflow states added 20260520600000 but missing from this
1708
+ // enum ever since (2026-08-16 audit P2-2) — agents could not
1709
+ // mark a report as being looked at.
1710
+ "triaged",
1711
+ "in_progress",
1712
+ "grouped",
1713
+ "fixing",
1714
+ "fixed",
1715
+ "resolved",
1716
+ "verified",
1717
+ "reopened",
1718
+ "dismissed"
1719
+ ]).describe("Target status (resolved is stored as fixed)"),
1342
1720
  reason: z.string().optional().describe("Reason for the transition (audit trail)")
1343
1721
  }
1344
1722
  },
@@ -1362,7 +1740,12 @@ function createMushiServer(config) {
1362
1740
  },
1363
1741
  async (args) => {
1364
1742
  const pid = args.projectId ?? projectId;
1365
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for setup_repo_for_mushi");
1743
+ if (!pid)
1744
+ throw new MushiApiError(
1745
+ 400,
1746
+ "MISSING_PROJECT",
1747
+ "projectId is required for setup_repo_for_mushi"
1748
+ );
1366
1749
  const data = await apiCall(`/v1/admin/projects/${pid}/repo/bootstrap`, {
1367
1750
  method: "POST",
1368
1751
  body: JSON.stringify({})
@@ -1427,23 +1810,45 @@ function createMushiServer(config) {
1427
1810
  "project://stats",
1428
1811
  { description: "Report counts, category breakdown, severity distribution" },
1429
1812
  async () => ({
1430
- contents: [{ uri: "project://stats", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/stats"), null, 2) }]
1813
+ contents: [
1814
+ {
1815
+ uri: "project://stats",
1816
+ mimeType: "application/json",
1817
+ text: JSON.stringify(await apiCall("/v1/admin/stats"), null, 2)
1818
+ }
1819
+ ]
1431
1820
  })
1432
1821
  );
1433
1822
  server.resource(
1434
1823
  "project_settings",
1435
1824
  "project://settings",
1436
- { description: "Project configuration \u2014 autofix agent, plugins enabled, ontology, LLM budgets" },
1825
+ {
1826
+ description: "Project configuration \u2014 autofix agent, plugins enabled, ontology, LLM budgets"
1827
+ },
1437
1828
  async () => ({
1438
- contents: [{ uri: "project://settings", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/settings"), null, 2) }]
1829
+ contents: [
1830
+ {
1831
+ uri: "project://settings",
1832
+ mimeType: "application/json",
1833
+ text: JSON.stringify(await apiCall("/v1/admin/settings"), null, 2)
1834
+ }
1835
+ ]
1439
1836
  })
1440
1837
  );
1441
1838
  server.resource(
1442
1839
  "project_dashboard",
1443
1840
  "project://dashboard",
1444
- { description: "PDCA health snapshot \u2014 stage counts, bottleneck, recent activity (same payload the admin console polls)" },
1841
+ {
1842
+ description: "PDCA health snapshot \u2014 stage counts, bottleneck, recent activity (same payload the admin console polls)"
1843
+ },
1445
1844
  async () => ({
1446
- contents: [{ uri: "project://dashboard", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/dashboard"), null, 2) }]
1845
+ contents: [
1846
+ {
1847
+ uri: "project://dashboard",
1848
+ mimeType: "application/json",
1849
+ text: JSON.stringify(await apiCall("/v1/admin/dashboard"), null, 2)
1850
+ }
1851
+ ]
1447
1852
  })
1448
1853
  );
1449
1854
  server.registerTool(
@@ -1458,14 +1863,16 @@ function createMushiServer(config) {
1458
1863
  }
1459
1864
  },
1460
1865
  async ({ limit, range }) => ({
1461
- content: [{
1462
- type: "text",
1463
- text: JSON.stringify(
1464
- await apiCall(`/v1/admin/rewards/leaderboard?range=${range}&limit=${limit}`),
1465
- null,
1466
- 2
1467
- )
1468
- }]
1866
+ content: [
1867
+ {
1868
+ type: "text",
1869
+ text: JSON.stringify(
1870
+ await apiCall(`/v1/admin/rewards/leaderboard?range=${range}&limit=${limit}`),
1871
+ null,
1872
+ 2
1873
+ )
1874
+ }
1875
+ ]
1469
1876
  })
1470
1877
  );
1471
1878
  server.registerTool(
@@ -1481,18 +1888,20 @@ function createMushiServer(config) {
1481
1888
  }
1482
1889
  },
1483
1890
  async ({ external_user_id, points, reason }) => ({
1484
- content: [{
1485
- type: "text",
1486
- text: JSON.stringify(
1487
- await apiCall("/v1/admin/rewards/bonus-points", {
1488
- method: "POST",
1489
- headers: { "Content-Type": "application/json" },
1490
- body: JSON.stringify({ external_user_id, points, reason })
1491
- }),
1492
- null,
1493
- 2
1494
- )
1495
- }]
1891
+ content: [
1892
+ {
1893
+ type: "text",
1894
+ text: JSON.stringify(
1895
+ await apiCall("/v1/admin/rewards/bonus-points", {
1896
+ method: "POST",
1897
+ headers: { "Content-Type": "application/json" },
1898
+ body: JSON.stringify({ external_user_id, points, reason })
1899
+ }),
1900
+ null,
1901
+ 2
1902
+ )
1903
+ }
1904
+ ]
1496
1905
  })
1497
1906
  );
1498
1907
  server.registerTool(
@@ -1508,18 +1917,20 @@ function createMushiServer(config) {
1508
1917
  }
1509
1918
  },
1510
1919
  async ({ external_user_id, tier_slug, reason }) => ({
1511
- content: [{
1512
- type: "text",
1513
- text: JSON.stringify(
1514
- await apiCall("/v1/admin/rewards/set-tier", {
1515
- method: "POST",
1516
- headers: { "Content-Type": "application/json" },
1517
- body: JSON.stringify({ external_user_id, tier_slug, reason })
1518
- }),
1519
- null,
1520
- 2
1521
- )
1522
- }]
1920
+ content: [
1921
+ {
1922
+ type: "text",
1923
+ text: JSON.stringify(
1924
+ await apiCall("/v1/admin/rewards/set-tier", {
1925
+ method: "POST",
1926
+ headers: { "Content-Type": "application/json" },
1927
+ body: JSON.stringify({ external_user_id, tier_slug, reason })
1928
+ }),
1929
+ null,
1930
+ 2
1931
+ )
1932
+ }
1933
+ ]
1523
1934
  })
1524
1935
  );
1525
1936
  server.resource(
@@ -1529,11 +1940,13 @@ function createMushiServer(config) {
1529
1940
  description: "Returns the privacy posture for this project: storage region, LLM provider, whether BYOK is configured, data retention window, and last audit timestamp."
1530
1941
  },
1531
1942
  async () => ({
1532
- contents: [{
1533
- uri: "privacy://status",
1534
- mimeType: "application/json",
1535
- text: JSON.stringify(await apiCall("/v1/admin/privacy-status"), null, 2)
1536
- }]
1943
+ contents: [
1944
+ {
1945
+ uri: "privacy://status",
1946
+ mimeType: "application/json",
1947
+ text: JSON.stringify(await apiCall("/v1/admin/privacy-status"), null, 2)
1948
+ }
1949
+ ]
1537
1950
  })
1538
1951
  );
1539
1952
  server.resource(
@@ -1543,15 +1956,19 @@ function createMushiServer(config) {
1543
1956
  description: "Returns the project's last 30 days of judge scores, prompt promotions, fixed-bug count, and lesson inductions. Agents can read this to see whether the loop is converging (rising judge scores, falling recurrence) or stalling."
1544
1957
  },
1545
1958
  async () => ({
1546
- contents: [{
1547
- uri: "evolution://history",
1548
- mimeType: "application/json",
1549
- text: projectId ? JSON.stringify(
1550
- await apiCall(`/v1/admin/projects/${encodeURIComponent(projectId)}/evolution-history`),
1551
- null,
1552
- 2
1553
- ) : JSON.stringify({ ok: false, error: "project_id required" })
1554
- }]
1959
+ contents: [
1960
+ {
1961
+ uri: "evolution://history",
1962
+ mimeType: "application/json",
1963
+ text: projectId ? JSON.stringify(
1964
+ await apiCall(
1965
+ `/v1/admin/projects/${encodeURIComponent(projectId)}/evolution-history`
1966
+ ),
1967
+ null,
1968
+ 2
1969
+ ) : JSON.stringify({ ok: false, error: "project_id required" })
1970
+ }
1971
+ ]
1555
1972
  })
1556
1973
  );
1557
1974
  server.resource(
@@ -1561,11 +1978,13 @@ function createMushiServer(config) {
1561
1978
  description: "Live health status of every configured BYOK channel (Sentry, GitHub, LangFuse, PagerDuty, \u2026). Orchestrators should check this before dispatching a fix to fail-fast on broken channels rather than burning LLM budget and discovering the failure mid-run."
1562
1979
  },
1563
1980
  async () => ({
1564
- contents: [{
1565
- uri: "project://integration-health",
1566
- mimeType: "application/json",
1567
- text: JSON.stringify(await apiCall("/v1/admin/integrations/health"), null, 2)
1568
- }]
1981
+ contents: [
1982
+ {
1983
+ uri: "project://integration-health",
1984
+ mimeType: "application/json",
1985
+ text: JSON.stringify(await apiCall("/v1/admin/integrations/health"), null, 2)
1986
+ }
1987
+ ]
1569
1988
  })
1570
1989
  );
1571
1990
  server.resource(
@@ -1577,11 +1996,13 @@ function createMushiServer(config) {
1577
1996
  async () => {
1578
1997
  const path = projectId ? `/v1/admin/inventory/${projectId}` : "/v1/admin/inventory";
1579
1998
  return {
1580
- contents: [{
1581
- uri: "inventory://current",
1582
- mimeType: "application/json",
1583
- text: JSON.stringify(await apiCall(path), null, 2)
1584
- }]
1999
+ contents: [
2000
+ {
2001
+ uri: "inventory://current",
2002
+ mimeType: "application/json",
2003
+ text: JSON.stringify(await apiCall(path), null, 2)
2004
+ }
2005
+ ]
1585
2006
  };
1586
2007
  }
1587
2008
  );
@@ -1610,11 +2031,13 @@ function createMushiServer(config) {
1610
2031
  async () => {
1611
2032
  const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
1612
2033
  return {
1613
- contents: [{
1614
- uri: "mushi://activation",
1615
- mimeType: "application/json",
1616
- text: JSON.stringify(await apiCall(`/v1/admin/activation${qs}`), null, 2)
1617
- }]
2034
+ contents: [
2035
+ {
2036
+ uri: "mushi://activation",
2037
+ mimeType: "application/json",
2038
+ text: JSON.stringify(await apiCall(`/v1/admin/activation${qs}`), null, 2)
2039
+ }
2040
+ ]
1618
2041
  };
1619
2042
  }
1620
2043
  );
@@ -1623,11 +2046,12 @@ function createMushiServer(config) {
1623
2046
  "Turn a Mushi report into a one-line root cause, smallest file set, repro steps, and blast-radius warnings. Use before asking an agent to write the patch.",
1624
2047
  { reportId: z.string().describe("The report UUID to summarize") },
1625
2048
  ({ reportId }) => ({
1626
- messages: [{
1627
- role: "user",
1628
- content: {
1629
- type: "text",
1630
- text: `You are a senior engineer preparing a fix plan. Use the Mushi MCP tools to:
2049
+ messages: [
2050
+ {
2051
+ role: "user",
2052
+ content: {
2053
+ type: "text",
2054
+ text: `You are a senior engineer preparing a fix plan. Use the Mushi MCP tools to:
1631
2055
  1. Call get_fix_context for reportId "${reportId}".
1632
2056
  2. Call get_blast_radius if the report has a component node id.
1633
2057
  3. Call get_similar_bugs with the component or summary as the query.
@@ -1640,8 +2064,9 @@ Then produce a markdown fix plan with exactly these sections:
1640
2064
  - Confidence (low/medium/high) with a one-line justification
1641
2065
 
1642
2066
  Lead with the fix. Skip the preamble.`
2067
+ }
1643
2068
  }
1644
- }]
2069
+ ]
1645
2070
  })
1646
2071
  );
1647
2072
  server.prompt(
@@ -1649,11 +2074,12 @@ Lead with the fix. Skip the preamble.`
1649
2074
  "Turn raw Sonnet-as-Judge scores into ship / iterate / dismiss guidance. Use after a fix attempt has been judged.",
1650
2075
  { fixId: z.string().describe("The fix_attempt UUID to explain") },
1651
2076
  ({ fixId }) => ({
1652
- messages: [{
1653
- role: "user",
1654
- content: {
1655
- type: "text",
1656
- text: `You are a release engineer. Use the Mushi MCP tools:
2077
+ messages: [
2078
+ {
2079
+ role: "user",
2080
+ content: {
2081
+ type: "text",
2082
+ text: `You are a release engineer. Use the Mushi MCP tools:
1657
2083
  1. Call get_fix_timeline for fixId "${fixId}" to see the full PDCA journey.
1658
2084
 
1659
2085
  Then write a short verdict in this format:
@@ -1662,8 +2088,9 @@ Then write a short verdict in this format:
1662
2088
  - **If iterate:** bullet list of the smallest next patch
1663
2089
 
1664
2090
  No preamble, no score-by-score recap \u2014 the numbers are in the tool output.`
2091
+ }
1665
2092
  }
1666
- }]
2093
+ ]
1667
2094
  })
1668
2095
  );
1669
2096
  server.prompt(
@@ -1671,11 +2098,12 @@ No preamble, no score-by-score recap \u2014 the numbers are in the tool output.`
1671
2098
  'Answer "what should I focus on right now?" \u2014 five-item markdown list drawn from the dashboard + recent classified queue.',
1672
2099
  {},
1673
2100
  () => ({
1674
- messages: [{
1675
- role: "user",
1676
- content: {
1677
- type: "text",
1678
- text: `You are a tech lead looking at the Mushi cockpit. Use the MCP tools:
2101
+ messages: [
2102
+ {
2103
+ role: "user",
2104
+ content: {
2105
+ type: "text",
2106
+ text: `You are a tech lead looking at the Mushi cockpit. Use the MCP tools:
1679
2107
  1. Read the project://dashboard resource for the PDCA snapshot.
1680
2108
  2. Call get_recent_reports with status="classified", limit=10.
1681
2109
 
@@ -1683,8 +2111,9 @@ Then output exactly 5 bullets, in priority order, each formatted as:
1683
2111
  \`**Action** \u2014 why it matters \u2014 suggested tool call\`
1684
2112
 
1685
2113
  Prefer items that are bottlenecks or critical severity. Skip filler.`
2114
+ }
1686
2115
  }
1687
- }]
2116
+ ]
1688
2117
  })
1689
2118
  );
1690
2119
  server.prompt(
@@ -1692,11 +2121,12 @@ Prefer items that are bottlenecks or critical severity. Skip filler.`
1692
2121
  "Diagnose why Mushi setup is stuck and return the single next command or console step to unblock it.",
1693
2122
  {},
1694
2123
  () => ({
1695
- messages: [{
1696
- role: "user",
1697
- content: {
1698
- type: "text",
1699
- text: `You are a Mushi onboarding copilot. Use the MCP tools:
2124
+ messages: [
2125
+ {
2126
+ role: "user",
2127
+ content: {
2128
+ type: "text",
2129
+ text: `You are a Mushi onboarding copilot. Use the MCP tools:
1700
2130
  1. Read mushi://activation for unified setup posture.
1701
2131
  2. Read project://integration-health if GitHub or Sentry looks blocked.
1702
2132
  3. Call diagnose_setup if the resource is unavailable.
@@ -1708,8 +2138,9 @@ Then output:
1708
2138
  - **If still stuck:** one diagnostic command (e.g. mushi doctor --fix)
1709
2139
 
1710
2140
  Be specific. No generic advice.`
2141
+ }
1711
2142
  }
1712
- }]
2143
+ ]
1713
2144
  })
1714
2145
  );
1715
2146
  server.registerTool(
@@ -1730,7 +2161,15 @@ Be specific. No generic advice.`
1730
2161
  if (!projectId2) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required");
1731
2162
  const data = await apiCall(
1732
2163
  `/v1/admin/inventory/${projectId2}/map-from-live`,
1733
- { method: "POST", body: JSON.stringify({ base_url: baseUrl, max_pages: maxPages, provider, cursor_cloud_refine: cursorCloudRefine }) }
2164
+ {
2165
+ method: "POST",
2166
+ body: JSON.stringify({
2167
+ base_url: baseUrl,
2168
+ max_pages: maxPages,
2169
+ provider,
2170
+ cursor_cloud_refine: cursorCloudRefine
2171
+ })
2172
+ }
1734
2173
  );
1735
2174
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1736
2175
  }
@@ -1758,7 +2197,9 @@ Be specific. No generic advice.`
1758
2197
  annotations: annotationsFor("generate_tdd_from_story", TDD_TOOL_CATALOG),
1759
2198
  inputSchema: {
1760
2199
  projectId: z.string().describe("Project UUID that owns the inventory story"),
1761
- storyNodeId: z.string().describe("User story node id/slug from accepted inventory (from map_user_stories or inventory.yaml)"),
2200
+ storyNodeId: z.string().describe(
2201
+ "User story node id/slug from accepted inventory (from map_user_stories or inventory.yaml)"
2202
+ ),
1762
2203
  automationMode: z.enum(["auto", "review", "approve"]).optional().describe("Approval gate for the generated qa_story (default review \u2192 pending_review)"),
1763
2204
  baseUrl: z.string().url().optional().describe("Override the live app URL the Playwright test should target"),
1764
2205
  openPr: z.boolean().optional().describe("Open a draft GitHub PR with the generated spec (default true)")
@@ -1772,10 +2213,14 @@ Be specific. No generic advice.`
1772
2213
  },
1773
2214
  async ({ projectId: projectId2, storyNodeId, automationMode, baseUrl, openPr }) => {
1774
2215
  if (!projectId2) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required");
1775
- const data = await apiCall(
1776
- `/v1/admin/inventory/${projectId2}/stories/${storyNodeId}/generate-test`,
1777
- { method: "POST", body: JSON.stringify({ automation_mode: automationMode, base_url: baseUrl, open_pr: openPr }) }
1778
- );
2216
+ const data = await apiCall(`/v1/admin/inventory/${projectId2}/stories/${storyNodeId}/generate-test`, {
2217
+ method: "POST",
2218
+ body: JSON.stringify({
2219
+ automation_mode: automationMode,
2220
+ base_url: baseUrl,
2221
+ open_pr: openPr
2222
+ })
2223
+ });
1779
2224
  return jsonResult(data);
1780
2225
  }
1781
2226
  );
@@ -1790,10 +2235,10 @@ Be specific. No generic advice.`
1790
2235
  }
1791
2236
  },
1792
2237
  async ({ projectId: projectId2 }) => {
1793
- const data = await apiCall(
1794
- "/v1/admin/pdca/improve-qa-stories",
1795
- { method: "POST", body: JSON.stringify({ project_id: projectId2 }) }
1796
- );
2238
+ const data = await apiCall("/v1/admin/pdca/improve-qa-stories", {
2239
+ method: "POST",
2240
+ body: JSON.stringify({ project_id: projectId2 })
2241
+ });
1797
2242
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1798
2243
  }
1799
2244
  );
@@ -1827,7 +2272,9 @@ Be specific. No generic advice.`
1827
2272
  }
1828
2273
  },
1829
2274
  async ({ projectId: projectId2 }) => {
1830
- const data = await apiCall(`/v1/admin/byok/keys?project_id=${encodeURIComponent(projectId2)}`);
2275
+ const data = await apiCall(
2276
+ `/v1/admin/byok/keys?project_id=${encodeURIComponent(projectId2)}`
2277
+ );
1831
2278
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1832
2279
  }
1833
2280
  );
@@ -1840,16 +2287,60 @@ Be specific. No generic advice.`
1840
2287
  inputSchema: {
1841
2288
  projectId: z.string().describe("Project id"),
1842
2289
  provider: z.enum(["anthropic", "openai", "firecrawl", "browserbase", "cursor"]).describe("Provider slug"),
1843
- key: z.string().min(10).describe("The API key value to add"),
2290
+ key: z.string().min(8).max(4096).describe("The API key value to add"),
1844
2291
  label: z.string().optional().describe("Human-readable label for this key"),
1845
- priority: z.number().int().min(1).max(999).optional().describe("Priority for ordering (lower = higher priority)")
2292
+ priority: z.number().int().min(0).max(1e4).optional().describe("Priority for ordering (lower = higher priority)"),
2293
+ baseUrl: z.string().url().max(2048).optional().describe("Allow-listed OpenAI-compatible HTTPS base URL")
1846
2294
  }
1847
2295
  },
1848
- async ({ projectId: projectId2, provider, key, label, priority }) => {
1849
- const data = await apiCall(
1850
- "/v1/admin/byok/keys",
1851
- { method: "POST", body: JSON.stringify({ project_id: projectId2, provider_slug: provider, key, label, priority }) }
1852
- );
2296
+ async ({ projectId: projectId2, provider, key, label, priority, baseUrl }) => {
2297
+ const data = await apiCall("/v1/admin/byok/keys", {
2298
+ method: "POST",
2299
+ body: JSON.stringify({
2300
+ project_id: projectId2,
2301
+ provider_slug: provider,
2302
+ key,
2303
+ label,
2304
+ priority,
2305
+ base_url: baseUrl
2306
+ })
2307
+ });
2308
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2309
+ }
2310
+ );
2311
+ server.registerTool(
2312
+ "test_byok_key",
2313
+ {
2314
+ title: titleOf("test_byok_key", TDD_TOOL_CATALOG),
2315
+ description: descOf("test_byok_key", TDD_TOOL_CATALOG),
2316
+ annotations: annotationsFor("test_byok_key", TDD_TOOL_CATALOG),
2317
+ inputSchema: {
2318
+ projectId: z.string().describe("Project id"),
2319
+ keyId: z.string().uuid().describe("Pooled BYOK key id")
2320
+ }
2321
+ },
2322
+ async ({ keyId }) => {
2323
+ const data = await apiCall(`/v1/admin/byok/keys/${encodeURIComponent(keyId)}/test`, {
2324
+ method: "POST"
2325
+ });
2326
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2327
+ }
2328
+ );
2329
+ server.registerTool(
2330
+ "remove_byok_key",
2331
+ {
2332
+ title: titleOf("remove_byok_key", TDD_TOOL_CATALOG),
2333
+ description: descOf("remove_byok_key", TDD_TOOL_CATALOG),
2334
+ annotations: annotationsFor("remove_byok_key", TDD_TOOL_CATALOG),
2335
+ inputSchema: {
2336
+ projectId: z.string().describe("Project id"),
2337
+ keyId: z.string().uuid().describe("Pooled BYOK key UUID from list_byok_keys")
2338
+ }
2339
+ },
2340
+ async ({ keyId }) => {
2341
+ const data = await apiCall(`/v1/admin/byok/keys/${encodeURIComponent(keyId)}`, {
2342
+ method: "DELETE"
2343
+ });
1853
2344
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1854
2345
  }
1855
2346
  );
@@ -1864,7 +2355,9 @@ Be specific. No generic advice.`
1864
2355
  }
1865
2356
  },
1866
2357
  async ({ projectId: projectId2 }) => {
1867
- const data = await apiCall(`/v1/admin/inventory/${projectId2}/stories/pending-review`);
2358
+ const data = await apiCall(
2359
+ `/v1/admin/inventory/${projectId2}/stories/pending-review`
2360
+ );
1868
2361
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1869
2362
  }
1870
2363
  );
@@ -1901,13 +2394,10 @@ Be specific. No generic advice.`
1901
2394
  }
1902
2395
  },
1903
2396
  async ({ reportId, message, authorName }) => {
1904
- const data = await apiCall(
1905
- `/v1/sync/reports/${reportId}/reply`,
1906
- {
1907
- method: "POST",
1908
- body: JSON.stringify({ message, author_name: authorName })
1909
- }
1910
- );
2397
+ const data = await apiCall(`/v1/sync/reports/${reportId}/reply`, {
2398
+ method: "POST",
2399
+ body: JSON.stringify({ message, author_name: authorName })
2400
+ });
1911
2401
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1912
2402
  }
1913
2403
  );
@@ -1986,10 +2476,10 @@ Be specific. No generic advice.`
1986
2476
  async ({ project_id }) => {
1987
2477
  const pid = project_id ?? config.projectId;
1988
2478
  if (!pid) throw new MushiApiError(400, "MISSING_PROJECT_ID", "project_id is required");
1989
- const data = await apiCall(
1990
- `/v1/admin/projects/${pid}/audit`,
1991
- { method: "POST", body: "{}" }
1992
- );
2479
+ const data = await apiCall(`/v1/admin/projects/${pid}/audit`, {
2480
+ method: "POST",
2481
+ body: "{}"
2482
+ });
1993
2483
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1994
2484
  }
1995
2485
  );
@@ -2085,17 +2575,19 @@ Be specific. No generic advice.`
2085
2575
  inputSchema: {
2086
2576
  root_skill_slug: z.string().describe('Root skill slug to run, e.g. "workflow-fix-and-ship"'),
2087
2577
  report_id: z.string().optional().describe("Report UUID to attach the pipeline to"),
2088
- mode: z.enum(["handoff", "cloud"]).optional().describe("handoff (default): get context packet for local agent. cloud: auto-dispatch via Cursor Cloud."),
2578
+ mode: z.enum(["handoff", "cloud"]).optional().describe(
2579
+ "handoff (default): get context packet for local agent. cloud: auto-dispatch via Cursor Cloud."
2580
+ ),
2089
2581
  project_id: z.string().optional().describe("Project UUID. Falls back to the configured project.")
2090
2582
  }
2091
2583
  },
2092
2584
  async (args) => {
2093
2585
  const resolvedProjectId = args.project_id ?? projectId;
2094
2586
  if (!resolvedProjectId) return jsonText({ error: "No project_id provided or configured." });
2095
- const data = await apiCall(
2096
- "/v1/admin/skills/pipelines",
2097
- { method: "POST", body: JSON.stringify({ ...args, project_id: resolvedProjectId }) }
2098
- );
2587
+ const data = await apiCall("/v1/admin/skills/pipelines", {
2588
+ method: "POST",
2589
+ body: JSON.stringify({ ...args, project_id: resolvedProjectId })
2590
+ });
2099
2591
  return jsonText(data);
2100
2592
  }
2101
2593
  );
@@ -2131,10 +2623,10 @@ Be specific. No generic advice.`
2131
2623
  },
2132
2624
  async (args) => {
2133
2625
  const { run_id, step_index, ...body } = args;
2134
- await apiCall(
2135
- `/v1/admin/skills/pipelines/${run_id}/steps/${step_index}/checkin`,
2136
- { method: "POST", body: JSON.stringify(body) }
2137
- );
2626
+ await apiCall(`/v1/admin/skills/pipelines/${run_id}/steps/${step_index}/checkin`, {
2627
+ method: "POST",
2628
+ body: JSON.stringify(body)
2629
+ });
2138
2630
  return jsonText({ ok: true, message: `Step ${step_index} \u2192 ${args.status}` });
2139
2631
  }
2140
2632
  );
@@ -2302,7 +2794,11 @@ Be specific. No generic advice.`
2302
2794
  if (args.compare) qs.set("compare", args.compare);
2303
2795
  if (args.fix_id) qs.set("fix_id", args.fix_id);
2304
2796
  if (!qs.toString()) {
2305
- throw new MushiApiError(400, "BAD_REQUEST", "Provide paths, source=last_push, compare, or fix_id");
2797
+ throw new MushiApiError(
2798
+ 400,
2799
+ "BAD_REQUEST",
2800
+ "Provide paths, source=last_push, compare, or fix_id"
2801
+ );
2306
2802
  }
2307
2803
  const data = await apiCall(`/v1/admin/projects/${pid}/codebase/impact?${qs}`);
2308
2804
  return jsonResult(data);
@@ -2354,7 +2850,7 @@ Be specific. No generic advice.`
2354
2850
  "### First step",
2355
2851
  `Call \`${cluster.tools[0]}\` to get started.`,
2356
2852
  "",
2357
- "> Tip: call any tool by name \u2014 `use_mushi` is a read-only helper that never calls other tools itself. All 68 tools remain available."
2853
+ "> Tip: call any tool by name \u2014 `use_mushi` is a read-only helper that never calls other tools itself. All 71 tools remain available."
2358
2854
  ].join("\n");
2359
2855
  return { content: [{ type: "text", text: orientation }] };
2360
2856
  }
@@ -2363,11 +2859,7 @@ Be specific. No generic advice.`
2363
2859
  const featureFilter = config.features ?? "all";
2364
2860
  if (grantedScopes !== void 0 || featureFilter !== "all") {
2365
2861
  const toolRegistry = server._registeredTools;
2366
- const allSpecs = [
2367
- ...TOOL_CATALOG,
2368
- ...TDD_TOOL_CATALOG,
2369
- ...CODEBASE_TOOL_CATALOG
2370
- ];
2862
+ const allSpecs = [...TOOL_CATALOG, ...TDD_TOOL_CATALOG, ...CODEBASE_TOOL_CATALOG];
2371
2863
  for (const spec of allSpecs) {
2372
2864
  if (grantedScopes !== void 0 && !grantedScopes.includes(spec.scope)) {
2373
2865
  toolRegistry[spec.name]?.remove();
@@ -2440,5 +2932,6 @@ Be specific. No generic advice.`
2440
2932
  }
2441
2933
  export {
2442
2934
  MushiApiError,
2443
- createMushiServer
2935
+ createMushiServer,
2936
+ resolveTimeoutMsFromEnv
2444
2937
  };