@mushi-mushi/mcp 0.20.1 → 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",
@@ -546,24 +620,29 @@ var TDD_TOOL_CATALOG = [
546
620
  }
547
621
  ];
548
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.
549
628
  fix: {
550
629
  label: "Fix a bug",
551
630
  tools: [
552
631
  "get_recent_reports",
553
- "get_report",
554
- "summarize_report_for_fix",
632
+ "get_report_detail",
633
+ "triage_issue",
555
634
  "dispatch_fix",
556
635
  "start_skill_pipeline",
557
636
  "checkin_pipeline_step",
558
637
  "get_pipeline_run"
559
638
  ],
560
- 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."
561
640
  },
562
641
  status: {
563
642
  label: "Check project status",
564
643
  tools: [
565
- "get_dashboard",
566
644
  "triage_next_steps",
645
+ "get_account_overview",
567
646
  "get_usage",
568
647
  "get_backend_health",
569
648
  "activation_status"
@@ -573,7 +652,7 @@ var USE_MUSHI_INTENTS = {
573
652
  setup: {
574
653
  label: "Set up Mushi",
575
654
  tools: [
576
- "mushi_setup",
655
+ "diagnose_setup",
577
656
  "activation_status",
578
657
  "get_backend_health",
579
658
  "list_byok_keys",
@@ -581,12 +660,11 @@ var USE_MUSHI_INTENTS = {
581
660
  "test_byok_key",
582
661
  "remove_byok_key"
583
662
  ],
584
- 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."
585
664
  },
586
665
  qa: {
587
666
  label: "Run / review QA tests",
588
667
  tools: [
589
- "list_qa_stories",
590
668
  "run_qa_story",
591
669
  "list_qa_story_runs",
592
670
  "get_qa_story_run",
@@ -594,7 +672,7 @@ var USE_MUSHI_INTENTS = {
594
672
  "approve_qa_story",
595
673
  "improve_qa_story"
596
674
  ],
597
- 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."
598
676
  },
599
677
  pipeline: {
600
678
  label: "Run an agent pipeline / skill",
@@ -609,7 +687,7 @@ var USE_MUSHI_INTENTS = {
609
687
  },
610
688
  audit: {
611
689
  label: "Audit / health check",
612
- tools: ["run_fullstack_audit", "get_backend_health", "get_dashboard", "get_usage"],
690
+ tools: ["run_fullstack_audit", "get_backend_health", "get_account_overview", "get_usage"],
613
691
  hint: "Call run_fullstack_audit for a full-stack health scorecard."
614
692
  }
615
693
  };
@@ -981,7 +1059,40 @@ function wrapUntrustedJson(value, role) {
981
1059
  }
982
1060
 
983
1061
  // src/server.ts
1062
+ installStdoutGuard();
984
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
+ }
985
1096
  var MushiApiError = class extends Error {
986
1097
  constructor(status, code, message) {
987
1098
  super(`[${code}] ${message}`);
@@ -995,6 +1106,7 @@ var MushiApiError = class extends Error {
995
1106
  function createMushiServer(config) {
996
1107
  const { version, apiEndpoint, apiKey, projectId } = config;
997
1108
  const doFetch = config.fetch ?? globalThis.fetch;
1109
+ const timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : resolveTimeoutMsFromEnv();
998
1110
  const apiLog = createLogger({
999
1111
  scope: "mushi:mcp:api",
1000
1112
  level: process.env.MUSHI_LOG_LEVEL ?? "info",
@@ -1012,22 +1124,66 @@ function createMushiServer(config) {
1012
1124
  async function apiCall(path, options) {
1013
1125
  const requestId = crypto.randomUUID().slice(0, 12);
1014
1126
  const started = Date.now();
1015
- const res = await doFetch(`${apiEndpoint}${path}`, {
1016
- ...options,
1017
- headers: {
1018
- "Content-Type": "application/json",
1019
- // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
1020
- // still send it for endpoints still behind plain `jwtAuth` (legacy)
1021
- // and for transparent proxies that strip X-Mushi-* headers.
1022
- Authorization: `Bearer ${apiKey}`,
1023
- "X-Mushi-Api-Key": apiKey,
1024
- "X-Request-Id": requestId,
1025
- ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
1026
- ...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
+ );
1027
1164
  }
1028
- });
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
+ }
1029
1186
  const durationMs = Date.now() - started;
1030
- const text = await res.text();
1031
1187
  let body = null;
1032
1188
  if (text) {
1033
1189
  try {
@@ -1057,6 +1213,7 @@ function createMushiServer(config) {
1057
1213
  apiLog.info("api.done", { path, requestId, status: res.status, durationMs });
1058
1214
  return body;
1059
1215
  }
1216
+ let resolvedAccountProjectId = null;
1060
1217
  async function resolveProjectId(explicitId) {
1061
1218
  if (explicitId) {
1062
1219
  if (projectId && explicitId !== projectId) {
@@ -1069,10 +1226,12 @@ function createMushiServer(config) {
1069
1226
  return explicitId;
1070
1227
  }
1071
1228
  if (projectId) return projectId;
1229
+ if (resolvedAccountProjectId) return resolvedAccountProjectId;
1072
1230
  try {
1073
1231
  const data = await apiCall("/v1/admin/mcp/account-overview");
1074
1232
  if (data.total === 1 && data.projects[0]) {
1075
- return data.projects[0].id;
1233
+ resolvedAccountProjectId = data.projects[0].id;
1234
+ return resolvedAccountProjectId;
1076
1235
  }
1077
1236
  if (data.total === 0) {
1078
1237
  throw new MushiApiError(
@@ -1088,11 +1247,27 @@ function createMushiServer(config) {
1088
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.`
1089
1248
  );
1090
1249
  } catch (err) {
1091
- 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
+ }
1092
1267
  throw new MushiApiError(
1093
1268
  400,
1094
1269
  "MISSING_PROJECT_ID",
1095
- "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)})`
1096
1271
  );
1097
1272
  }
1098
1273
  }
@@ -1516,12 +1691,24 @@ function createMushiServer(config) {
1516
1691
  suggestedFix: z.unknown().describe("Stage-2 suggested fix, or null if not yet classified"),
1517
1692
  reproductionSteps: z.unknown().describe("Reproduction steps recorded on the report (array; [] if none)"),
1518
1693
  summary: z.unknown().describe("One-line report summary, or null"),
1519
- 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")
1520
1696
  }
1521
1697
  },
1522
1698
  async (args) => {
1523
1699
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`);
1524
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
+ }
1525
1712
  return jsonResult({
1526
1713
  reportId: args.reportId,
1527
1714
  rootCause: s2?.rootCause ?? null,
@@ -1952,12 +2139,31 @@ function createMushiServer(config) {
1952
2139
  const category = report ? report.category : "unknown";
1953
2140
  const status = report ? report.status : "unknown";
1954
2141
  const actions = [];
1955
- 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") {
1956
2162
  actions.push({
1957
2163
  action: "dispatch_fix",
1958
- reason: "Report is classified but no fix has been attempted",
2164
+ reason: "Report is classified and no fix has been attempted",
1959
2165
  tool: "dispatch_fix",
1960
- args: { reportId: args.report_id, agent: "cursor_cloud" }
2166
+ args: { reportId: args.report_id }
1961
2167
  });
1962
2168
  } else if (status === "fixing") {
1963
2169
  actions.push({
@@ -2035,7 +2241,7 @@ function createMushiServer(config) {
2035
2241
  linesChanged: z.number().describe("Total lines added + removed across filesChanged"),
2036
2242
  summary: z.string().describe("One-line human summary of what the fix changed"),
2037
2243
  idempotencyKey: z.string().uuid().optional().describe(
2038
- "Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows"
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."
2039
2245
  )
2040
2246
  },
2041
2247
  outputSchema: {
@@ -2044,27 +2250,123 @@ function createMushiServer(config) {
2044
2250
  }
2045
2251
  },
2046
2252
  async (args) => {
2047
- 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
+ );
2048
2259
  const created = await apiCall("/v1/admin/fixes", {
2049
2260
  method: "POST",
2050
2261
  headers: { "Idempotency-Key": idemKey },
2051
2262
  body: JSON.stringify({ reportId: args.reportId, agent: "mcp" })
2052
2263
  });
2053
- await apiCall(`/v1/admin/fixes/${created.fixId}`, {
2054
- method: "PATCH",
2055
- body: JSON.stringify({
2056
- status: "completed",
2057
- branch: args.branch,
2058
- pr_url: args.prUrl,
2059
- files_changed: args.filesChanged,
2060
- lines_changed: args.linesChanged,
2061
- summary: args.summary,
2062
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
2063
- })
2064
- });
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
+ }
2065
2285
  return jsonResult({ ok: true, fixId: created.fixId });
2066
2286
  }
2067
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
+ );
2068
2370
  server.registerTool(
2069
2371
  "dispatch_fix",
2070
2372
  {
@@ -2101,7 +2403,7 @@ function createMushiServer(config) {
2101
2403
  } catch {
2102
2404
  }
2103
2405
  }
2104
- const data = await apiCall("/v1/admin/fixes/dispatch", {
2406
+ const dispatch = await apiCall("/v1/admin/fixes/dispatch", {
2105
2407
  method: "POST",
2106
2408
  headers: args.idempotencyKey ? { "Idempotency-Key": args.idempotencyKey } : void 0,
2107
2409
  body: JSON.stringify({
@@ -2111,7 +2413,12 @@ function createMushiServer(config) {
2111
2413
  ...projectId ? { projectId } : {}
2112
2414
  })
2113
2415
  });
2114
- 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" });
2115
2422
  }
2116
2423
  );
2117
2424
  server.registerTool(
@@ -2246,6 +2553,11 @@ function createMushiServer(config) {
2246
2553
  status: z.enum([
2247
2554
  "pending",
2248
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",
2249
2561
  "grouped",
2250
2562
  "fixing",
2251
2563
  "fixed",
@@ -2253,7 +2565,7 @@ function createMushiServer(config) {
2253
2565
  "verified",
2254
2566
  "reopened",
2255
2567
  "dismissed"
2256
- ]).describe("Target status"),
2568
+ ]).describe("Target status (resolved is stored as fixed)"),
2257
2569
  reason: z.string().optional().describe("Reason for the transition (audit trail)")
2258
2570
  }
2259
2571
  },
@@ -3470,28 +3782,30 @@ Be specific. No generic advice.`
3470
3782
 
3471
3783
  // src/index.ts
3472
3784
  import * as Sentry from "@sentry/node";
3473
- var _writeStderr = (...args) => process.stderr.write(
3474
- args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n"
3475
- );
3476
- console.log = _writeStderr;
3477
- console.warn = _writeStderr;
3785
+ installStdoutGuard();
3478
3786
  var require2 = createRequire(import.meta.url);
3479
3787
  var VERSION = require2("../package.json").version;
3480
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
+ }
3481
3795
  function readCliConfig() {
3796
+ const configPath = resolveCliConfigPath();
3482
3797
  try {
3483
- const xdg = process.env.XDG_CONFIG_HOME;
3484
- const appData = process.env.APPDATA;
3485
- const base = xdg && xdg.length > 0 ? xdg : process.platform === "win32" && appData && appData.length > 0 ? appData : join(homedir(), ".config");
3486
- const raw = readFileSync(join(base, "mushi", "config.json"), "utf8");
3798
+ const raw = readFileSync(configPath, "utf8");
3487
3799
  const parsed = JSON.parse(raw);
3488
3800
  return {
3489
3801
  apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : void 0,
3490
3802
  projectId: typeof parsed.projectId === "string" ? parsed.projectId : void 0,
3491
- 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
3492
3806
  };
3493
3807
  } catch {
3494
- return {};
3808
+ return { path: configPath, found: false };
3495
3809
  }
3496
3810
  }
3497
3811
  var CLI_CONFIG = readCliConfig();
@@ -3510,11 +3824,49 @@ if (MCP_SENTRY_DSN) {
3510
3824
  tracesSampleRate: 0
3511
3825
  });
3512
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
+ }
3513
3862
  async function main() {
3514
3863
  if (!API_KEY) {
3515
- log.fatal(
3516
- "No API key found \u2014 set MUSHI_API_KEY, or run `mushi login` so the CLI config can supply it."
3517
- );
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
+ });
3518
3870
  process.exit(1);
3519
3871
  }
3520
3872
  if (!process.env.MUSHI_API_KEY && CLI_CONFIG.apiKey) {
@@ -3561,6 +3913,7 @@ async function main() {
3561
3913
  if (pollTimer) clearInterval(pollTimer);
3562
3914
  void transport.close().finally(() => process.exit(exitCode));
3563
3915
  };
3916
+ setActiveShutdown(shutdown);
3564
3917
  process.stdin.on("end", () => shutdown(0));
3565
3918
  process.stdin.on("close", () => shutdown(0));
3566
3919
  process.on("SIGINT", () => shutdown(0));
@@ -3607,6 +3960,49 @@ async function main() {
3607
3960
  pollTimer.unref();
3608
3961
  }
3609
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
+ });
3610
4006
  main().catch((err) => {
3611
4007
  log.fatal("MCP server crashed", { err: String(err) });
3612
4008
  process.exit(1);