@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/index.js CHANGED
@@ -8,6 +8,72 @@ import { join } from "path";
8
8
  import { homedir } from "os";
9
9
  import { createLogger as createLogger2, DEFAULT_API_ENDPOINT } from "@mushi-mushi/core";
10
10
 
11
+ // src/stdout-guard.ts
12
+ import { Console } from "console";
13
+ var GUARDED_CONSOLE_METHODS = [
14
+ "log",
15
+ "info",
16
+ "debug",
17
+ "dir",
18
+ "dirxml",
19
+ "table",
20
+ "group",
21
+ "groupCollapsed",
22
+ "groupEnd",
23
+ "count",
24
+ "countReset",
25
+ "time",
26
+ "timeEnd",
27
+ "timeLog",
28
+ "trace",
29
+ "assert",
30
+ "warn",
31
+ "error"
32
+ ];
33
+ var installed = false;
34
+ function installStdoutGuard() {
35
+ if (installed) return false;
36
+ if (typeof process !== "undefined" && process.env?.MUSHI_MCP_STDOUT_GUARD === "off") {
37
+ return false;
38
+ }
39
+ if (typeof process === "undefined" || typeof process.stderr?.write !== "function" || typeof console === "undefined") {
40
+ return false;
41
+ }
42
+ try {
43
+ const stderrConsole = new Console({
44
+ stdout: process.stderr,
45
+ stderr: process.stderr,
46
+ // Colour codes would land in the client's log pane as escape noise.
47
+ colorMode: false
48
+ });
49
+ const target = console;
50
+ const source = stderrConsole;
51
+ for (const method of GUARDED_CONSOLE_METHODS) {
52
+ const fn = source[method];
53
+ if (typeof fn === "function") {
54
+ target[method] = fn.bind(stderrConsole);
55
+ }
56
+ }
57
+ } catch {
58
+ const write = (...args) => process.stderr.write(
59
+ args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ") + "\n"
60
+ );
61
+ const target = console;
62
+ for (const method of GUARDED_CONSOLE_METHODS) {
63
+ target[method] = write;
64
+ }
65
+ }
66
+ installed = true;
67
+ return true;
68
+ }
69
+ function safeStringify(value) {
70
+ try {
71
+ return JSON.stringify(value) ?? String(value);
72
+ } catch {
73
+ return String(value);
74
+ }
75
+ }
76
+
11
77
  // src/catalog.ts
12
78
  var ALL_SCOPES = ["mcp:read", "mcp:write"];
13
79
  var TOOL_CATALOG = [
@@ -170,7 +236,7 @@ var TOOL_CATALOG = [
170
236
  {
171
237
  name: "dispatch_fix",
172
238
  title: "Dispatch Mushi fix agent",
173
- description: 'Start a Mushi fix agent for a classified report; it writes a branch and opens a signed draft PR. Set agent="cursor_cloud" to dispatch a Cursor Cloud Agent (default uses the in-repo worker). Requires GitHub connected + an LLM key (run diagnose_setup mode=dispatch first). Returns { fixAttemptId }; poll get_fix_timeline for progress and merge_fix when CI is green. Write; NOT idempotent \u2014 each call starts a new attempt. Report must be classified \u2014 run triage_issue if not.',
239
+ description: 'Start a Mushi fix agent for a classified report; it writes a branch and opens a signed draft PR. Set agent="cursor_cloud" to dispatch a Cursor Cloud Agent (default uses the in-repo worker). Requires GitHub connected + an LLM key (run diagnose_setup mode=dispatch first). Returns { fixId, status } (fixId is the dispatch id; get_fix_timeline accepts it immediately); poll get_fix_timeline for progress and merge_fix when CI is green. Write; pass the same idempotencyKey to retry safely \u2014 without it each call starts a new attempt. Report must be classified \u2014 run triage_issue if not.',
174
240
  scope: "mcp:write",
175
241
  hints: { readOnly: false, destructive: false, idempotent: false, openWorld: true },
176
242
  useCase: "Let the in-repo agent attempt this fix for me (or: dispatch a Cursor Cloud Agent)."
@@ -305,6 +371,14 @@ var TOOL_CATALOG = [
305
371
  hints: { readOnly: true, idempotent: true, openWorld: true },
306
372
  useCase: "Analyze this bug report end-to-end and tell me what to do."
307
373
  },
374
+ {
375
+ name: "triage_next_steps",
376
+ title: "What should I work on now?",
377
+ description: 'Prioritised "do this next" list for the project: blocked auto-fixes first (with the unblock action), then in-flight fixes to shepherd to merge, then user-felt classified reports by severity, with robot/cron chores (dependency bumps) last. Returns { steps: [{ priority, action, reason, tool, args }], summary }. Read-only. Call this first when the user asks "what needs my attention / what should I triage or fix".',
378
+ scope: "mcp:read",
379
+ hints: { readOnly: true, idempotent: true, openWorld: true },
380
+ useCase: "What should I triage or fix right now?"
381
+ },
308
382
  // ── Lessons / evolution loop ─────────────────────────────────────────────
309
383
  {
310
384
  name: "query_lessons",
@@ -375,7 +449,7 @@ var TDD_TOOL_CATALOG = [
375
449
  {
376
450
  name: "list_byok_keys",
377
451
  title: "List your API key pool",
378
- description: "List the project's BYOK API keys grouped by provider (anthropic | openai | firecrawl | browserbase | cursor). Returns { keys: [{ id, provider, label, priority, status, cooldownUntil }] } \u2014 metadata only, never the raw secret. Read-only. Use to see which keys are active vs rate-limited/exhausted; use add_byok_key to add one.",
452
+ description: "List the project's BYOK API keys grouped by provider (anthropic | openai | firecrawl | browserbase | cursor). Returns pooled { keys: [{ id, provider_slug, label, priority, status, cooldown_until, test_status, key_hint, base_url, last_tested_at, last_used_at }] } plus read-only { legacyKeys } metadata for credentials saved before the pooled lifecycle \u2014 never the raw secret. Read-only. Use to see which keys are validated, active, pending, legacy, or rate-limited; use add_byok_key to add one.",
379
453
  scope: "mcp:read",
380
454
  hints: { readOnly: true, idempotent: true, openWorld: true },
381
455
  useCase: "Which API keys are active and which are rate-limited?"
@@ -383,11 +457,27 @@ var TDD_TOOL_CATALOG = [
383
457
  {
384
458
  name: "add_byok_key",
385
459
  title: "Add an API key",
386
- description: "Add a BYOK API key to the project pool for a provider (anthropic | openai | firecrawl | browserbase | cursor), with a label and priority for failover ordering. The raw key is stored encrypted in Supabase Vault and never returned. Returns { id, provider, label }. Write; NOT idempotent \u2014 adds a new row each call. Use to register a backup/rotated key; use list_byok_keys to review the pool.",
460
+ description: "Add and immediately validate a BYOK API key for anthropic | openai | firecrawl | browserbase | cursor. The raw key is stored encrypted in Supabase Vault and never returned; failed probes remain quarantined. Optional baseUrl is accepted only for allow-listed OpenAI-compatible HTTPS providers. Write; NOT idempotent.",
387
461
  scope: "mcp:write",
388
462
  hints: { readOnly: false, destructive: false, idempotent: false, openWorld: true },
389
463
  useCase: "Add a backup Anthropic key to the pool."
390
464
  },
465
+ {
466
+ name: "test_byok_key",
467
+ title: "Test an API key",
468
+ description: "Re-test one pooled BYOK credential by key id. A successful provider probe activates the key; auth, quota, and network failures keep it out of the runtime pool. Returns the sanitized validation result and updated key metadata. Write; idempotent.",
469
+ scope: "mcp:write",
470
+ hints: { readOnly: false, destructive: false, idempotent: true, openWorld: true },
471
+ useCase: "Re-test a quarantined OpenAI key after fixing its account quota."
472
+ },
473
+ {
474
+ name: "remove_byok_key",
475
+ title: "Remove an API key",
476
+ description: "Permanently remove one pooled BYOK credential by key id from the authenticated project, including its Vault secret. Write; destructive; idempotent.",
477
+ scope: "mcp:write",
478
+ hints: { readOnly: false, destructive: true, idempotent: true, openWorld: true },
479
+ useCase: "Remove a rotated or compromised provider key from the runtime pool."
480
+ },
391
481
  {
392
482
  name: "list_pending_review_stories",
393
483
  title: "List QA stories pending review",
@@ -530,24 +620,29 @@ var TDD_TOOL_CATALOG = [
530
620
  }
531
621
  ];
532
622
  var USE_MUSHI_INTENTS = {
623
+ // NOTE: every name below MUST be a registered TOOL (scopes.test.ts guards
624
+ // this). 2026-08-16 audit P0-3 found phantom names (get_report,
625
+ // get_dashboard, list_qa_stories) and prompts listed as tools
626
+ // (summarize_report_for_fix, mushi_setup) — agents routed here then
627
+ // failed the call and gave up.
533
628
  fix: {
534
629
  label: "Fix a bug",
535
630
  tools: [
536
631
  "get_recent_reports",
537
- "get_report",
538
- "summarize_report_for_fix",
632
+ "get_report_detail",
633
+ "triage_issue",
539
634
  "dispatch_fix",
540
635
  "start_skill_pipeline",
541
636
  "checkin_pipeline_step",
542
637
  "get_pipeline_run"
543
638
  ],
544
- hint: "Call get_recent_reports to find the top unresolved bug, then summarize_report_for_fix before dispatching."
639
+ hint: "Call get_recent_reports to find the top unresolved bug, then triage_issue before dispatching."
545
640
  },
546
641
  status: {
547
642
  label: "Check project status",
548
643
  tools: [
549
- "get_dashboard",
550
644
  "triage_next_steps",
645
+ "get_account_overview",
551
646
  "get_usage",
552
647
  "get_backend_health",
553
648
  "activation_status"
@@ -557,18 +652,19 @@ var USE_MUSHI_INTENTS = {
557
652
  setup: {
558
653
  label: "Set up Mushi",
559
654
  tools: [
560
- "mushi_setup",
655
+ "diagnose_setup",
561
656
  "activation_status",
562
657
  "get_backend_health",
563
658
  "list_byok_keys",
564
- "add_byok_key"
659
+ "add_byok_key",
660
+ "test_byok_key",
661
+ "remove_byok_key"
565
662
  ],
566
- hint: "Call mushi_setup first \u2014 it diagnoses setup gaps and returns the next command to run."
663
+ hint: "Call diagnose_setup first \u2014 it diagnoses setup gaps and returns the next action to take."
567
664
  },
568
665
  qa: {
569
666
  label: "Run / review QA tests",
570
667
  tools: [
571
- "list_qa_stories",
572
668
  "run_qa_story",
573
669
  "list_qa_story_runs",
574
670
  "get_qa_story_run",
@@ -576,7 +672,7 @@ var USE_MUSHI_INTENTS = {
576
672
  "approve_qa_story",
577
673
  "improve_qa_story"
578
674
  ],
579
- hint: "Call list_qa_stories to see what test coverage exists; run_qa_story to trigger a run."
675
+ hint: "Call list_qa_story_runs to see recent coverage and outcomes; run_qa_story to trigger a run."
580
676
  },
581
677
  pipeline: {
582
678
  label: "Run an agent pipeline / skill",
@@ -591,12 +687,7 @@ var USE_MUSHI_INTENTS = {
591
687
  },
592
688
  audit: {
593
689
  label: "Audit / health check",
594
- tools: [
595
- "run_fullstack_audit",
596
- "get_backend_health",
597
- "get_dashboard",
598
- "get_usage"
599
- ],
690
+ tools: ["run_fullstack_audit", "get_backend_health", "get_account_overview", "get_usage"],
600
691
  hint: "Call run_fullstack_audit for a full-stack health scorecard."
601
692
  }
602
693
  };
@@ -759,8 +850,10 @@ var TOOL_FEATURE_MAP = {
759
850
  start_skill_pipeline: "skills",
760
851
  get_pipeline_run: "skills",
761
852
  checkin_pipeline_step: "skills",
762
- list_byok_keys: "skills",
763
- add_byok_key: "skills",
853
+ list_byok_keys: "setup",
854
+ add_byok_key: "setup",
855
+ test_byok_key: "setup",
856
+ remove_byok_key: "setup",
764
857
  // audit
765
858
  run_fullstack_audit: "audit",
766
859
  get_backend_health: "audit",
@@ -966,7 +1059,40 @@ function wrapUntrustedJson(value, role) {
966
1059
  }
967
1060
 
968
1061
  // src/server.ts
1062
+ installStdoutGuard();
969
1063
  var NO_ARG_INPUT = {};
1064
+ var DEFAULT_API_TIMEOUT_MS = 15e3;
1065
+ var MIN_API_TIMEOUT_MS = 250;
1066
+ var MAX_API_TIMEOUT_MS = 3e5;
1067
+ function resolveTimeoutMsFromEnv() {
1068
+ const raw = process.env?.MUSHI_MCP_TIMEOUT_MS?.trim();
1069
+ if (!raw) return DEFAULT_API_TIMEOUT_MS;
1070
+ const parsed = Number(raw);
1071
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_TIMEOUT_MS;
1072
+ return Math.min(Math.max(parsed, MIN_API_TIMEOUT_MS), MAX_API_TIMEOUT_MS);
1073
+ }
1074
+ function bareMessage(err) {
1075
+ const raw = err instanceof Error ? err.message : String(err);
1076
+ return raw.replace(/^\[[A-Z0-9_]+\]\s*/, "");
1077
+ }
1078
+ var IDEMPOTENCY_NAMESPACE = "mushi.mcp.submit_fix_result.v1";
1079
+ async function stableUuidFrom(...parts) {
1080
+ const input = parts.join("");
1081
+ const digest = new Uint8Array(
1082
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input))
1083
+ );
1084
+ const bytes = digest.slice(0, 16);
1085
+ bytes[6] = bytes[6] & 15 | 80;
1086
+ bytes[8] = bytes[8] & 63 | 128;
1087
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
1088
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
1089
+ }
1090
+ function withTimeoutSignal(callerSignal, timeoutSignal) {
1091
+ if (!callerSignal) return timeoutSignal;
1092
+ const anyFn = AbortSignal.any;
1093
+ if (typeof anyFn === "function") return anyFn.call(AbortSignal, [callerSignal, timeoutSignal]);
1094
+ return timeoutSignal;
1095
+ }
970
1096
  var MushiApiError = class extends Error {
971
1097
  constructor(status, code, message) {
972
1098
  super(`[${code}] ${message}`);
@@ -980,6 +1106,7 @@ var MushiApiError = class extends Error {
980
1106
  function createMushiServer(config) {
981
1107
  const { version, apiEndpoint, apiKey, projectId } = config;
982
1108
  const doFetch = config.fetch ?? globalThis.fetch;
1109
+ const timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : resolveTimeoutMsFromEnv();
983
1110
  const apiLog = createLogger({
984
1111
  scope: "mushi:mcp:api",
985
1112
  level: process.env.MUSHI_LOG_LEVEL ?? "info",
@@ -997,22 +1124,66 @@ function createMushiServer(config) {
997
1124
  async function apiCall(path, options) {
998
1125
  const requestId = crypto.randomUUID().slice(0, 12);
999
1126
  const started = Date.now();
1000
- const res = await doFetch(`${apiEndpoint}${path}`, {
1001
- ...options,
1002
- headers: {
1003
- "Content-Type": "application/json",
1004
- // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
1005
- // still send it for endpoints still behind plain `jwtAuth` (legacy)
1006
- // and for transparent proxies that strip X-Mushi-* headers.
1007
- "Authorization": `Bearer ${apiKey}`,
1008
- "X-Mushi-Api-Key": apiKey,
1009
- "X-Request-Id": requestId,
1010
- ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
1011
- ...options?.headers ?? {}
1127
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
1128
+ let res;
1129
+ try {
1130
+ res = await doFetch(`${apiEndpoint}${path}`, {
1131
+ ...options,
1132
+ headers: {
1133
+ "Content-Type": "application/json",
1134
+ // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
1135
+ // still send it for endpoints still behind plain `jwtAuth` (legacy)
1136
+ // and for transparent proxies that strip X-Mushi-* headers.
1137
+ Authorization: `Bearer ${apiKey}`,
1138
+ "X-Mushi-Api-Key": apiKey,
1139
+ "X-Request-Id": requestId,
1140
+ ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
1141
+ ...options?.headers ?? {}
1142
+ },
1143
+ // After the spread so a caller-supplied `signal` is merged, not lost.
1144
+ signal: withTimeoutSignal(options?.signal, timeoutSignal)
1145
+ });
1146
+ } catch (err) {
1147
+ throw classifyTransportError(err);
1148
+ }
1149
+ let text;
1150
+ try {
1151
+ text = await res.text();
1152
+ } catch (err) {
1153
+ throw classifyTransportError(err);
1154
+ }
1155
+ function classifyTransportError(err) {
1156
+ const durationMs2 = Date.now() - started;
1157
+ if (timeoutSignal.aborted) {
1158
+ apiLog.warn("api.timeout", { path, requestId, durationMs: durationMs2, timeoutMs });
1159
+ return new MushiApiError(
1160
+ 504,
1161
+ "MUSHI_TIMEOUT",
1162
+ `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}`
1163
+ );
1012
1164
  }
1013
- });
1165
+ const name = err instanceof Error ? err.name : "";
1166
+ if (name === "AbortError") {
1167
+ apiLog.warn("api.aborted", { path, requestId, durationMs: durationMs2 });
1168
+ return new MushiApiError(
1169
+ 499,
1170
+ "REQUEST_ABORTED",
1171
+ `The request to ${path} was aborted by the caller after ${durationMs2}ms.`
1172
+ );
1173
+ }
1174
+ apiLog.warn("api.unreachable", {
1175
+ path,
1176
+ requestId,
1177
+ durationMs: durationMs2,
1178
+ err: bareMessage(err)
1179
+ });
1180
+ return new MushiApiError(
1181
+ 503,
1182
+ "NETWORK_ERROR",
1183
+ `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.`
1184
+ );
1185
+ }
1014
1186
  const durationMs = Date.now() - started;
1015
- const text = await res.text();
1016
1187
  let body = null;
1017
1188
  if (text) {
1018
1189
  try {
@@ -1042,6 +1213,7 @@ function createMushiServer(config) {
1042
1213
  apiLog.info("api.done", { path, requestId, status: res.status, durationMs });
1043
1214
  return body;
1044
1215
  }
1216
+ let resolvedAccountProjectId = null;
1045
1217
  async function resolveProjectId(explicitId) {
1046
1218
  if (explicitId) {
1047
1219
  if (projectId && explicitId !== projectId) {
@@ -1054,12 +1226,12 @@ function createMushiServer(config) {
1054
1226
  return explicitId;
1055
1227
  }
1056
1228
  if (projectId) return projectId;
1229
+ if (resolvedAccountProjectId) return resolvedAccountProjectId;
1057
1230
  try {
1058
- const data = await apiCall(
1059
- "/v1/admin/mcp/account-overview"
1060
- );
1231
+ const data = await apiCall("/v1/admin/mcp/account-overview");
1061
1232
  if (data.total === 1 && data.projects[0]) {
1062
- return data.projects[0].id;
1233
+ resolvedAccountProjectId = data.projects[0].id;
1234
+ return resolvedAccountProjectId;
1063
1235
  }
1064
1236
  if (data.total === 0) {
1065
1237
  throw new MushiApiError(
@@ -1075,11 +1247,27 @@ function createMushiServer(config) {
1075
1247
  `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.`
1076
1248
  );
1077
1249
  } catch (err) {
1078
- if (err instanceof MushiApiError) throw err;
1250
+ if (err instanceof MushiApiError) {
1251
+ if (err.code === "MUSHI_TIMEOUT" || err.code === "NETWORK_ERROR") {
1252
+ throw new MushiApiError(
1253
+ err.status,
1254
+ err.code,
1255
+ `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.`
1256
+ );
1257
+ }
1258
+ if (err.status === 401 || err.status === 403) {
1259
+ throw new MushiApiError(
1260
+ err.status,
1261
+ err.code,
1262
+ `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.`
1263
+ );
1264
+ }
1265
+ throw err;
1266
+ }
1079
1267
  throw new MushiApiError(
1080
1268
  400,
1081
1269
  "MISSING_PROJECT_ID",
1082
- "projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly."
1270
+ `projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly. (Project lookup failed with: ${bareMessage(err)})`
1083
1271
  );
1084
1272
  }
1085
1273
  }
@@ -1168,9 +1356,12 @@ function createMushiServer(config) {
1168
1356
  params.set("limit", String(Math.min(args.limit ?? 20, 100)));
1169
1357
  const pid = await resolveProjectId(args.project_id);
1170
1358
  const extraHeaders = pid !== projectId ? { "X-Mushi-Project-Id": pid } : {};
1171
- const data = await apiCall(`/v1/admin/reports?${params}`, {
1172
- headers: extraHeaders
1173
- });
1359
+ const data = await apiCall(
1360
+ `/v1/admin/reports?${params}`,
1361
+ {
1362
+ headers: extraHeaders
1363
+ }
1364
+ );
1174
1365
  return jsonResult(data);
1175
1366
  }
1176
1367
  );
@@ -1193,7 +1384,10 @@ function createMushiServer(config) {
1193
1384
  return {
1194
1385
  ...res,
1195
1386
  resource_links: [
1196
- { uri: `project://reports/${args.reportId}`, title: `Report ${args.reportId.slice(0, 8)}\u2026` }
1387
+ {
1388
+ uri: `project://reports/${args.reportId}`,
1389
+ title: `Report ${args.reportId.slice(0, 8)}\u2026`
1390
+ }
1197
1391
  ]
1198
1392
  };
1199
1393
  }
@@ -1297,17 +1491,20 @@ function createMushiServer(config) {
1297
1491
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`, {
1298
1492
  headers
1299
1493
  });
1300
- return wrappedJsonResult({
1301
- report,
1302
- // Paste-ready fix prompt composed server-side by composeFixPacket()
1303
- // (diagnosis + repro + suggested fix + relevant code + blast radius).
1304
- // Hand this straight to the editing agent — no second LLM key needed.
1305
- fixPrompt: report.fix_packet ?? null,
1306
- reproductionSteps: report.reproduction_steps ?? [],
1307
- component: report.component,
1308
- rootCause: report.stage2_analysis?.rootCause,
1309
- bugOntologyTags: report.bug_ontology_tags
1310
- }, "fix context");
1494
+ return wrappedJsonResult(
1495
+ {
1496
+ report,
1497
+ // Paste-ready fix prompt composed server-side by composeFixPacket()
1498
+ // (diagnosis + repro + suggested fix + relevant code + blast radius).
1499
+ // Hand this straight to the editing agent — no second LLM key needed.
1500
+ fixPrompt: report.fix_packet ?? null,
1501
+ reproductionSteps: report.reproduction_steps ?? [],
1502
+ component: report.component,
1503
+ rootCause: report.stage2_analysis?.rootCause,
1504
+ bugOntologyTags: report.bug_ontology_tags
1505
+ },
1506
+ "fix context"
1507
+ );
1311
1508
  }
1312
1509
  );
1313
1510
  server.registerTool(
@@ -1356,7 +1553,9 @@ function createMushiServer(config) {
1356
1553
  seed: args.seed,
1357
1554
  depth: String(Math.min(args.depth ?? 2, 4))
1358
1555
  });
1359
- const data = await apiCall(`/v1/admin/graph/traverse?${params}`);
1556
+ const data = await apiCall(
1557
+ `/v1/admin/graph/traverse?${params}`
1558
+ );
1360
1559
  return jsonResult(data);
1361
1560
  }
1362
1561
  );
@@ -1367,7 +1566,9 @@ function createMushiServer(config) {
1367
1566
  description: descOf("get_graph_neighborhood"),
1368
1567
  annotations: annotationsFor("get_graph_neighborhood"),
1369
1568
  inputSchema: {
1370
- seed: z.string().describe("Starting graph node id or label to expand around (e.g. an inventory Action id or component name)"),
1569
+ seed: z.string().describe(
1570
+ "Starting graph node id or label to expand around (e.g. an inventory Action id or component name)"
1571
+ ),
1371
1572
  depth: z.number().int().min(1).max(4).optional().describe("BFS hops to traverse outward. Default 2; clamped to a max of 4.")
1372
1573
  },
1373
1574
  outputSchema: {
@@ -1380,7 +1581,9 @@ function createMushiServer(config) {
1380
1581
  seed: args.seed,
1381
1582
  depth: String(Math.min(args.depth ?? 2, 4))
1382
1583
  });
1383
- const data = await apiCall(`/v1/admin/graph/traverse?${params}`);
1584
+ const data = await apiCall(
1585
+ `/v1/admin/graph/traverse?${params}`
1586
+ );
1384
1587
  return jsonResult(data);
1385
1588
  }
1386
1589
  );
@@ -1390,7 +1593,9 @@ function createMushiServer(config) {
1390
1593
  title: titleOf("get_graph_node"),
1391
1594
  description: descOf("get_graph_node"),
1392
1595
  annotations: annotationsFor("get_graph_node"),
1393
- inputSchema: { nodeId: z.string().describe("The graph_nodes.id of the node to fetch (UUID).") },
1596
+ inputSchema: {
1597
+ nodeId: z.string().describe("The graph_nodes.id of the node to fetch (UUID).")
1598
+ },
1394
1599
  outputSchema: {
1395
1600
  node: z.unknown().describe("Single graph_nodes row including metadata (Action nodes carry v2 status)")
1396
1601
  }
@@ -1412,7 +1617,8 @@ function createMushiServer(config) {
1412
1617
  },
1413
1618
  async (args) => {
1414
1619
  const pid = args.projectId ?? projectId;
1415
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for get_inventory");
1620
+ if (!pid)
1621
+ throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for get_inventory");
1416
1622
  return jsonText(await apiCall(`/v1/admin/inventory/${pid}`));
1417
1623
  }
1418
1624
  );
@@ -1430,7 +1636,8 @@ function createMushiServer(config) {
1430
1636
  },
1431
1637
  async (args) => {
1432
1638
  const pid = args.projectId ?? projectId;
1433
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for diff_inventory");
1639
+ if (!pid)
1640
+ throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for diff_inventory");
1434
1641
  const q = new URLSearchParams({ from: args.fromSha, to: args.toSha });
1435
1642
  return jsonText(await apiCall(`/v1/admin/inventory/${pid}/diff?${q}`));
1436
1643
  }
@@ -1453,12 +1660,19 @@ function createMushiServer(config) {
1453
1660
  },
1454
1661
  async (args) => {
1455
1662
  const pid = args.projectId ?? projectId;
1456
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for list_gate_findings");
1663
+ if (!pid)
1664
+ throw new MushiApiError(
1665
+ 400,
1666
+ "MISSING_PROJECT",
1667
+ "projectId is required for list_gate_findings"
1668
+ );
1457
1669
  const q = new URLSearchParams();
1458
1670
  if (args.gate) q.set("gate", args.gate);
1459
1671
  if (args.severity) q.set("severity", args.severity);
1460
1672
  const suffix = q.toString() ? `?${q}` : "";
1461
- const data = await apiCall(`/v1/admin/inventory/${pid}/findings${suffix}`);
1673
+ const data = await apiCall(
1674
+ `/v1/admin/inventory/${pid}/findings${suffix}`
1675
+ );
1462
1676
  return jsonResult(data);
1463
1677
  }
1464
1678
  );
@@ -1468,19 +1682,33 @@ function createMushiServer(config) {
1468
1682
  title: titleOf("suggest_fix"),
1469
1683
  description: descOf("suggest_fix"),
1470
1684
  annotations: annotationsFor("suggest_fix"),
1471
- inputSchema: { reportId: z.string().describe("Report UUID to read the Stage-2 suggested-fix slice for") },
1685
+ inputSchema: {
1686
+ reportId: z.string().describe("Report UUID to read the Stage-2 suggested-fix slice for")
1687
+ },
1472
1688
  outputSchema: {
1473
1689
  reportId: z.string().describe("The report this slice was read from"),
1474
1690
  rootCause: z.unknown().describe("Stage-2 root-cause hint, or null if not yet classified"),
1475
1691
  suggestedFix: z.unknown().describe("Stage-2 suggested fix, or null if not yet classified"),
1476
1692
  reproductionSteps: z.unknown().describe("Reproduction steps recorded on the report (array; [] if none)"),
1477
1693
  summary: z.unknown().describe("One-line report summary, or null"),
1478
- component: z.unknown().describe("Component/page the bug was attributed to, or null")
1694
+ component: z.unknown().describe("Component/page the bug was attributed to, or null"),
1695
+ note: z.string().optional().describe("Present when no Stage-2 analysis exists \u2014 explains why and what to call instead")
1479
1696
  }
1480
1697
  },
1481
1698
  async (args) => {
1482
1699
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`);
1483
1700
  const s2 = report.stage2_analysis;
1701
+ if (!s2 || s2.rootCause == null && s2.suggestedFix == null) {
1702
+ return jsonResult({
1703
+ reportId: args.reportId,
1704
+ rootCause: null,
1705
+ suggestedFix: null,
1706
+ reproductionSteps: report.reproduction_steps ?? [],
1707
+ summary: report.summary ?? null,
1708
+ component: report.component ?? null,
1709
+ 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."
1710
+ });
1711
+ }
1484
1712
  return jsonResult({
1485
1713
  reportId: args.reportId,
1486
1714
  rootCause: s2?.rootCause ?? null,
@@ -1497,7 +1725,11 @@ function createMushiServer(config) {
1497
1725
  title: titleOf("run_nl_query"),
1498
1726
  description: descOf("run_nl_query"),
1499
1727
  annotations: annotationsFor("run_nl_query"),
1500
- inputSchema: { question: z.string().describe('Question in plain English, e.g. "Which components had the most critical bugs this week?"') }
1728
+ inputSchema: {
1729
+ question: z.string().describe(
1730
+ 'Question in plain English, e.g. "Which components had the most critical bugs this week?"'
1731
+ )
1732
+ }
1501
1733
  },
1502
1734
  async (args) => {
1503
1735
  const data = await apiCall("/v1/admin/query", {
@@ -1514,7 +1746,9 @@ function createMushiServer(config) {
1514
1746
  description: descOf("diagnose_setup"),
1515
1747
  annotations: annotationsFor("diagnose_setup"),
1516
1748
  inputSchema: {
1517
- mode: z.enum(["full", "ingest", "dispatch"]).optional().describe("full (default) = ingest + dispatch; ingest = SDK pipeline only; dispatch = fix preflight only."),
1749
+ mode: z.enum(["full", "ingest", "dispatch"]).optional().describe(
1750
+ "full (default) = ingest + dispatch; ingest = SDK pipeline only; dispatch = fix preflight only."
1751
+ ),
1518
1752
  project_id: z.string().optional().describe("Project UUID for dispatch checks (defaults to configured project)."),
1519
1753
  projectId: z.string().optional().describe("Alias for project_id.")
1520
1754
  },
@@ -1552,7 +1786,11 @@ function createMushiServer(config) {
1552
1786
  }
1553
1787
  if (mode === "dispatch") {
1554
1788
  if (!resolvedId) {
1555
- throw new MushiApiError(400, "MISSING_PROJECT", "project_id is required for dispatch mode");
1789
+ throw new MushiApiError(
1790
+ 400,
1791
+ "MISSING_PROJECT",
1792
+ "project_id is required for dispatch mode"
1793
+ );
1556
1794
  }
1557
1795
  const data = await apiCall(`/v1/admin/projects/${resolvedId}/preflight`);
1558
1796
  const failed = data.checks.filter((c) => !c.ready);
@@ -1686,7 +1924,12 @@ function createMushiServer(config) {
1686
1924
  async (args) => {
1687
1925
  const query = args.query ?? "";
1688
1926
  const hits = searchMushiDocs(query, args.limit ?? 8);
1689
- const results = hits.map(({ title, path, excerpt, score }) => ({ title, path, excerpt, score }));
1927
+ const results = hits.map(({ title, path, excerpt, score }) => ({
1928
+ title,
1929
+ path,
1930
+ excerpt,
1931
+ score
1932
+ }));
1690
1933
  return jsonResult({ query, results });
1691
1934
  }
1692
1935
  );
@@ -1836,7 +2079,9 @@ function createMushiServer(config) {
1836
2079
  } : { error: String(reportRes.reason) };
1837
2080
  return jsonText({
1838
2081
  evidence,
1839
- reporter_thread: timeline ?? { error: String(timelineRes.reason) }
2082
+ reporter_thread: timeline ?? {
2083
+ error: String(timelineRes.reason)
2084
+ }
1840
2085
  });
1841
2086
  }
1842
2087
  );
@@ -1871,27 +2116,54 @@ function createMushiServer(config) {
1871
2116
  const includeLogs = args.include_logs !== false;
1872
2117
  const [reportRes, evidenceRes, similarRes, fixCtxRes, blastRes, logsRes] = await Promise.allSettled([
1873
2118
  apiCall(`/v1/admin/reports/${args.report_id}`, { headers }),
1874
- apiCall(`/v1/admin/reports/${args.report_id}/timeline`, { headers }),
2119
+ apiCall(`/v1/admin/reports/${args.report_id}/timeline`, {
2120
+ headers
2121
+ }),
1875
2122
  apiCall(`/v1/admin/reports/similarity`, {
1876
2123
  method: "POST",
1877
2124
  headers,
1878
2125
  body: JSON.stringify({ report_id: args.report_id })
1879
2126
  }).catch(() => null),
1880
- apiCall(`/v1/admin/reports/${args.report_id}/fix-context`, { headers }).catch(() => null),
1881
- apiCall(`/v1/admin/reports/${args.report_id}/blast-radius`, { headers }).catch(() => null),
1882
- includeLogs ? apiCall(`/v1/admin/mcp/logs/${pid}?limit=20&level=warn`, { headers }).catch(() => null) : Promise.resolve(null)
2127
+ apiCall(`/v1/admin/reports/${args.report_id}/fix-context`, { headers }).catch(
2128
+ () => null
2129
+ ),
2130
+ apiCall(`/v1/admin/reports/${args.report_id}/blast-radius`, { headers }).catch(
2131
+ () => null
2132
+ ),
2133
+ includeLogs ? apiCall(`/v1/admin/mcp/logs/${pid}?limit=20&level=warn`, { headers }).catch(
2134
+ () => null
2135
+ ) : Promise.resolve(null)
1883
2136
  ]);
1884
2137
  const report = reportRes.status === "fulfilled" ? reportRes.value : null;
1885
2138
  const severity = report ? report.severity : "unknown";
1886
2139
  const category = report ? report.category : "unknown";
1887
2140
  const status = report ? report.status : "unknown";
1888
2141
  const actions = [];
1889
- if (status === "new" || status === "classified") {
2142
+ const processingError = report ? report.processing_error : null;
2143
+ const attempts = report ? report.fix_attempts ?? [] : [];
2144
+ const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
2145
+ const lastAttemptStatus = lastAttempt ? String(lastAttempt.status ?? "") : "";
2146
+ const autofixBlocked = typeof processingError === "string" && processingError.startsWith("autofix_blocked:") || lastAttemptStatus.startsWith("skipped") || lastAttemptStatus === "failed";
2147
+ if (autofixBlocked && status !== "fixing" && status !== "fixed") {
2148
+ const blockReason = typeof processingError === "string" && processingError || String(lastAttempt?.error ?? "previous fix attempt was skipped or failed");
2149
+ actions.push({
2150
+ action: "unblock_autofix",
2151
+ reason: `Auto-fix is blocked: ${blockReason}. Resolve the blocker (settings/integration/budget), then re-dispatch.`,
2152
+ tool: "diagnose_setup",
2153
+ args: { mode: "dispatch" }
2154
+ });
2155
+ actions.push({
2156
+ action: "redispatch_after_unblock",
2157
+ reason: "Once the blocker above is resolved, dispatch a fresh fix attempt.",
2158
+ tool: "dispatch_fix",
2159
+ args: { reportId: args.report_id }
2160
+ });
2161
+ } else if (status === "new" || status === "classified") {
1890
2162
  actions.push({
1891
2163
  action: "dispatch_fix",
1892
- reason: "Report is classified but no fix has been attempted",
2164
+ reason: "Report is classified and no fix has been attempted",
1893
2165
  tool: "dispatch_fix",
1894
- args: { reportId: args.report_id, agent: "cursor_cloud" }
2166
+ args: { reportId: args.report_id }
1895
2167
  });
1896
2168
  } else if (status === "fixing") {
1897
2169
  actions.push({
@@ -1946,7 +2218,10 @@ function createMushiServer(config) {
1946
2218
  return {
1947
2219
  ...res,
1948
2220
  resource_links: [
1949
- { uri: `project://reports/${args.report_id}`, title: `Report ${args.report_id.slice(0, 8)}\u2026` },
2221
+ {
2222
+ uri: `project://reports/${args.report_id}`,
2223
+ title: `Report ${args.report_id.slice(0, 8)}\u2026`
2224
+ },
1950
2225
  ...pid ? [{ uri: `project://dashboard`, title: "Project dashboard" }] : []
1951
2226
  ]
1952
2227
  };
@@ -1965,7 +2240,9 @@ function createMushiServer(config) {
1965
2240
  filesChanged: z.array(z.string()).describe("Repo-relative paths modified by the fix"),
1966
2241
  linesChanged: z.number().describe("Total lines added + removed across filesChanged"),
1967
2242
  summary: z.string().describe("One-line human summary of what the fix changed"),
1968
- idempotencyKey: z.string().uuid().optional().describe("Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows")
2243
+ idempotencyKey: z.string().uuid().optional().describe(
2244
+ "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."
2245
+ )
1969
2246
  },
1970
2247
  outputSchema: {
1971
2248
  ok: z.boolean().describe("True when the fix_attempt row was created and marked completed"),
@@ -1973,27 +2250,123 @@ function createMushiServer(config) {
1973
2250
  }
1974
2251
  },
1975
2252
  async (args) => {
1976
- const idemKey = args.idempotencyKey ?? crypto.randomUUID();
2253
+ const idemKey = args.idempotencyKey ?? await stableUuidFrom(
2254
+ IDEMPOTENCY_NAMESPACE,
2255
+ args.reportId,
2256
+ args.branch,
2257
+ args.prUrl ?? ""
2258
+ );
1977
2259
  const created = await apiCall("/v1/admin/fixes", {
1978
2260
  method: "POST",
1979
2261
  headers: { "Idempotency-Key": idemKey },
1980
2262
  body: JSON.stringify({ reportId: args.reportId, agent: "mcp" })
1981
2263
  });
1982
- await apiCall(`/v1/admin/fixes/${created.fixId}`, {
1983
- method: "PATCH",
1984
- body: JSON.stringify({
1985
- status: "completed",
1986
- branch: args.branch,
1987
- pr_url: args.prUrl,
1988
- files_changed: args.filesChanged,
1989
- lines_changed: args.linesChanged,
1990
- summary: args.summary,
1991
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
1992
- })
1993
- });
2264
+ try {
2265
+ await apiCall(`/v1/admin/fixes/${created.fixId}`, {
2266
+ method: "PATCH",
2267
+ body: JSON.stringify({
2268
+ status: "completed",
2269
+ branch: args.branch,
2270
+ pr_url: args.prUrl,
2271
+ files_changed: args.filesChanged,
2272
+ lines_changed: args.linesChanged,
2273
+ summary: args.summary,
2274
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
2275
+ })
2276
+ });
2277
+ } catch (err) {
2278
+ const status = err instanceof MushiApiError ? err.status : 500;
2279
+ throw new MushiApiError(
2280
+ status,
2281
+ "FIX_PATCH_FAILED",
2282
+ `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.`
2283
+ );
2284
+ }
1994
2285
  return jsonResult({ ok: true, fixId: created.fixId });
1995
2286
  }
1996
2287
  );
2288
+ server.registerTool(
2289
+ "triage_next_steps",
2290
+ {
2291
+ title: titleOf("triage_next_steps"),
2292
+ description: descOf("triage_next_steps"),
2293
+ annotations: annotationsFor("triage_next_steps"),
2294
+ inputSchema: {
2295
+ project_id: z.string().uuid().optional().describe("Project UUID (defaults to the configured project).")
2296
+ },
2297
+ outputSchema: {
2298
+ steps: z.array(
2299
+ z.object({
2300
+ priority: z.number(),
2301
+ action: z.string(),
2302
+ reason: z.string(),
2303
+ tool: z.string().optional(),
2304
+ args: z.record(z.string(), z.unknown()).optional()
2305
+ })
2306
+ ),
2307
+ summary: z.string()
2308
+ }
2309
+ },
2310
+ async (args) => {
2311
+ const pid = await resolveProjectId(args.project_id);
2312
+ const extraHeaders = pid !== projectId ? { "X-Mushi-Project-Id": pid } : {};
2313
+ const data = await apiCall(
2314
+ "/v1/admin/reports?limit=100",
2315
+ { headers: extraHeaders }
2316
+ );
2317
+ const reports = data.reports ?? [];
2318
+ const isCron = (r) => typeof r.reporter_token_hash === "string" && r.reporter_token_hash.startsWith("cron:");
2319
+ const isBlocked = (r) => typeof r.processing_error === "string" && r.processing_error.startsWith("autofix_blocked:");
2320
+ const label = (r) => String(r.title ?? r.summary ?? r.description ?? "").slice(0, 90);
2321
+ const steps = [];
2322
+ let priority = 1;
2323
+ const blocked = reports.filter(isBlocked);
2324
+ if (blocked.length > 0) {
2325
+ steps.push({
2326
+ priority: priority++,
2327
+ action: `Unblock auto-fix (${blocked.length} report${blocked.length === 1 ? "" : "s"} blocked)`,
2328
+ reason: String(blocked[0].processing_error).slice(0, 200),
2329
+ tool: "diagnose_setup",
2330
+ args: { mode: "dispatch" }
2331
+ });
2332
+ }
2333
+ const fixing = reports.filter((r) => r.status === "fixing");
2334
+ for (const r of fixing.slice(0, 2)) {
2335
+ steps.push({
2336
+ priority: priority++,
2337
+ action: `Shepherd in-flight fix: ${label(r)}`,
2338
+ reason: "A fix branch/PR is open \u2014 check CI and merge when green.",
2339
+ tool: "get_report_timeline",
2340
+ args: { report_id: r.id }
2341
+ });
2342
+ }
2343
+ const openUserReports = reports.filter((r) => (r.status === "new" || r.status === "classified") && !isCron(r) && !isBlocked(r)).sort((a, b) => {
2344
+ const rank = { critical: 0, high: 1, medium: 2, low: 3 };
2345
+ return (rank[String(a.severity)] ?? 4) - (rank[String(b.severity)] ?? 4);
2346
+ });
2347
+ for (const r of openUserReports.slice(0, Math.max(0, 4 - steps.length))) {
2348
+ steps.push({
2349
+ priority: priority++,
2350
+ action: `Triage [${r.severity}] ${label(r)}`,
2351
+ reason: "User-felt report awaiting triage \u2014 review the packet, then dispatch or dismiss.",
2352
+ tool: "triage_issue",
2353
+ args: { report_id: r.id }
2354
+ });
2355
+ }
2356
+ const chores = reports.filter((r) => r.status === "classified" && isCron(r) && !isBlocked(r));
2357
+ if (chores.length > 0 && steps.length < 5) {
2358
+ steps.push({
2359
+ priority: priority++,
2360
+ action: `Batch ${chores.length} maintenance chore${chores.length === 1 ? "" : "s"} (dependency bumps)`,
2361
+ reason: "Robot-filed modernization reports \u2014 batch-dispatch or dismiss in one sitting; do not let them crowd out user bugs.",
2362
+ tool: "get_recent_reports",
2363
+ args: { status: "classified" }
2364
+ });
2365
+ }
2366
+ 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 ");
2367
+ return jsonResult({ steps, summary });
2368
+ }
2369
+ );
1997
2370
  server.registerTool(
1998
2371
  "dispatch_fix",
1999
2372
  {
@@ -2003,8 +2376,12 @@ function createMushiServer(config) {
2003
2376
  inputSchema: {
2004
2377
  reportId: z.string().uuid().describe("Report UUID to fix"),
2005
2378
  agent: z.enum(["claude_code", "codex", "rest_worker", "mcp"]).optional().describe("Override the agent adapter"),
2006
- 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)."),
2007
- 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.")
2379
+ idempotencyKey: z.string().uuid().optional().describe(
2380
+ "Optional RFC 4122 UUID. Resend the same key to safely retry without dispatching a duplicate fix job (Idempotency-Key IETF draft)."
2381
+ ),
2382
+ inventoryActionNodeId: z.string().uuid().optional().describe(
2383
+ "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."
2384
+ )
2008
2385
  },
2009
2386
  outputSchema: {
2010
2387
  fixId: z.string(),
@@ -2026,7 +2403,7 @@ function createMushiServer(config) {
2026
2403
  } catch {
2027
2404
  }
2028
2405
  }
2029
- const data = await apiCall("/v1/admin/fixes/dispatch", {
2406
+ const dispatch = await apiCall("/v1/admin/fixes/dispatch", {
2030
2407
  method: "POST",
2031
2408
  headers: args.idempotencyKey ? { "Idempotency-Key": args.idempotencyKey } : void 0,
2032
2409
  body: JSON.stringify({
@@ -2036,7 +2413,12 @@ function createMushiServer(config) {
2036
2413
  ...projectId ? { projectId } : {}
2037
2414
  })
2038
2415
  });
2039
- return jsonResult(data);
2416
+ if (!dispatch?.dispatchId) {
2417
+ throw new Error(
2418
+ `Dispatch response missing dispatchId \u2014 got: ${JSON.stringify(dispatch).slice(0, 300)}`
2419
+ );
2420
+ }
2421
+ return jsonResult({ fixId: dispatch.dispatchId, status: dispatch.status ?? "queued" });
2040
2422
  }
2041
2423
  );
2042
2424
  server.registerTool(
@@ -2077,11 +2459,19 @@ function createMushiServer(config) {
2077
2459
  },
2078
2460
  async (args) => {
2079
2461
  const pid = args.projectId ?? projectId;
2080
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for test_gen_from_report");
2081
- const data = await apiCall(`/v1/admin/inventory/${pid}/test-gen/from-report/${args.reportId}`, {
2082
- method: "POST",
2083
- body: JSON.stringify({})
2084
- });
2462
+ if (!pid)
2463
+ throw new MushiApiError(
2464
+ 400,
2465
+ "MISSING_PROJECT",
2466
+ "projectId is required for test_gen_from_report"
2467
+ );
2468
+ const data = await apiCall(
2469
+ `/v1/admin/inventory/${pid}/test-gen/from-report/${args.reportId}`,
2470
+ {
2471
+ method: "POST",
2472
+ body: JSON.stringify({})
2473
+ }
2474
+ );
2085
2475
  return jsonText(data);
2086
2476
  }
2087
2477
  );
@@ -2160,7 +2550,22 @@ function createMushiServer(config) {
2160
2550
  annotations: annotationsFor("transition_status"),
2161
2551
  inputSchema: {
2162
2552
  reportId: z.string().describe("Report UUID"),
2163
- status: z.enum(["pending", "classified", "grouped", "fixing", "fixed", "resolved", "verified", "reopened", "dismissed"]).describe("Target status"),
2553
+ status: z.enum([
2554
+ "pending",
2555
+ "classified",
2556
+ // Workflow states added 20260520600000 but missing from this
2557
+ // enum ever since (2026-08-16 audit P2-2) — agents could not
2558
+ // mark a report as being looked at.
2559
+ "triaged",
2560
+ "in_progress",
2561
+ "grouped",
2562
+ "fixing",
2563
+ "fixed",
2564
+ "resolved",
2565
+ "verified",
2566
+ "reopened",
2567
+ "dismissed"
2568
+ ]).describe("Target status (resolved is stored as fixed)"),
2164
2569
  reason: z.string().optional().describe("Reason for the transition (audit trail)")
2165
2570
  }
2166
2571
  },
@@ -2184,7 +2589,12 @@ function createMushiServer(config) {
2184
2589
  },
2185
2590
  async (args) => {
2186
2591
  const pid = args.projectId ?? projectId;
2187
- if (!pid) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required for setup_repo_for_mushi");
2592
+ if (!pid)
2593
+ throw new MushiApiError(
2594
+ 400,
2595
+ "MISSING_PROJECT",
2596
+ "projectId is required for setup_repo_for_mushi"
2597
+ );
2188
2598
  const data = await apiCall(`/v1/admin/projects/${pid}/repo/bootstrap`, {
2189
2599
  method: "POST",
2190
2600
  body: JSON.stringify({})
@@ -2249,23 +2659,45 @@ function createMushiServer(config) {
2249
2659
  "project://stats",
2250
2660
  { description: "Report counts, category breakdown, severity distribution" },
2251
2661
  async () => ({
2252
- contents: [{ uri: "project://stats", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/stats"), null, 2) }]
2662
+ contents: [
2663
+ {
2664
+ uri: "project://stats",
2665
+ mimeType: "application/json",
2666
+ text: JSON.stringify(await apiCall("/v1/admin/stats"), null, 2)
2667
+ }
2668
+ ]
2253
2669
  })
2254
2670
  );
2255
2671
  server.resource(
2256
2672
  "project_settings",
2257
2673
  "project://settings",
2258
- { description: "Project configuration \u2014 autofix agent, plugins enabled, ontology, LLM budgets" },
2674
+ {
2675
+ description: "Project configuration \u2014 autofix agent, plugins enabled, ontology, LLM budgets"
2676
+ },
2259
2677
  async () => ({
2260
- contents: [{ uri: "project://settings", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/settings"), null, 2) }]
2678
+ contents: [
2679
+ {
2680
+ uri: "project://settings",
2681
+ mimeType: "application/json",
2682
+ text: JSON.stringify(await apiCall("/v1/admin/settings"), null, 2)
2683
+ }
2684
+ ]
2261
2685
  })
2262
2686
  );
2263
2687
  server.resource(
2264
2688
  "project_dashboard",
2265
2689
  "project://dashboard",
2266
- { description: "PDCA health snapshot \u2014 stage counts, bottleneck, recent activity (same payload the admin console polls)" },
2690
+ {
2691
+ description: "PDCA health snapshot \u2014 stage counts, bottleneck, recent activity (same payload the admin console polls)"
2692
+ },
2267
2693
  async () => ({
2268
- contents: [{ uri: "project://dashboard", mimeType: "application/json", text: JSON.stringify(await apiCall("/v1/admin/dashboard"), null, 2) }]
2694
+ contents: [
2695
+ {
2696
+ uri: "project://dashboard",
2697
+ mimeType: "application/json",
2698
+ text: JSON.stringify(await apiCall("/v1/admin/dashboard"), null, 2)
2699
+ }
2700
+ ]
2269
2701
  })
2270
2702
  );
2271
2703
  server.registerTool(
@@ -2280,14 +2712,16 @@ function createMushiServer(config) {
2280
2712
  }
2281
2713
  },
2282
2714
  async ({ limit, range }) => ({
2283
- content: [{
2284
- type: "text",
2285
- text: JSON.stringify(
2286
- await apiCall(`/v1/admin/rewards/leaderboard?range=${range}&limit=${limit}`),
2287
- null,
2288
- 2
2289
- )
2290
- }]
2715
+ content: [
2716
+ {
2717
+ type: "text",
2718
+ text: JSON.stringify(
2719
+ await apiCall(`/v1/admin/rewards/leaderboard?range=${range}&limit=${limit}`),
2720
+ null,
2721
+ 2
2722
+ )
2723
+ }
2724
+ ]
2291
2725
  })
2292
2726
  );
2293
2727
  server.registerTool(
@@ -2303,18 +2737,20 @@ function createMushiServer(config) {
2303
2737
  }
2304
2738
  },
2305
2739
  async ({ external_user_id, points, reason }) => ({
2306
- content: [{
2307
- type: "text",
2308
- text: JSON.stringify(
2309
- await apiCall("/v1/admin/rewards/bonus-points", {
2310
- method: "POST",
2311
- headers: { "Content-Type": "application/json" },
2312
- body: JSON.stringify({ external_user_id, points, reason })
2313
- }),
2314
- null,
2315
- 2
2316
- )
2317
- }]
2740
+ content: [
2741
+ {
2742
+ type: "text",
2743
+ text: JSON.stringify(
2744
+ await apiCall("/v1/admin/rewards/bonus-points", {
2745
+ method: "POST",
2746
+ headers: { "Content-Type": "application/json" },
2747
+ body: JSON.stringify({ external_user_id, points, reason })
2748
+ }),
2749
+ null,
2750
+ 2
2751
+ )
2752
+ }
2753
+ ]
2318
2754
  })
2319
2755
  );
2320
2756
  server.registerTool(
@@ -2330,18 +2766,20 @@ function createMushiServer(config) {
2330
2766
  }
2331
2767
  },
2332
2768
  async ({ external_user_id, tier_slug, reason }) => ({
2333
- content: [{
2334
- type: "text",
2335
- text: JSON.stringify(
2336
- await apiCall("/v1/admin/rewards/set-tier", {
2337
- method: "POST",
2338
- headers: { "Content-Type": "application/json" },
2339
- body: JSON.stringify({ external_user_id, tier_slug, reason })
2340
- }),
2341
- null,
2342
- 2
2343
- )
2344
- }]
2769
+ content: [
2770
+ {
2771
+ type: "text",
2772
+ text: JSON.stringify(
2773
+ await apiCall("/v1/admin/rewards/set-tier", {
2774
+ method: "POST",
2775
+ headers: { "Content-Type": "application/json" },
2776
+ body: JSON.stringify({ external_user_id, tier_slug, reason })
2777
+ }),
2778
+ null,
2779
+ 2
2780
+ )
2781
+ }
2782
+ ]
2345
2783
  })
2346
2784
  );
2347
2785
  server.resource(
@@ -2351,11 +2789,13 @@ function createMushiServer(config) {
2351
2789
  description: "Returns the privacy posture for this project: storage region, LLM provider, whether BYOK is configured, data retention window, and last audit timestamp."
2352
2790
  },
2353
2791
  async () => ({
2354
- contents: [{
2355
- uri: "privacy://status",
2356
- mimeType: "application/json",
2357
- text: JSON.stringify(await apiCall("/v1/admin/privacy-status"), null, 2)
2358
- }]
2792
+ contents: [
2793
+ {
2794
+ uri: "privacy://status",
2795
+ mimeType: "application/json",
2796
+ text: JSON.stringify(await apiCall("/v1/admin/privacy-status"), null, 2)
2797
+ }
2798
+ ]
2359
2799
  })
2360
2800
  );
2361
2801
  server.resource(
@@ -2365,15 +2805,19 @@ function createMushiServer(config) {
2365
2805
  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."
2366
2806
  },
2367
2807
  async () => ({
2368
- contents: [{
2369
- uri: "evolution://history",
2370
- mimeType: "application/json",
2371
- text: projectId ? JSON.stringify(
2372
- await apiCall(`/v1/admin/projects/${encodeURIComponent(projectId)}/evolution-history`),
2373
- null,
2374
- 2
2375
- ) : JSON.stringify({ ok: false, error: "project_id required" })
2376
- }]
2808
+ contents: [
2809
+ {
2810
+ uri: "evolution://history",
2811
+ mimeType: "application/json",
2812
+ text: projectId ? JSON.stringify(
2813
+ await apiCall(
2814
+ `/v1/admin/projects/${encodeURIComponent(projectId)}/evolution-history`
2815
+ ),
2816
+ null,
2817
+ 2
2818
+ ) : JSON.stringify({ ok: false, error: "project_id required" })
2819
+ }
2820
+ ]
2377
2821
  })
2378
2822
  );
2379
2823
  server.resource(
@@ -2383,11 +2827,13 @@ function createMushiServer(config) {
2383
2827
  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."
2384
2828
  },
2385
2829
  async () => ({
2386
- contents: [{
2387
- uri: "project://integration-health",
2388
- mimeType: "application/json",
2389
- text: JSON.stringify(await apiCall("/v1/admin/integrations/health"), null, 2)
2390
- }]
2830
+ contents: [
2831
+ {
2832
+ uri: "project://integration-health",
2833
+ mimeType: "application/json",
2834
+ text: JSON.stringify(await apiCall("/v1/admin/integrations/health"), null, 2)
2835
+ }
2836
+ ]
2391
2837
  })
2392
2838
  );
2393
2839
  server.resource(
@@ -2399,11 +2845,13 @@ function createMushiServer(config) {
2399
2845
  async () => {
2400
2846
  const path = projectId ? `/v1/admin/inventory/${projectId}` : "/v1/admin/inventory";
2401
2847
  return {
2402
- contents: [{
2403
- uri: "inventory://current",
2404
- mimeType: "application/json",
2405
- text: JSON.stringify(await apiCall(path), null, 2)
2406
- }]
2848
+ contents: [
2849
+ {
2850
+ uri: "inventory://current",
2851
+ mimeType: "application/json",
2852
+ text: JSON.stringify(await apiCall(path), null, 2)
2853
+ }
2854
+ ]
2407
2855
  };
2408
2856
  }
2409
2857
  );
@@ -2432,11 +2880,13 @@ function createMushiServer(config) {
2432
2880
  async () => {
2433
2881
  const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
2434
2882
  return {
2435
- contents: [{
2436
- uri: "mushi://activation",
2437
- mimeType: "application/json",
2438
- text: JSON.stringify(await apiCall(`/v1/admin/activation${qs}`), null, 2)
2439
- }]
2883
+ contents: [
2884
+ {
2885
+ uri: "mushi://activation",
2886
+ mimeType: "application/json",
2887
+ text: JSON.stringify(await apiCall(`/v1/admin/activation${qs}`), null, 2)
2888
+ }
2889
+ ]
2440
2890
  };
2441
2891
  }
2442
2892
  );
@@ -2445,11 +2895,12 @@ function createMushiServer(config) {
2445
2895
  "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.",
2446
2896
  { reportId: z.string().describe("The report UUID to summarize") },
2447
2897
  ({ reportId }) => ({
2448
- messages: [{
2449
- role: "user",
2450
- content: {
2451
- type: "text",
2452
- text: `You are a senior engineer preparing a fix plan. Use the Mushi MCP tools to:
2898
+ messages: [
2899
+ {
2900
+ role: "user",
2901
+ content: {
2902
+ type: "text",
2903
+ text: `You are a senior engineer preparing a fix plan. Use the Mushi MCP tools to:
2453
2904
  1. Call get_fix_context for reportId "${reportId}".
2454
2905
  2. Call get_blast_radius if the report has a component node id.
2455
2906
  3. Call get_similar_bugs with the component or summary as the query.
@@ -2462,8 +2913,9 @@ Then produce a markdown fix plan with exactly these sections:
2462
2913
  - Confidence (low/medium/high) with a one-line justification
2463
2914
 
2464
2915
  Lead with the fix. Skip the preamble.`
2916
+ }
2465
2917
  }
2466
- }]
2918
+ ]
2467
2919
  })
2468
2920
  );
2469
2921
  server.prompt(
@@ -2471,11 +2923,12 @@ Lead with the fix. Skip the preamble.`
2471
2923
  "Turn raw Sonnet-as-Judge scores into ship / iterate / dismiss guidance. Use after a fix attempt has been judged.",
2472
2924
  { fixId: z.string().describe("The fix_attempt UUID to explain") },
2473
2925
  ({ fixId }) => ({
2474
- messages: [{
2475
- role: "user",
2476
- content: {
2477
- type: "text",
2478
- text: `You are a release engineer. Use the Mushi MCP tools:
2926
+ messages: [
2927
+ {
2928
+ role: "user",
2929
+ content: {
2930
+ type: "text",
2931
+ text: `You are a release engineer. Use the Mushi MCP tools:
2479
2932
  1. Call get_fix_timeline for fixId "${fixId}" to see the full PDCA journey.
2480
2933
 
2481
2934
  Then write a short verdict in this format:
@@ -2484,8 +2937,9 @@ Then write a short verdict in this format:
2484
2937
  - **If iterate:** bullet list of the smallest next patch
2485
2938
 
2486
2939
  No preamble, no score-by-score recap \u2014 the numbers are in the tool output.`
2940
+ }
2487
2941
  }
2488
- }]
2942
+ ]
2489
2943
  })
2490
2944
  );
2491
2945
  server.prompt(
@@ -2493,11 +2947,12 @@ No preamble, no score-by-score recap \u2014 the numbers are in the tool output.`
2493
2947
  'Answer "what should I focus on right now?" \u2014 five-item markdown list drawn from the dashboard + recent classified queue.',
2494
2948
  {},
2495
2949
  () => ({
2496
- messages: [{
2497
- role: "user",
2498
- content: {
2499
- type: "text",
2500
- text: `You are a tech lead looking at the Mushi cockpit. Use the MCP tools:
2950
+ messages: [
2951
+ {
2952
+ role: "user",
2953
+ content: {
2954
+ type: "text",
2955
+ text: `You are a tech lead looking at the Mushi cockpit. Use the MCP tools:
2501
2956
  1. Read the project://dashboard resource for the PDCA snapshot.
2502
2957
  2. Call get_recent_reports with status="classified", limit=10.
2503
2958
 
@@ -2505,8 +2960,9 @@ Then output exactly 5 bullets, in priority order, each formatted as:
2505
2960
  \`**Action** \u2014 why it matters \u2014 suggested tool call\`
2506
2961
 
2507
2962
  Prefer items that are bottlenecks or critical severity. Skip filler.`
2963
+ }
2508
2964
  }
2509
- }]
2965
+ ]
2510
2966
  })
2511
2967
  );
2512
2968
  server.prompt(
@@ -2514,11 +2970,12 @@ Prefer items that are bottlenecks or critical severity. Skip filler.`
2514
2970
  "Diagnose why Mushi setup is stuck and return the single next command or console step to unblock it.",
2515
2971
  {},
2516
2972
  () => ({
2517
- messages: [{
2518
- role: "user",
2519
- content: {
2520
- type: "text",
2521
- text: `You are a Mushi onboarding copilot. Use the MCP tools:
2973
+ messages: [
2974
+ {
2975
+ role: "user",
2976
+ content: {
2977
+ type: "text",
2978
+ text: `You are a Mushi onboarding copilot. Use the MCP tools:
2522
2979
  1. Read mushi://activation for unified setup posture.
2523
2980
  2. Read project://integration-health if GitHub or Sentry looks blocked.
2524
2981
  3. Call diagnose_setup if the resource is unavailable.
@@ -2530,8 +2987,9 @@ Then output:
2530
2987
  - **If still stuck:** one diagnostic command (e.g. mushi doctor --fix)
2531
2988
 
2532
2989
  Be specific. No generic advice.`
2990
+ }
2533
2991
  }
2534
- }]
2992
+ ]
2535
2993
  })
2536
2994
  );
2537
2995
  server.registerTool(
@@ -2552,7 +3010,15 @@ Be specific. No generic advice.`
2552
3010
  if (!projectId2) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required");
2553
3011
  const data = await apiCall(
2554
3012
  `/v1/admin/inventory/${projectId2}/map-from-live`,
2555
- { method: "POST", body: JSON.stringify({ base_url: baseUrl, max_pages: maxPages, provider, cursor_cloud_refine: cursorCloudRefine }) }
3013
+ {
3014
+ method: "POST",
3015
+ body: JSON.stringify({
3016
+ base_url: baseUrl,
3017
+ max_pages: maxPages,
3018
+ provider,
3019
+ cursor_cloud_refine: cursorCloudRefine
3020
+ })
3021
+ }
2556
3022
  );
2557
3023
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2558
3024
  }
@@ -2580,7 +3046,9 @@ Be specific. No generic advice.`
2580
3046
  annotations: annotationsFor("generate_tdd_from_story", TDD_TOOL_CATALOG),
2581
3047
  inputSchema: {
2582
3048
  projectId: z.string().describe("Project UUID that owns the inventory story"),
2583
- storyNodeId: z.string().describe("User story node id/slug from accepted inventory (from map_user_stories or inventory.yaml)"),
3049
+ storyNodeId: z.string().describe(
3050
+ "User story node id/slug from accepted inventory (from map_user_stories or inventory.yaml)"
3051
+ ),
2584
3052
  automationMode: z.enum(["auto", "review", "approve"]).optional().describe("Approval gate for the generated qa_story (default review \u2192 pending_review)"),
2585
3053
  baseUrl: z.string().url().optional().describe("Override the live app URL the Playwright test should target"),
2586
3054
  openPr: z.boolean().optional().describe("Open a draft GitHub PR with the generated spec (default true)")
@@ -2594,10 +3062,14 @@ Be specific. No generic advice.`
2594
3062
  },
2595
3063
  async ({ projectId: projectId2, storyNodeId, automationMode, baseUrl, openPr }) => {
2596
3064
  if (!projectId2) throw new MushiApiError(400, "MISSING_PROJECT", "projectId is required");
2597
- const data = await apiCall(
2598
- `/v1/admin/inventory/${projectId2}/stories/${storyNodeId}/generate-test`,
2599
- { method: "POST", body: JSON.stringify({ automation_mode: automationMode, base_url: baseUrl, open_pr: openPr }) }
2600
- );
3065
+ const data = await apiCall(`/v1/admin/inventory/${projectId2}/stories/${storyNodeId}/generate-test`, {
3066
+ method: "POST",
3067
+ body: JSON.stringify({
3068
+ automation_mode: automationMode,
3069
+ base_url: baseUrl,
3070
+ open_pr: openPr
3071
+ })
3072
+ });
2601
3073
  return jsonResult(data);
2602
3074
  }
2603
3075
  );
@@ -2612,10 +3084,10 @@ Be specific. No generic advice.`
2612
3084
  }
2613
3085
  },
2614
3086
  async ({ projectId: projectId2 }) => {
2615
- const data = await apiCall(
2616
- "/v1/admin/pdca/improve-qa-stories",
2617
- { method: "POST", body: JSON.stringify({ project_id: projectId2 }) }
2618
- );
3087
+ const data = await apiCall("/v1/admin/pdca/improve-qa-stories", {
3088
+ method: "POST",
3089
+ body: JSON.stringify({ project_id: projectId2 })
3090
+ });
2619
3091
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2620
3092
  }
2621
3093
  );
@@ -2649,7 +3121,9 @@ Be specific. No generic advice.`
2649
3121
  }
2650
3122
  },
2651
3123
  async ({ projectId: projectId2 }) => {
2652
- const data = await apiCall(`/v1/admin/byok/keys?project_id=${encodeURIComponent(projectId2)}`);
3124
+ const data = await apiCall(
3125
+ `/v1/admin/byok/keys?project_id=${encodeURIComponent(projectId2)}`
3126
+ );
2653
3127
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2654
3128
  }
2655
3129
  );
@@ -2662,16 +3136,60 @@ Be specific. No generic advice.`
2662
3136
  inputSchema: {
2663
3137
  projectId: z.string().describe("Project id"),
2664
3138
  provider: z.enum(["anthropic", "openai", "firecrawl", "browserbase", "cursor"]).describe("Provider slug"),
2665
- key: z.string().min(10).describe("The API key value to add"),
3139
+ key: z.string().min(8).max(4096).describe("The API key value to add"),
2666
3140
  label: z.string().optional().describe("Human-readable label for this key"),
2667
- priority: z.number().int().min(1).max(999).optional().describe("Priority for ordering (lower = higher priority)")
3141
+ priority: z.number().int().min(0).max(1e4).optional().describe("Priority for ordering (lower = higher priority)"),
3142
+ baseUrl: z.string().url().max(2048).optional().describe("Allow-listed OpenAI-compatible HTTPS base URL")
2668
3143
  }
2669
3144
  },
2670
- async ({ projectId: projectId2, provider, key, label, priority }) => {
2671
- const data = await apiCall(
2672
- "/v1/admin/byok/keys",
2673
- { method: "POST", body: JSON.stringify({ project_id: projectId2, provider_slug: provider, key, label, priority }) }
2674
- );
3145
+ async ({ projectId: projectId2, provider, key, label, priority, baseUrl }) => {
3146
+ const data = await apiCall("/v1/admin/byok/keys", {
3147
+ method: "POST",
3148
+ body: JSON.stringify({
3149
+ project_id: projectId2,
3150
+ provider_slug: provider,
3151
+ key,
3152
+ label,
3153
+ priority,
3154
+ base_url: baseUrl
3155
+ })
3156
+ });
3157
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
3158
+ }
3159
+ );
3160
+ server.registerTool(
3161
+ "test_byok_key",
3162
+ {
3163
+ title: titleOf("test_byok_key", TDD_TOOL_CATALOG),
3164
+ description: descOf("test_byok_key", TDD_TOOL_CATALOG),
3165
+ annotations: annotationsFor("test_byok_key", TDD_TOOL_CATALOG),
3166
+ inputSchema: {
3167
+ projectId: z.string().describe("Project id"),
3168
+ keyId: z.string().uuid().describe("Pooled BYOK key id")
3169
+ }
3170
+ },
3171
+ async ({ keyId }) => {
3172
+ const data = await apiCall(`/v1/admin/byok/keys/${encodeURIComponent(keyId)}/test`, {
3173
+ method: "POST"
3174
+ });
3175
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
3176
+ }
3177
+ );
3178
+ server.registerTool(
3179
+ "remove_byok_key",
3180
+ {
3181
+ title: titleOf("remove_byok_key", TDD_TOOL_CATALOG),
3182
+ description: descOf("remove_byok_key", TDD_TOOL_CATALOG),
3183
+ annotations: annotationsFor("remove_byok_key", TDD_TOOL_CATALOG),
3184
+ inputSchema: {
3185
+ projectId: z.string().describe("Project id"),
3186
+ keyId: z.string().uuid().describe("Pooled BYOK key UUID from list_byok_keys")
3187
+ }
3188
+ },
3189
+ async ({ keyId }) => {
3190
+ const data = await apiCall(`/v1/admin/byok/keys/${encodeURIComponent(keyId)}`, {
3191
+ method: "DELETE"
3192
+ });
2675
3193
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2676
3194
  }
2677
3195
  );
@@ -2686,7 +3204,9 @@ Be specific. No generic advice.`
2686
3204
  }
2687
3205
  },
2688
3206
  async ({ projectId: projectId2 }) => {
2689
- const data = await apiCall(`/v1/admin/inventory/${projectId2}/stories/pending-review`);
3207
+ const data = await apiCall(
3208
+ `/v1/admin/inventory/${projectId2}/stories/pending-review`
3209
+ );
2690
3210
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2691
3211
  }
2692
3212
  );
@@ -2723,13 +3243,10 @@ Be specific. No generic advice.`
2723
3243
  }
2724
3244
  },
2725
3245
  async ({ reportId, message, authorName }) => {
2726
- const data = await apiCall(
2727
- `/v1/sync/reports/${reportId}/reply`,
2728
- {
2729
- method: "POST",
2730
- body: JSON.stringify({ message, author_name: authorName })
2731
- }
2732
- );
3246
+ const data = await apiCall(`/v1/sync/reports/${reportId}/reply`, {
3247
+ method: "POST",
3248
+ body: JSON.stringify({ message, author_name: authorName })
3249
+ });
2733
3250
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2734
3251
  }
2735
3252
  );
@@ -2808,10 +3325,10 @@ Be specific. No generic advice.`
2808
3325
  async ({ project_id }) => {
2809
3326
  const pid = project_id ?? config.projectId;
2810
3327
  if (!pid) throw new MushiApiError(400, "MISSING_PROJECT_ID", "project_id is required");
2811
- const data = await apiCall(
2812
- `/v1/admin/projects/${pid}/audit`,
2813
- { method: "POST", body: "{}" }
2814
- );
3328
+ const data = await apiCall(`/v1/admin/projects/${pid}/audit`, {
3329
+ method: "POST",
3330
+ body: "{}"
3331
+ });
2815
3332
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
2816
3333
  }
2817
3334
  );
@@ -2907,17 +3424,19 @@ Be specific. No generic advice.`
2907
3424
  inputSchema: {
2908
3425
  root_skill_slug: z.string().describe('Root skill slug to run, e.g. "workflow-fix-and-ship"'),
2909
3426
  report_id: z.string().optional().describe("Report UUID to attach the pipeline to"),
2910
- mode: z.enum(["handoff", "cloud"]).optional().describe("handoff (default): get context packet for local agent. cloud: auto-dispatch via Cursor Cloud."),
3427
+ mode: z.enum(["handoff", "cloud"]).optional().describe(
3428
+ "handoff (default): get context packet for local agent. cloud: auto-dispatch via Cursor Cloud."
3429
+ ),
2911
3430
  project_id: z.string().optional().describe("Project UUID. Falls back to the configured project.")
2912
3431
  }
2913
3432
  },
2914
3433
  async (args) => {
2915
3434
  const resolvedProjectId = args.project_id ?? projectId;
2916
3435
  if (!resolvedProjectId) return jsonText({ error: "No project_id provided or configured." });
2917
- const data = await apiCall(
2918
- "/v1/admin/skills/pipelines",
2919
- { method: "POST", body: JSON.stringify({ ...args, project_id: resolvedProjectId }) }
2920
- );
3436
+ const data = await apiCall("/v1/admin/skills/pipelines", {
3437
+ method: "POST",
3438
+ body: JSON.stringify({ ...args, project_id: resolvedProjectId })
3439
+ });
2921
3440
  return jsonText(data);
2922
3441
  }
2923
3442
  );
@@ -2953,10 +3472,10 @@ Be specific. No generic advice.`
2953
3472
  },
2954
3473
  async (args) => {
2955
3474
  const { run_id, step_index, ...body } = args;
2956
- await apiCall(
2957
- `/v1/admin/skills/pipelines/${run_id}/steps/${step_index}/checkin`,
2958
- { method: "POST", body: JSON.stringify(body) }
2959
- );
3475
+ await apiCall(`/v1/admin/skills/pipelines/${run_id}/steps/${step_index}/checkin`, {
3476
+ method: "POST",
3477
+ body: JSON.stringify(body)
3478
+ });
2960
3479
  return jsonText({ ok: true, message: `Step ${step_index} \u2192 ${args.status}` });
2961
3480
  }
2962
3481
  );
@@ -3124,7 +3643,11 @@ Be specific. No generic advice.`
3124
3643
  if (args.compare) qs.set("compare", args.compare);
3125
3644
  if (args.fix_id) qs.set("fix_id", args.fix_id);
3126
3645
  if (!qs.toString()) {
3127
- throw new MushiApiError(400, "BAD_REQUEST", "Provide paths, source=last_push, compare, or fix_id");
3646
+ throw new MushiApiError(
3647
+ 400,
3648
+ "BAD_REQUEST",
3649
+ "Provide paths, source=last_push, compare, or fix_id"
3650
+ );
3128
3651
  }
3129
3652
  const data = await apiCall(`/v1/admin/projects/${pid}/codebase/impact?${qs}`);
3130
3653
  return jsonResult(data);
@@ -3176,7 +3699,7 @@ Be specific. No generic advice.`
3176
3699
  "### First step",
3177
3700
  `Call \`${cluster.tools[0]}\` to get started.`,
3178
3701
  "",
3179
- "> 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."
3702
+ "> 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."
3180
3703
  ].join("\n");
3181
3704
  return { content: [{ type: "text", text: orientation }] };
3182
3705
  }
@@ -3185,11 +3708,7 @@ Be specific. No generic advice.`
3185
3708
  const featureFilter = config.features ?? "all";
3186
3709
  if (grantedScopes !== void 0 || featureFilter !== "all") {
3187
3710
  const toolRegistry = server._registeredTools;
3188
- const allSpecs = [
3189
- ...TOOL_CATALOG,
3190
- ...TDD_TOOL_CATALOG,
3191
- ...CODEBASE_TOOL_CATALOG
3192
- ];
3711
+ const allSpecs = [...TOOL_CATALOG, ...TDD_TOOL_CATALOG, ...CODEBASE_TOOL_CATALOG];
3193
3712
  for (const spec of allSpecs) {
3194
3713
  if (grantedScopes !== void 0 && !grantedScopes.includes(spec.scope)) {
3195
3714
  toolRegistry[spec.name]?.remove();
@@ -3263,28 +3782,30 @@ Be specific. No generic advice.`
3263
3782
 
3264
3783
  // src/index.ts
3265
3784
  import * as Sentry from "@sentry/node";
3266
- var _writeStderr = (...args) => process.stderr.write(
3267
- args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n"
3268
- );
3269
- console.log = _writeStderr;
3270
- console.warn = _writeStderr;
3785
+ installStdoutGuard();
3271
3786
  var require2 = createRequire(import.meta.url);
3272
3787
  var VERSION = require2("../package.json").version;
3273
3788
  var log = createLogger2({ scope: "mushi:mcp", level: "info", destination: "stderr" });
3789
+ function resolveCliConfigPath() {
3790
+ const xdg = process.env.XDG_CONFIG_HOME;
3791
+ const appData = process.env.APPDATA;
3792
+ const base = xdg && xdg.length > 0 ? xdg : process.platform === "win32" && appData && appData.length > 0 ? appData : join(homedir(), ".config");
3793
+ return join(base, "mushi", "config.json");
3794
+ }
3274
3795
  function readCliConfig() {
3796
+ const configPath = resolveCliConfigPath();
3275
3797
  try {
3276
- const xdg = process.env.XDG_CONFIG_HOME;
3277
- const appData = process.env.APPDATA;
3278
- const base = xdg && xdg.length > 0 ? xdg : process.platform === "win32" && appData && appData.length > 0 ? appData : join(homedir(), ".config");
3279
- const raw = readFileSync(join(base, "mushi", "config.json"), "utf8");
3798
+ const raw = readFileSync(configPath, "utf8");
3280
3799
  const parsed = JSON.parse(raw);
3281
3800
  return {
3282
3801
  apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0,
3283
3802
  projectId: typeof parsed.projectId === "string" ? parsed.projectId : void 0,
3284
- endpoint: typeof parsed.endpoint === "string" ? parsed.endpoint : void 0
3803
+ endpoint: typeof parsed.endpoint === "string" ? parsed.endpoint : void 0,
3804
+ path: configPath,
3805
+ found: true
3285
3806
  };
3286
3807
  } catch {
3287
- return {};
3808
+ return { path: configPath, found: false };
3288
3809
  }
3289
3810
  }
3290
3811
  var CLI_CONFIG = readCliConfig();
@@ -3303,11 +3824,49 @@ if (MCP_SENTRY_DSN) {
3303
3824
  tracesSampleRate: 0
3304
3825
  });
3305
3826
  }
3827
+ function missingApiKeyReport() {
3828
+ const envState = process.env.MUSHI_API_KEY === void 0 ? "not set" : process.env.MUSHI_API_KEY.trim() === "" ? "set but empty" : "set";
3829
+ const cliState = !CLI_CONFIG.found ? "no file at this path" : CLI_CONFIG.apiKey ? "present" : 'file exists but has no "apiKey" field';
3830
+ return [
3831
+ "",
3832
+ "[mushi-mcp] FATAL: no API key \u2014 the MCP server cannot serve a single tool call.",
3833
+ "",
3834
+ " Sources checked, in precedence order:",
3835
+ ` 1. env MUSHI_API_KEY \u2192 ${envState}`,
3836
+ ` 2. CLI config file \u2192 ${cliState}`,
3837
+ ` ${CLI_CONFIG.path}`,
3838
+ "",
3839
+ " Fix either one:",
3840
+ " \u2022 Run `mushi login` (writes the config file above), or",
3841
+ ' \u2022 Add the key to the "env" block of your MCP client config',
3842
+ " (.cursor/mcp.json \xB7 claude_desktop_config.json \xB7 .vscode/mcp.json):",
3843
+ "",
3844
+ ' { "mcpServers": { "mushi-mushi": {',
3845
+ ' "command": "npx", "args": ["-y", "@mushi-mushi/mcp"],',
3846
+ ' "env": { "MUSHI_API_KEY": "mushi_\u2026", "MUSHI_PROJECT_ID": "<uuid>" } } } }',
3847
+ "",
3848
+ " A key exported in your shell does NOT reach the server: MCP clients",
3849
+ " spawn this process with only the env block they are given.",
3850
+ "",
3851
+ " Other env vars this server reads:",
3852
+ ` MUSHI_API_ENDPOINT ${process.env.MUSHI_API_ENDPOINT?.trim() ? "= " + process.env.MUSHI_API_ENDPOINT.trim() : `unset \u2192 ${API_ENDPOINT}`}`,
3853
+ ` MUSHI_PROJECT_ID ${PROJECT_ID ? "= " + PROJECT_ID : "unset (account mode)"}`,
3854
+ " MUSHI_SCOPES optional CSV: mcp:read,mcp:write",
3855
+ ' MUSHI_FEATURES optional CSV of tool groups, or "all"',
3856
+ " MUSHI_MCP_TIMEOUT_MS optional per-request timeout in ms (default 15000)",
3857
+ "",
3858
+ " Mint a key: Console \u2192 Settings \u2192 API keys.",
3859
+ ""
3860
+ ].join("\n");
3861
+ }
3306
3862
  async function main() {
3307
3863
  if (!API_KEY) {
3308
- log.fatal(
3309
- "No API key found \u2014 set MUSHI_API_KEY, or run `mushi login` so the CLI config can supply it."
3310
- );
3864
+ process.stderr.write(missingApiKeyReport());
3865
+ log.fatal("No API key found \u2014 set MUSHI_API_KEY, or run `mushi login`.", {
3866
+ cliConfigPath: CLI_CONFIG.path,
3867
+ cliConfigFound: CLI_CONFIG.found,
3868
+ endpoint: API_ENDPOINT
3869
+ });
3311
3870
  process.exit(1);
3312
3871
  }
3313
3872
  if (!process.env.MUSHI_API_KEY && CLI_CONFIG.apiKey) {
@@ -3354,6 +3913,7 @@ async function main() {
3354
3913
  if (pollTimer) clearInterval(pollTimer);
3355
3914
  void transport.close().finally(() => process.exit(exitCode));
3356
3915
  };
3916
+ setActiveShutdown(shutdown);
3357
3917
  process.stdin.on("end", () => shutdown(0));
3358
3918
  process.stdin.on("close", () => shutdown(0));
3359
3919
  process.on("SIGINT", () => shutdown(0));
@@ -3400,6 +3960,49 @@ async function main() {
3400
3960
  pollTimer.unref();
3401
3961
  }
3402
3962
  }
3963
+ var activeShutdown;
3964
+ var setActiveShutdown = (fn) => {
3965
+ activeShutdown = fn;
3966
+ };
3967
+ var exitNow = (code) => {
3968
+ if (activeShutdown) activeShutdown(code);
3969
+ else process.exit(code);
3970
+ };
3971
+ var CRASH_WINDOW_MS = 1e4;
3972
+ var CRASH_LIMIT = 20;
3973
+ var crashTimes = [];
3974
+ function isCrashLooping() {
3975
+ const now = Date.now();
3976
+ crashTimes = crashTimes.filter((t) => now - t < CRASH_WINDOW_MS);
3977
+ crashTimes.push(now);
3978
+ return crashTimes.length >= CRASH_LIMIT;
3979
+ }
3980
+ process.on("unhandledRejection", (reason) => {
3981
+ log.error("unhandledRejection \u2014 server kept alive", {
3982
+ err: reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason),
3983
+ stack: reason instanceof Error ? reason.stack?.split("\n").slice(0, 5).join(" | ") : void 0
3984
+ });
3985
+ if (isCrashLooping()) {
3986
+ log.fatal("too many unhandled rejections in 10s \u2014 exiting so the client can respawn");
3987
+ exitNow(1);
3988
+ }
3989
+ });
3990
+ process.on("uncaughtException", (err) => {
3991
+ if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED" || err?.code === "ERR_STREAM_WRITE_AFTER_END") {
3992
+ log.info("stdio pipe closed by the client \u2014 shutting down", { code: err.code });
3993
+ exitNow(0);
3994
+ return;
3995
+ }
3996
+ log.error("uncaughtException \u2014 server kept alive", {
3997
+ err: `${err?.name ?? "Error"}: ${err?.message ?? String(err)}`,
3998
+ code: err?.code,
3999
+ stack: err?.stack?.split("\n").slice(0, 5).join(" | ")
4000
+ });
4001
+ if (isCrashLooping()) {
4002
+ log.fatal("too many uncaught exceptions in 10s \u2014 exiting so the client can respawn");
4003
+ exitNow(1);
4004
+ }
4005
+ });
3403
4006
  main().catch((err) => {
3404
4007
  log.fatal("MCP server crashed", { err: String(err) });
3405
4008
  process.exit(1);