@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/server.d.ts CHANGED
@@ -19,6 +19,8 @@ import { FeatureFilter } from './feature-groups.js';
19
19
  * a proxy strips first.
20
20
  */
21
21
 
22
+ /** Resolve the per-request timeout from `MUSHI_MCP_TIMEOUT_MS`, clamped. Exported for tests. */
23
+ declare function resolveTimeoutMsFromEnv(): number;
22
24
  declare class MushiApiError extends Error {
23
25
  readonly status: number;
24
26
  readonly code: string;
@@ -49,6 +51,12 @@ interface MushiServerConfig {
49
51
  * Set via `MUSHI_FEATURES` env (CSV) on stdio; HTTP uses `?features=` query.
50
52
  */
51
53
  features?: FeatureFilter;
54
+ /**
55
+ * Per-request timeout in ms for every Mushi API call a tool makes.
56
+ * Defaults to `MUSHI_MCP_TIMEOUT_MS` (clamped to 250ms…5min) or 15000.
57
+ * Explicit values are honoured as given — tests use small ones.
58
+ */
59
+ timeoutMs?: number;
52
60
  }
53
61
  /**
54
62
  * Build an MCP server instance pre-wired with every Mushi tool, resource,
@@ -57,4 +65,4 @@ interface MushiServerConfig {
57
65
  */
58
66
  declare function createMushiServer(config: MushiServerConfig): McpServer;
59
67
 
60
- export { MushiApiError, type MushiServerConfig, createMushiServer };
68
+ export { MushiApiError, type MushiServerConfig, createMushiServer, resolveTimeoutMsFromEnv };
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  TDD_TOOL_CATALOG,
4
4
  TOOL_CATALOG,
5
5
  USE_MUSHI_INTENTS
6
- } from "./chunk-TP2QQWDJ.js";
6
+ } from "./chunk-RVZIKP4X.js";
7
7
  import {
8
8
  MUSHI_SERVER_METADATA
9
9
  } from "./chunk-EANW5YTI.js";
@@ -143,8 +143,107 @@ function wrapUntrustedJson(value, role) {
143
143
  return wrapUntrusted(serialised, role);
144
144
  }
145
145
 
146
+ // src/stdout-guard.ts
147
+ import { Console } from "console";
148
+ var GUARDED_CONSOLE_METHODS = [
149
+ "log",
150
+ "info",
151
+ "debug",
152
+ "dir",
153
+ "dirxml",
154
+ "table",
155
+ "group",
156
+ "groupCollapsed",
157
+ "groupEnd",
158
+ "count",
159
+ "countReset",
160
+ "time",
161
+ "timeEnd",
162
+ "timeLog",
163
+ "trace",
164
+ "assert",
165
+ "warn",
166
+ "error"
167
+ ];
168
+ var installed = false;
169
+ function installStdoutGuard() {
170
+ if (installed) return false;
171
+ if (typeof process !== "undefined" && process.env?.MUSHI_MCP_STDOUT_GUARD === "off") {
172
+ return false;
173
+ }
174
+ if (typeof process === "undefined" || typeof process.stderr?.write !== "function" || typeof console === "undefined") {
175
+ return false;
176
+ }
177
+ try {
178
+ const stderrConsole = new Console({
179
+ stdout: process.stderr,
180
+ stderr: process.stderr,
181
+ // Colour codes would land in the client's log pane as escape noise.
182
+ colorMode: false
183
+ });
184
+ const target = console;
185
+ const source = stderrConsole;
186
+ for (const method of GUARDED_CONSOLE_METHODS) {
187
+ const fn = source[method];
188
+ if (typeof fn === "function") {
189
+ target[method] = fn.bind(stderrConsole);
190
+ }
191
+ }
192
+ } catch {
193
+ const write = (...args) => process.stderr.write(
194
+ args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ") + "\n"
195
+ );
196
+ const target = console;
197
+ for (const method of GUARDED_CONSOLE_METHODS) {
198
+ target[method] = write;
199
+ }
200
+ }
201
+ installed = true;
202
+ return true;
203
+ }
204
+ function safeStringify(value) {
205
+ try {
206
+ return JSON.stringify(value) ?? String(value);
207
+ } catch {
208
+ return String(value);
209
+ }
210
+ }
211
+
146
212
  // src/server.ts
213
+ installStdoutGuard();
147
214
  var NO_ARG_INPUT = {};
215
+ var DEFAULT_API_TIMEOUT_MS = 15e3;
216
+ var MIN_API_TIMEOUT_MS = 250;
217
+ var MAX_API_TIMEOUT_MS = 3e5;
218
+ function resolveTimeoutMsFromEnv() {
219
+ const raw = process.env?.MUSHI_MCP_TIMEOUT_MS?.trim();
220
+ if (!raw) return DEFAULT_API_TIMEOUT_MS;
221
+ const parsed = Number(raw);
222
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_TIMEOUT_MS;
223
+ return Math.min(Math.max(parsed, MIN_API_TIMEOUT_MS), MAX_API_TIMEOUT_MS);
224
+ }
225
+ function bareMessage(err) {
226
+ const raw = err instanceof Error ? err.message : String(err);
227
+ return raw.replace(/^\[[A-Z0-9_]+\]\s*/, "");
228
+ }
229
+ var IDEMPOTENCY_NAMESPACE = "mushi.mcp.submit_fix_result.v1";
230
+ async function stableUuidFrom(...parts) {
231
+ const input = parts.join("");
232
+ const digest = new Uint8Array(
233
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input))
234
+ );
235
+ const bytes = digest.slice(0, 16);
236
+ bytes[6] = bytes[6] & 15 | 80;
237
+ bytes[8] = bytes[8] & 63 | 128;
238
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
239
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
240
+ }
241
+ function withTimeoutSignal(callerSignal, timeoutSignal) {
242
+ if (!callerSignal) return timeoutSignal;
243
+ const anyFn = AbortSignal.any;
244
+ if (typeof anyFn === "function") return anyFn.call(AbortSignal, [callerSignal, timeoutSignal]);
245
+ return timeoutSignal;
246
+ }
148
247
  var MushiApiError = class extends Error {
149
248
  constructor(status, code, message) {
150
249
  super(`[${code}] ${message}`);
@@ -158,6 +257,7 @@ var MushiApiError = class extends Error {
158
257
  function createMushiServer(config) {
159
258
  const { version, apiEndpoint, apiKey, projectId } = config;
160
259
  const doFetch = config.fetch ?? globalThis.fetch;
260
+ const timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : resolveTimeoutMsFromEnv();
161
261
  const apiLog = createLogger({
162
262
  scope: "mushi:mcp:api",
163
263
  level: process.env.MUSHI_LOG_LEVEL ?? "info",
@@ -175,22 +275,66 @@ function createMushiServer(config) {
175
275
  async function apiCall(path, options) {
176
276
  const requestId = crypto.randomUUID().slice(0, 12);
177
277
  const started = Date.now();
178
- const res = await doFetch(`${apiEndpoint}${path}`, {
179
- ...options,
180
- headers: {
181
- "Content-Type": "application/json",
182
- // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
183
- // still send it for endpoints still behind plain `jwtAuth` (legacy)
184
- // and for transparent proxies that strip X-Mushi-* headers.
185
- Authorization: `Bearer ${apiKey}`,
186
- "X-Mushi-Api-Key": apiKey,
187
- "X-Request-Id": requestId,
188
- ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
189
- ...options?.headers ?? {}
278
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
279
+ let res;
280
+ try {
281
+ res = await doFetch(`${apiEndpoint}${path}`, {
282
+ ...options,
283
+ headers: {
284
+ "Content-Type": "application/json",
285
+ // Authorization is accepted by `adminOrApiKey` only as a JWT, but we
286
+ // still send it for endpoints still behind plain `jwtAuth` (legacy)
287
+ // and for transparent proxies that strip X-Mushi-* headers.
288
+ Authorization: `Bearer ${apiKey}`,
289
+ "X-Mushi-Api-Key": apiKey,
290
+ "X-Request-Id": requestId,
291
+ ...projectId ? { "X-Mushi-Project-Id": projectId } : {},
292
+ ...options?.headers ?? {}
293
+ },
294
+ // After the spread so a caller-supplied `signal` is merged, not lost.
295
+ signal: withTimeoutSignal(options?.signal, timeoutSignal)
296
+ });
297
+ } catch (err) {
298
+ throw classifyTransportError(err);
299
+ }
300
+ let text;
301
+ try {
302
+ text = await res.text();
303
+ } catch (err) {
304
+ throw classifyTransportError(err);
305
+ }
306
+ function classifyTransportError(err) {
307
+ const durationMs2 = Date.now() - started;
308
+ if (timeoutSignal.aborted) {
309
+ apiLog.warn("api.timeout", { path, requestId, durationMs: durationMs2, timeoutMs });
310
+ return new MushiApiError(
311
+ 504,
312
+ "MUSHI_TIMEOUT",
313
+ `Timed out after ${timeoutMs}ms waiting for ${path}. The Mushi API accepted the connection but did not answer in time \u2014 retry, or raise the budget with MUSHI_MCP_TIMEOUT_MS (ms) in your MCP env block if you self-host and cold starts are slow. Endpoint: ${apiEndpoint}`
314
+ );
190
315
  }
191
- });
316
+ const name = err instanceof Error ? err.name : "";
317
+ if (name === "AbortError") {
318
+ apiLog.warn("api.aborted", { path, requestId, durationMs: durationMs2 });
319
+ return new MushiApiError(
320
+ 499,
321
+ "REQUEST_ABORTED",
322
+ `The request to ${path} was aborted by the caller after ${durationMs2}ms.`
323
+ );
324
+ }
325
+ apiLog.warn("api.unreachable", {
326
+ path,
327
+ requestId,
328
+ durationMs: durationMs2,
329
+ err: bareMessage(err)
330
+ });
331
+ return new MushiApiError(
332
+ 503,
333
+ "NETWORK_ERROR",
334
+ `Could not reach the Mushi API at ${apiEndpoint}${path} \u2014 ${bareMessage(err)}. This is a connectivity failure, not an API rejection: check that you are online, that MUSHI_API_ENDPOINT points at your \`.../functions/v1/api\` URL, and that no proxy/VPN is blocking it.`
335
+ );
336
+ }
192
337
  const durationMs = Date.now() - started;
193
- const text = await res.text();
194
338
  let body = null;
195
339
  if (text) {
196
340
  try {
@@ -220,6 +364,7 @@ function createMushiServer(config) {
220
364
  apiLog.info("api.done", { path, requestId, status: res.status, durationMs });
221
365
  return body;
222
366
  }
367
+ let resolvedAccountProjectId = null;
223
368
  async function resolveProjectId(explicitId) {
224
369
  if (explicitId) {
225
370
  if (projectId && explicitId !== projectId) {
@@ -232,10 +377,12 @@ function createMushiServer(config) {
232
377
  return explicitId;
233
378
  }
234
379
  if (projectId) return projectId;
380
+ if (resolvedAccountProjectId) return resolvedAccountProjectId;
235
381
  try {
236
382
  const data = await apiCall("/v1/admin/mcp/account-overview");
237
383
  if (data.total === 1 && data.projects[0]) {
238
- return data.projects[0].id;
384
+ resolvedAccountProjectId = data.projects[0].id;
385
+ return resolvedAccountProjectId;
239
386
  }
240
387
  if (data.total === 0) {
241
388
  throw new MushiApiError(
@@ -251,11 +398,27 @@ function createMushiServer(config) {
251
398
  `Multiple projects are accessible with this key: ${projectList}. Pass projectId explicitly to target a specific project, or set MUSHI_PROJECT_ID in your MCP env config.`
252
399
  );
253
400
  } catch (err) {
254
- if (err instanceof MushiApiError) throw err;
401
+ if (err instanceof MushiApiError) {
402
+ if (err.code === "MUSHI_TIMEOUT" || err.code === "NETWORK_ERROR") {
403
+ throw new MushiApiError(
404
+ err.status,
405
+ err.code,
406
+ `Could not resolve which project to use because the Mushi API is unreachable: ${bareMessage(err)} This is a connectivity problem \u2014 setting MUSHI_PROJECT_ID would only skip the lookup, not fix the tool call that follows it.`
407
+ );
408
+ }
409
+ if (err.status === 401 || err.status === 403) {
410
+ throw new MushiApiError(
411
+ err.status,
412
+ err.code,
413
+ `The API key was rejected while resolving the project: ${bareMessage(err)} Mint a fresh key in the console (Settings \u2192 API keys) and update MUSHI_API_KEY, or re-run \`mushi login\`. This is an authentication problem, not a missing project id.`
414
+ );
415
+ }
416
+ throw err;
417
+ }
255
418
  throw new MushiApiError(
256
419
  400,
257
420
  "MISSING_PROJECT_ID",
258
- "projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly."
421
+ `projectId is required. Set MUSHI_PROJECT_ID in .cursor/mcp.json or pass it explicitly. (Project lookup failed with: ${bareMessage(err)})`
259
422
  );
260
423
  }
261
424
  }
@@ -679,12 +842,24 @@ function createMushiServer(config) {
679
842
  suggestedFix: z.unknown().describe("Stage-2 suggested fix, or null if not yet classified"),
680
843
  reproductionSteps: z.unknown().describe("Reproduction steps recorded on the report (array; [] if none)"),
681
844
  summary: z.unknown().describe("One-line report summary, or null"),
682
- component: z.unknown().describe("Component/page the bug was attributed to, or null")
845
+ component: z.unknown().describe("Component/page the bug was attributed to, or null"),
846
+ note: z.string().optional().describe("Present when no Stage-2 analysis exists \u2014 explains why and what to call instead")
683
847
  }
684
848
  },
685
849
  async (args) => {
686
850
  const report = await apiCall(`/v1/admin/reports/${args.reportId}`);
687
851
  const s2 = report.stage2_analysis;
852
+ if (!s2 || s2.rootCause == null && s2.suggestedFix == null) {
853
+ return jsonResult({
854
+ reportId: args.reportId,
855
+ rootCause: null,
856
+ suggestedFix: null,
857
+ reproductionSteps: report.reproduction_steps ?? [],
858
+ summary: report.summary ?? null,
859
+ component: report.component ?? null,
860
+ note: "No Stage-2 analysis exists for this report (it was classified by the fast Stage-1 path, which does not produce root-cause/fix hints). Use triage_issue for the full review packet, or dispatch_fix \u2014 the fix worker does its own grounded analysis."
861
+ });
862
+ }
688
863
  return jsonResult({
689
864
  reportId: args.reportId,
690
865
  rootCause: s2?.rootCause ?? null,
@@ -1115,12 +1290,31 @@ function createMushiServer(config) {
1115
1290
  const category = report ? report.category : "unknown";
1116
1291
  const status = report ? report.status : "unknown";
1117
1292
  const actions = [];
1118
- if (status === "new" || status === "classified") {
1293
+ const processingError = report ? report.processing_error : null;
1294
+ const attempts = report ? report.fix_attempts ?? [] : [];
1295
+ const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
1296
+ const lastAttemptStatus = lastAttempt ? String(lastAttempt.status ?? "") : "";
1297
+ const autofixBlocked = typeof processingError === "string" && processingError.startsWith("autofix_blocked:") || lastAttemptStatus.startsWith("skipped") || lastAttemptStatus === "failed";
1298
+ if (autofixBlocked && status !== "fixing" && status !== "fixed") {
1299
+ const blockReason = typeof processingError === "string" && processingError || String(lastAttempt?.error ?? "previous fix attempt was skipped or failed");
1300
+ actions.push({
1301
+ action: "unblock_autofix",
1302
+ reason: `Auto-fix is blocked: ${blockReason}. Resolve the blocker (settings/integration/budget), then re-dispatch.`,
1303
+ tool: "diagnose_setup",
1304
+ args: { mode: "dispatch" }
1305
+ });
1306
+ actions.push({
1307
+ action: "redispatch_after_unblock",
1308
+ reason: "Once the blocker above is resolved, dispatch a fresh fix attempt.",
1309
+ tool: "dispatch_fix",
1310
+ args: { reportId: args.report_id }
1311
+ });
1312
+ } else if (status === "new" || status === "classified") {
1119
1313
  actions.push({
1120
1314
  action: "dispatch_fix",
1121
- reason: "Report is classified but no fix has been attempted",
1315
+ reason: "Report is classified and no fix has been attempted",
1122
1316
  tool: "dispatch_fix",
1123
- args: { reportId: args.report_id, agent: "cursor_cloud" }
1317
+ args: { reportId: args.report_id }
1124
1318
  });
1125
1319
  } else if (status === "fixing") {
1126
1320
  actions.push({
@@ -1198,7 +1392,7 @@ function createMushiServer(config) {
1198
1392
  linesChanged: z.number().describe("Total lines added + removed across filesChanged"),
1199
1393
  summary: z.string().describe("One-line human summary of what the fix changed"),
1200
1394
  idempotencyKey: z.string().uuid().optional().describe(
1201
- "Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows"
1395
+ "Optional UUID \u2014 resend the same key to safely retry without creating duplicate fix rows. Omit it and a stable key is derived from reportId + branch + prUrl, so a retried submission carries the identical key instead of a fresh random one."
1202
1396
  )
1203
1397
  },
1204
1398
  outputSchema: {
@@ -1207,27 +1401,123 @@ function createMushiServer(config) {
1207
1401
  }
1208
1402
  },
1209
1403
  async (args) => {
1210
- const idemKey = args.idempotencyKey ?? crypto.randomUUID();
1404
+ const idemKey = args.idempotencyKey ?? await stableUuidFrom(
1405
+ IDEMPOTENCY_NAMESPACE,
1406
+ args.reportId,
1407
+ args.branch,
1408
+ args.prUrl ?? ""
1409
+ );
1211
1410
  const created = await apiCall("/v1/admin/fixes", {
1212
1411
  method: "POST",
1213
1412
  headers: { "Idempotency-Key": idemKey },
1214
1413
  body: JSON.stringify({ reportId: args.reportId, agent: "mcp" })
1215
1414
  });
1216
- await apiCall(`/v1/admin/fixes/${created.fixId}`, {
1217
- method: "PATCH",
1218
- body: JSON.stringify({
1219
- status: "completed",
1220
- branch: args.branch,
1221
- pr_url: args.prUrl,
1222
- files_changed: args.filesChanged,
1223
- lines_changed: args.linesChanged,
1224
- summary: args.summary,
1225
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
1226
- })
1227
- });
1415
+ try {
1416
+ await apiCall(`/v1/admin/fixes/${created.fixId}`, {
1417
+ method: "PATCH",
1418
+ body: JSON.stringify({
1419
+ status: "completed",
1420
+ branch: args.branch,
1421
+ pr_url: args.prUrl,
1422
+ files_changed: args.filesChanged,
1423
+ lines_changed: args.linesChanged,
1424
+ summary: args.summary,
1425
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1426
+ })
1427
+ });
1428
+ } catch (err) {
1429
+ const status = err instanceof MushiApiError ? err.status : 500;
1430
+ throw new MushiApiError(
1431
+ status,
1432
+ "FIX_PATCH_FAILED",
1433
+ `Fix row ${created.fixId} was created for report ${args.reportId}, but marking it completed failed: ${bareMessage(err)} \u2014 the fix row exists and is still in its initial (pending) state, so no work is lost. Preferred repair: PATCH /v1/admin/fixes/${created.fixId} with {"status":"completed"} to finish this exact row. Re-running submit_fix_result re-sends the same derived Idempotency-Key (reportId + branch + prUrl) rather than a fresh one, so it dedupes wherever the API honours that header \u2014 retry only with identical arguments.`
1434
+ );
1435
+ }
1228
1436
  return jsonResult({ ok: true, fixId: created.fixId });
1229
1437
  }
1230
1438
  );
1439
+ server.registerTool(
1440
+ "triage_next_steps",
1441
+ {
1442
+ title: titleOf("triage_next_steps"),
1443
+ description: descOf("triage_next_steps"),
1444
+ annotations: annotationsFor("triage_next_steps"),
1445
+ inputSchema: {
1446
+ project_id: z.string().uuid().optional().describe("Project UUID (defaults to the configured project).")
1447
+ },
1448
+ outputSchema: {
1449
+ steps: z.array(
1450
+ z.object({
1451
+ priority: z.number(),
1452
+ action: z.string(),
1453
+ reason: z.string(),
1454
+ tool: z.string().optional(),
1455
+ args: z.record(z.string(), z.unknown()).optional()
1456
+ })
1457
+ ),
1458
+ summary: z.string()
1459
+ }
1460
+ },
1461
+ async (args) => {
1462
+ const pid = await resolveProjectId(args.project_id);
1463
+ const extraHeaders = pid !== projectId ? { "X-Mushi-Project-Id": pid } : {};
1464
+ const data = await apiCall(
1465
+ "/v1/admin/reports?limit=100",
1466
+ { headers: extraHeaders }
1467
+ );
1468
+ const reports = data.reports ?? [];
1469
+ const isCron = (r) => typeof r.reporter_token_hash === "string" && r.reporter_token_hash.startsWith("cron:");
1470
+ const isBlocked = (r) => typeof r.processing_error === "string" && r.processing_error.startsWith("autofix_blocked:");
1471
+ const label = (r) => String(r.title ?? r.summary ?? r.description ?? "").slice(0, 90);
1472
+ const steps = [];
1473
+ let priority = 1;
1474
+ const blocked = reports.filter(isBlocked);
1475
+ if (blocked.length > 0) {
1476
+ steps.push({
1477
+ priority: priority++,
1478
+ action: `Unblock auto-fix (${blocked.length} report${blocked.length === 1 ? "" : "s"} blocked)`,
1479
+ reason: String(blocked[0].processing_error).slice(0, 200),
1480
+ tool: "diagnose_setup",
1481
+ args: { mode: "dispatch" }
1482
+ });
1483
+ }
1484
+ const fixing = reports.filter((r) => r.status === "fixing");
1485
+ for (const r of fixing.slice(0, 2)) {
1486
+ steps.push({
1487
+ priority: priority++,
1488
+ action: `Shepherd in-flight fix: ${label(r)}`,
1489
+ reason: "A fix branch/PR is open \u2014 check CI and merge when green.",
1490
+ tool: "get_report_timeline",
1491
+ args: { report_id: r.id }
1492
+ });
1493
+ }
1494
+ const openUserReports = reports.filter((r) => (r.status === "new" || r.status === "classified") && !isCron(r) && !isBlocked(r)).sort((a, b) => {
1495
+ const rank = { critical: 0, high: 1, medium: 2, low: 3 };
1496
+ return (rank[String(a.severity)] ?? 4) - (rank[String(b.severity)] ?? 4);
1497
+ });
1498
+ for (const r of openUserReports.slice(0, Math.max(0, 4 - steps.length))) {
1499
+ steps.push({
1500
+ priority: priority++,
1501
+ action: `Triage [${r.severity}] ${label(r)}`,
1502
+ reason: "User-felt report awaiting triage \u2014 review the packet, then dispatch or dismiss.",
1503
+ tool: "triage_issue",
1504
+ args: { report_id: r.id }
1505
+ });
1506
+ }
1507
+ const chores = reports.filter((r) => r.status === "classified" && isCron(r) && !isBlocked(r));
1508
+ if (chores.length > 0 && steps.length < 5) {
1509
+ steps.push({
1510
+ priority: priority++,
1511
+ action: `Batch ${chores.length} maintenance chore${chores.length === 1 ? "" : "s"} (dependency bumps)`,
1512
+ reason: "Robot-filed modernization reports \u2014 batch-dispatch or dismiss in one sitting; do not let them crowd out user bugs.",
1513
+ tool: "get_recent_reports",
1514
+ args: { status: "classified" }
1515
+ });
1516
+ }
1517
+ const summary = steps.length === 0 ? "Inbox is clear \u2014 no blocked fixes, no open user reports, no chores. Nothing needs your attention." : `${steps.length} prioritised step${steps.length === 1 ? "" : "s"}: ` + steps.map((s) => s.action).join(" \u2192 ");
1518
+ return jsonResult({ steps, summary });
1519
+ }
1520
+ );
1231
1521
  server.registerTool(
1232
1522
  "dispatch_fix",
1233
1523
  {
@@ -1264,7 +1554,7 @@ function createMushiServer(config) {
1264
1554
  } catch {
1265
1555
  }
1266
1556
  }
1267
- const data = await apiCall("/v1/admin/fixes/dispatch", {
1557
+ const dispatch = await apiCall("/v1/admin/fixes/dispatch", {
1268
1558
  method: "POST",
1269
1559
  headers: args.idempotencyKey ? { "Idempotency-Key": args.idempotencyKey } : void 0,
1270
1560
  body: JSON.stringify({
@@ -1274,7 +1564,12 @@ function createMushiServer(config) {
1274
1564
  ...projectId ? { projectId } : {}
1275
1565
  })
1276
1566
  });
1277
- return jsonResult(data);
1567
+ if (!dispatch?.dispatchId) {
1568
+ throw new Error(
1569
+ `Dispatch response missing dispatchId \u2014 got: ${JSON.stringify(dispatch).slice(0, 300)}`
1570
+ );
1571
+ }
1572
+ return jsonResult({ fixId: dispatch.dispatchId, status: dispatch.status ?? "queued" });
1278
1573
  }
1279
1574
  );
1280
1575
  server.registerTool(
@@ -1409,6 +1704,11 @@ function createMushiServer(config) {
1409
1704
  status: z.enum([
1410
1705
  "pending",
1411
1706
  "classified",
1707
+ // Workflow states added 20260520600000 but missing from this
1708
+ // enum ever since (2026-08-16 audit P2-2) — agents could not
1709
+ // mark a report as being looked at.
1710
+ "triaged",
1711
+ "in_progress",
1412
1712
  "grouped",
1413
1713
  "fixing",
1414
1714
  "fixed",
@@ -1416,7 +1716,7 @@ function createMushiServer(config) {
1416
1716
  "verified",
1417
1717
  "reopened",
1418
1718
  "dismissed"
1419
- ]).describe("Target status"),
1719
+ ]).describe("Target status (resolved is stored as fixed)"),
1420
1720
  reason: z.string().optional().describe("Reason for the transition (audit trail)")
1421
1721
  }
1422
1722
  },
@@ -2632,5 +2932,6 @@ Be specific. No generic advice.`
2632
2932
  }
2633
2933
  export {
2634
2934
  MushiApiError,
2635
- createMushiServer
2935
+ createMushiServer,
2936
+ resolveTimeoutMsFromEnv
2636
2937
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mushi-mushi/mcp",
3
3
  "mcpName": "io.github.kensaurus/mushi-mushi",
4
- "version": "0.20.1",
4
+ "version": "0.20.2",
5
5
  "license": "MIT",
6
6
  "description": "MCP server for Mushi Mushi — triage user-felt bugs, pull evidence (screenshots, console logs, network requests), check blast radius, and dispatch fix PRs, all from your AI coding agent",
7
7
  "type": "module",
@@ -48,7 +48,7 @@
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
49
  "@sentry/node": "^10.57.0",
50
50
  "zod": "^4.4.3",
51
- "@mushi-mushi/core": "^1.27.0"
51
+ "@mushi-mushi/core": "^1.27.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.20.0",