mergestorm-mcp 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +225 -211
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # mergestorm-mcp
2
2
 
3
- Stdio MCP server for Mergestorm. Tools: `whoami`, `credits`, `review_list`, `review_get`, `review_get_pr`, `review_submit`, `review_wait`, `review_wait_pr`, `stack_adopt`, `stack_list`, `stack_set`, `stack_status`, `stack_wait`, `queue_status`, `settings_get`, `settings_set`.
3
+ Stdio MCP server for Mergestorm. Tools: `whoami`, `credits`, `review_list`, `review_submit`, `review_wait`, `review_wait_pr`, `stack_adopt`, `stack_list`, `stack_set`, `stack_status`, `stack_wait`, `queue_status`, `settings_get`, `settings_set`.
4
+
5
+ Every tool declares MCP `annotations`. The reads carry `readOnlyHint: true`; `review_submit`, `stack_adopt`, `stack_set`, and `settings_set` carry `readOnlyHint: false` and `destructiveHint: false`.
6
+
7
+ `review_wait({ job_id, timeout_s? })` reads a local review job by the `job_id` that `review_submit` returned. `review_wait_pr({ owner, repo, pr_number, after_sha?, pass?, after_pass?, timeout_s? })` reads the Vortex pass on a GitHub PR. Both poll one 45s slice by default; `timeout_s: 0` reads once without polling.
4
8
 
5
9
  The stack tools expose reads plus one scoped policy write:
6
10
 
@@ -8,7 +12,7 @@ The stack tools expose reads plus one scoped policy write:
8
12
  - `stack_list` returns the current API key owner's registered stacks.
9
13
  - `stack_set({ stack_id, auto_land?, auto_review?, auto_patch? })` sets per-stack policy on one owned stack. `auto_land` flips Auto land. `auto_review` and `auto_patch` pin Vortex auto-review or Cyclone auto-patch for that stack in either direction (`true` or `false`); `null` clears the pin so the stack follows the account setting. Pass at least one key. Account settings are never changed here.
10
14
  - `stack_status({ stack_id })` returns one owned stack with enriched checks and agent state, plus `attention`, `issues`, and `currentCandidate` from the same blocker rules as `stack_wait`, read against that stack's own merge queue. It returns a structured `stack_not_found` or `rate_limited` error.
11
- - `stack_wait({ stack_id, timeout_s?, enrolled_head_sha?, after_finished_at?, bounce_id? })` waits for a stack or queue state change and returns the latest snapshots. `timeout_s: 0` returns one snapshot; a positive timeout after a snapshot with no attention returns the current `waiting` or `in_progress` status with selectors to use for the next call. An unread deadline returns `failed`.
15
+ - `stack_wait({ stack_id, timeout_s?, enrolled_head_sha?, after_finished_at?, bounce_id? })` waits for a stack or queue state change and returns the latest snapshots. `timeout_s: 0` returns one snapshot; a positive timeout after a snapshot with no attention returns the current `waiting` or `in_progress` status with selectors to use for the next call. An unread deadline returns `failed`. While Cyclone or Vortex is still working on the blocked PR, `stack_wait` and `stack_status` return `in_progress` instead of `attention`, with `busy[]` naming the PR, blocker, and agent, and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and busy flags. A nonempty `busy[]` means wait. A queued Vortex run holds for at most 15 minutes, `seam_pending` alone does not hold, and reviewing or patching holds until the run finishes or its lease or check run is cleared. A hold can hide a blocker no agent clears (such as Draft); if it looks stuck, refresh `stack_status` and tell the human.
12
16
 
13
17
  Auth is `MERGESTORM_API_KEY`, then `~/.mergestorm/config.json` (same as `mg`).
14
18
 
package/dist/index.js CHANGED
@@ -8,6 +8,33 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { z as z3 } from "zod";
9
9
  import { CommandError as CommandError11, BEARER_SETTINGS as BEARER_SETTINGS2 } from "mergestorm/client";
10
10
 
11
+ // src/brand-icons.ts
12
+ var MARK_RECTS = [
13
+ { x: 103.685, y: 79.639, width: 336.635, height: 32.005 },
14
+ { x: 71.68, y: 143.816, width: 288.545, height: 32.005 },
15
+ { x: 103.685, y: 207.993, width: 192.363, height: 32.005 },
16
+ { x: 151.775, y: 272.002, width: 176.277, height: 32.005 },
17
+ { x: 215.952, y: 336.179, width: 96.182, height: 32.005 },
18
+ { x: 264.043, y: 400.356, width: 32.005, height: 32.005 }
19
+ ];
20
+ var SITE = "https://mergestorm.ai";
21
+ function markSvg(fill) {
22
+ const rects = MARK_RECTS.map(
23
+ (r) => `<rect x="${r.x}" y="${r.y}" width="${r.width}" height="${r.height}" fill="${fill}"/>`
24
+ ).join("");
25
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="64 64 384 384">${rects}</svg>`;
26
+ }
27
+ function svgDataUri(svg) {
28
+ return `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`;
29
+ }
30
+ var MCP_SERVER_ICONS = [
31
+ { src: svgDataUri(markSvg("#000000")), mimeType: "image/svg+xml", sizes: ["any"], theme: "light" },
32
+ { src: svgDataUri(markSvg("#ffffff")), mimeType: "image/svg+xml", sizes: ["any"], theme: "dark" },
33
+ { src: `${SITE}/images/android-chrome-192x192.png`, mimeType: "image/png", sizes: ["192x192"] },
34
+ { src: `${SITE}/images/android-chrome-512x512.png`, mimeType: "image/png", sizes: ["512x512"] }
35
+ ];
36
+ var MCP_WEBSITE_URL = SITE;
37
+
11
38
  // src/tools/credits.ts
12
39
  import { CommandError, getMe, loadConfig } from "mergestorm/client";
13
40
  async function credits(cfg) {
@@ -135,95 +162,43 @@ async function queueStatus(stackId, cfg) {
135
162
  }
136
163
  }
137
164
 
138
- // src/tools/review-get-pr.ts
139
- import {
140
- CommandError as CommandError3,
141
- getPrVortexReview,
142
- isCommandErrorCode as isCommandErrorCode2
143
- } from "mergestorm/client";
144
- function prReviewTarget(owner, repo, prNumber) {
145
- const cleanOwner = owner.trim();
146
- const cleanRepo = repo.trim();
147
- if (!cleanOwner || !cleanRepo || !Number.isInteger(prNumber) || prNumber <= 0) {
148
- throw new CommandError3(
149
- "owner, repo, and a positive pr_number are required",
150
- 2,
151
- "usage"
152
- );
153
- }
154
- return { owner: cleanOwner, repo: cleanRepo, prNumber };
155
- }
156
- function prReviewSummary(owner, repo, prNumber, envelope) {
157
- const bits = [`${owner}/${repo}#${prNumber}`, envelope.status];
158
- const sha = envelope.head_sha?.trim();
159
- if (sha) bits.push(`sha ${sha.slice(0, 7)}`);
160
- if (typeof envelope.finding_count === "number") {
161
- bits.push(
162
- `${envelope.finding_count} finding${envelope.finding_count === 1 ? "" : "s"}`
163
- );
164
- }
165
- if (envelope.phase) bits.push(String(envelope.phase));
166
- return bits.join(" \xB7 ");
167
- }
168
- function payloadFromPrReview(owner, repo, prNumber, envelope) {
169
- return {
170
- summary: prReviewSummary(owner, repo, prNumber, envelope),
171
- data: { ...envelope }
172
- };
173
- }
174
- function payloadFromPrRateLimit(err, owner, repo, prNumber) {
175
- if (!isCommandErrorCode2(err, "rate_limited")) return null;
165
+ // src/tools/review-list.ts
166
+ import { listJobs, toReviewJobEnvelope } from "mergestorm/client";
167
+ async function reviewList(limit = 10, cfg) {
168
+ const items = await listJobs(limit, cfg);
169
+ const envelopes = items.map((row) => toReviewJobEnvelope(row));
170
+ const n = envelopes.length;
176
171
  return {
177
- summary: `${owner}/${repo}#${prNumber} \xB7 rate_limited`,
178
- data: {
179
- status: "rate_limited",
180
- error: err.message,
181
- retry_after_seconds: err.retryAfterSeconds
182
- }
172
+ summary: n === 0 ? "No review jobs" : `${n} review job${n === 1 ? "" : "s"}`,
173
+ data: { items: envelopes }
183
174
  };
184
175
  }
185
- async function reviewGetPr(owner, repo, prNumber, cfg, opts = {}) {
186
- const target = prReviewTarget(owner, repo, prNumber);
187
- try {
188
- const envelope = await getPrVortexReview(
189
- target.owner,
190
- target.repo,
191
- target.prNumber,
192
- cfg,
193
- { afterSha: opts.afterSha, pass: opts.pass, afterPass: opts.afterPass }
194
- );
195
- return payloadFromPrReview(
196
- target.owner,
197
- target.repo,
198
- target.prNumber,
199
- envelope
200
- );
201
- } catch (err) {
202
- const rateLimited = payloadFromPrRateLimit(
203
- err,
204
- target.owner,
205
- target.repo,
206
- target.prNumber
207
- );
208
- if (rateLimited) return rateLimited;
209
- throw err;
210
- }
211
- }
212
176
 
213
- // src/tools/review-get.ts
177
+ // src/tools/review-submit.ts
178
+ import { createHash } from "node:crypto";
179
+ import { realpath } from "node:fs/promises";
180
+ import { relative, resolve, sep } from "node:path";
214
181
  import {
215
- CommandError as CommandError4,
216
- getReview,
217
- toReviewJobEnvelope as toReviewJobEnvelope2
182
+ CommandError as CommandError3,
183
+ REVIEW_THREAD_SLUG_RE,
184
+ ReviewPollTimeoutError,
185
+ collectReviewInput,
186
+ discoverTrunk,
187
+ formatReviewSubmitError,
188
+ loadConfig as loadConfig3,
189
+ loadReviewContext,
190
+ pollReview,
191
+ rateLimitedMessage as rateLimitedMessage2,
192
+ submitReview
218
193
  } from "mergestorm/client";
219
194
 
220
195
  // src/tools/envelope.ts
221
196
  import {
222
- isCommandErrorCode as isCommandErrorCode3,
223
- toReviewJobEnvelope
197
+ isCommandErrorCode as isCommandErrorCode2,
198
+ toReviewJobEnvelope as toReviewJobEnvelope2
224
199
  } from "mergestorm/client";
225
200
  function payloadFromRow(row, fallback = {}) {
226
- const envelope = toReviewJobEnvelope(row, fallback);
201
+ const envelope = toReviewJobEnvelope2(row, fallback);
227
202
  const bits = [envelope.status];
228
203
  if (envelope.verdict) bits.push(envelope.verdict);
229
204
  return {
@@ -232,7 +207,7 @@ function payloadFromRow(row, fallback = {}) {
232
207
  };
233
208
  }
234
209
  function payloadFromRateLimit(err, jobId) {
235
- if (!isCommandErrorCode3(err, "rate_limited")) return null;
210
+ if (!isCommandErrorCode2(err, "rate_limited")) return null;
236
211
  return payloadFromRow(null, {
237
212
  jobId,
238
213
  status: "rate_limited",
@@ -241,57 +216,7 @@ function payloadFromRateLimit(err, jobId) {
241
216
  });
242
217
  }
243
218
 
244
- // src/tools/review-get.ts
245
- async function reviewGet(jobId, cfg) {
246
- let row;
247
- try {
248
- row = await getReview(jobId, cfg);
249
- } catch (err) {
250
- const rateLimited = payloadFromRateLimit(err, jobId);
251
- if (rateLimited) return rateLimited;
252
- throw err;
253
- }
254
- if (!row || typeof row !== "object" || typeof row.status !== "string") {
255
- throw new CommandError4(`Unexpected response for review ${jobId}.`);
256
- }
257
- const envelope = toReviewJobEnvelope2(row, { jobId });
258
- const bits = [envelope.status];
259
- if (envelope.verdict) bits.push(envelope.verdict);
260
- return {
261
- summary: `${envelope.job_id ?? jobId} \xB7 ${bits.join(" \xB7 ")}`,
262
- data: envelope
263
- };
264
- }
265
-
266
- // src/tools/review-list.ts
267
- import { listJobs, toReviewJobEnvelope as toReviewJobEnvelope3 } from "mergestorm/client";
268
- async function reviewList(limit = 10, cfg) {
269
- const items = await listJobs(limit, cfg);
270
- const envelopes = items.map((row) => toReviewJobEnvelope3(row));
271
- const n = envelopes.length;
272
- return {
273
- summary: n === 0 ? "No review jobs" : `${n} review job${n === 1 ? "" : "s"}`,
274
- data: { items: envelopes }
275
- };
276
- }
277
-
278
219
  // src/tools/review-submit.ts
279
- import { createHash } from "node:crypto";
280
- import { realpath } from "node:fs/promises";
281
- import { relative, resolve, sep } from "node:path";
282
- import {
283
- CommandError as CommandError5,
284
- REVIEW_THREAD_SLUG_RE,
285
- ReviewPollTimeoutError,
286
- collectReviewInput,
287
- discoverTrunk,
288
- formatReviewSubmitError,
289
- loadConfig as loadConfig3,
290
- loadReviewContext,
291
- pollReview,
292
- rateLimitedMessage as rateLimitedMessage2,
293
- submitReview
294
- } from "mergestorm/client";
295
220
  var MCP_WAIT_CEILING_S = 300;
296
221
  var MCP_PR_WAIT_DEFAULT_S = 45;
297
222
  var PINNABLE_SPECIALISTS = [
@@ -309,7 +234,7 @@ function assertPinnableSpecialists(specialists) {
309
234
  (id) => !PINNABLE_SPECIALISTS.includes(id)
310
235
  );
311
236
  if (unknown.length === 0) return;
312
- throw new CommandError5(
237
+ throw new CommandError3(
313
238
  `Unknown specialist: ${unknown.join(", ")}. Valid lanes: ${PINNABLE_SPECIALISTS.join(", ")}. governance and seam cannot be requested.`,
314
239
  2,
315
240
  "usage"
@@ -321,7 +246,7 @@ function mcpSandboxRoot() {
321
246
  function waitTimeoutMs(timeout_s, defaultSeconds = MCP_PR_WAIT_DEFAULT_S) {
322
247
  const seconds = timeout_s ?? defaultSeconds;
323
248
  if (!Number.isFinite(seconds) || seconds <= 0) {
324
- throw new CommandError5("timeout_s must be a positive number of seconds", 2, "usage");
249
+ throw new CommandError3("timeout_s must be a positive number of seconds", 2, "usage");
325
250
  }
326
251
  return Math.max(1, Math.round(Math.min(seconds, MCP_WAIT_CEILING_S) * 1e3));
327
252
  }
@@ -354,11 +279,11 @@ async function resolveInput(input) {
354
279
  ]);
355
280
  const relToSandbox = relative(sandboxReal, cwdReal);
356
281
  if (relToSandbox === ".." || relToSandbox.startsWith(`..${sep}`)) {
357
- throw new CommandError5("cwd must be inside the configured sandbox root", 2, "usage");
282
+ throw new CommandError3("cwd must be inside the configured sandbox root", 2, "usage");
358
283
  }
359
284
  } catch (err) {
360
- if (err instanceof CommandError5) throw err;
361
- throw new CommandError5("cwd must be inside the configured sandbox root", 2, "usage");
285
+ if (err instanceof CommandError3) throw err;
286
+ throw new CommandError3("cwd must be inside the configured sandbox root", 2, "usage");
362
287
  }
363
288
  const base = input.base?.trim() || discoverTrunk(cwd);
364
289
  return collectReviewInput(base, head, cwd, sandboxRoot);
@@ -369,10 +294,10 @@ async function reviewSubmit(input, cfg, opts = {}) {
369
294
  const timeoutMs = waitTimeoutMs(input.timeout_s);
370
295
  assertPinnableSpecialists(input.specialists);
371
296
  if (input.thread && !REVIEW_THREAD_SLUG_RE.test(input.thread)) {
372
- throw new CommandError5("thread must be 1\u2013120 chars [A-Za-z0-9._/-]", 2, "usage");
297
+ throw new CommandError3("thread must be 1\u2013120 chars [A-Za-z0-9._/-]", 2, "usage");
373
298
  }
374
299
  if (input.context === "-") {
375
- throw new CommandError5(
300
+ throw new CommandError3(
376
301
  'context "-" (stdin) is not available over MCP; pass the text directly',
377
302
  2,
378
303
  "usage"
@@ -422,7 +347,7 @@ async function reviewSubmit(input, cfg, opts = {}) {
422
347
  };
423
348
  }
424
349
  if (!jobId) {
425
- throw new CommandError5("Review API returned no job_id");
350
+ throw new CommandError3("Review API returned no job_id");
426
351
  }
427
352
  if (!wait) {
428
353
  return payloadFromRow(submitted.row, { jobId, status: submitted.row.status ?? "queued" });
@@ -454,9 +379,93 @@ import {
454
379
  loadConfig as loadConfig4,
455
380
  pollPrVortexReview
456
381
  } from "mergestorm/client";
382
+
383
+ // src/tools/review-get-pr.ts
384
+ import {
385
+ CommandError as CommandError4,
386
+ getPrVortexReview,
387
+ isCommandErrorCode as isCommandErrorCode3
388
+ } from "mergestorm/client";
389
+ function prReviewTarget(owner, repo, prNumber) {
390
+ const cleanOwner = owner.trim();
391
+ const cleanRepo = repo.trim();
392
+ if (!cleanOwner || !cleanRepo || !Number.isInteger(prNumber) || prNumber <= 0) {
393
+ throw new CommandError4(
394
+ "owner, repo, and a positive pr_number are required",
395
+ 2,
396
+ "usage"
397
+ );
398
+ }
399
+ return { owner: cleanOwner, repo: cleanRepo, prNumber };
400
+ }
401
+ function prReviewSummary(owner, repo, prNumber, envelope) {
402
+ const bits = [`${owner}/${repo}#${prNumber}`, envelope.status];
403
+ const sha = envelope.head_sha?.trim();
404
+ if (sha) bits.push(`sha ${sha.slice(0, 7)}`);
405
+ if (typeof envelope.finding_count === "number") {
406
+ bits.push(
407
+ `${envelope.finding_count} finding${envelope.finding_count === 1 ? "" : "s"}`
408
+ );
409
+ }
410
+ if (envelope.phase) bits.push(String(envelope.phase));
411
+ return bits.join(" \xB7 ");
412
+ }
413
+ function payloadFromPrReview(owner, repo, prNumber, envelope) {
414
+ return {
415
+ summary: prReviewSummary(owner, repo, prNumber, envelope),
416
+ data: { ...envelope }
417
+ };
418
+ }
419
+ function payloadFromPrRateLimit(err, owner, repo, prNumber) {
420
+ if (!isCommandErrorCode3(err, "rate_limited")) return null;
421
+ return {
422
+ summary: `${owner}/${repo}#${prNumber} \xB7 rate_limited`,
423
+ data: {
424
+ status: "rate_limited",
425
+ error: err.message,
426
+ retry_after_seconds: err.retryAfterSeconds
427
+ }
428
+ };
429
+ }
430
+ async function reviewGetPr(owner, repo, prNumber, cfg, opts = {}) {
431
+ const target = prReviewTarget(owner, repo, prNumber);
432
+ try {
433
+ const envelope = await getPrVortexReview(
434
+ target.owner,
435
+ target.repo,
436
+ target.prNumber,
437
+ cfg,
438
+ { afterSha: opts.afterSha, pass: opts.pass, afterPass: opts.afterPass }
439
+ );
440
+ return payloadFromPrReview(
441
+ target.owner,
442
+ target.repo,
443
+ target.prNumber,
444
+ envelope
445
+ );
446
+ } catch (err) {
447
+ const rateLimited = payloadFromPrRateLimit(
448
+ err,
449
+ target.owner,
450
+ target.repo,
451
+ target.prNumber
452
+ );
453
+ if (rateLimited) return rateLimited;
454
+ throw err;
455
+ }
456
+ }
457
+
458
+ // src/tools/review-wait-pr.ts
457
459
  async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opts = {}) {
458
460
  const target = prReviewTarget(owner, repo, prNumber);
459
461
  const resolved = cfg ?? await loadConfig4();
462
+ if (timeout_s === 0) {
463
+ return reviewGetPr(target.owner, target.repo, target.prNumber, resolved, {
464
+ afterSha,
465
+ pass: opts.pass,
466
+ afterPass: opts.afterPass
467
+ });
468
+ }
460
469
  try {
461
470
  const envelope = await pollPrVortexReview(
462
471
  resolved,
@@ -521,12 +530,42 @@ import {
521
530
  loadConfig as loadConfig5,
522
531
  pollReview as pollReview2
523
532
  } from "mergestorm/client";
533
+
534
+ // src/tools/review-get.ts
535
+ import {
536
+ CommandError as CommandError5,
537
+ getReview,
538
+ toReviewJobEnvelope as toReviewJobEnvelope3
539
+ } from "mergestorm/client";
540
+ async function reviewGet(jobId, cfg) {
541
+ let row;
542
+ try {
543
+ row = await getReview(jobId, cfg);
544
+ } catch (err) {
545
+ const rateLimited = payloadFromRateLimit(err, jobId);
546
+ if (rateLimited) return rateLimited;
547
+ throw err;
548
+ }
549
+ if (!row || typeof row !== "object" || typeof row.status !== "string") {
550
+ throw new CommandError5(`Unexpected response for review ${jobId}.`);
551
+ }
552
+ const envelope = toReviewJobEnvelope3(row, { jobId });
553
+ const bits = [envelope.status];
554
+ if (envelope.verdict) bits.push(envelope.verdict);
555
+ return {
556
+ summary: `${envelope.job_id ?? jobId} \xB7 ${bits.join(" \xB7 ")}`,
557
+ data: envelope
558
+ };
559
+ }
560
+
561
+ // src/tools/review-wait.ts
524
562
  async function reviewWait(jobId, timeout_s, cfg, opts = {}) {
525
563
  const resolved = cfg ?? await loadConfig5();
526
564
  const id = jobId.trim();
527
565
  if (!id) {
528
566
  throw new CommandError6("job_id is required", 2, "usage");
529
567
  }
568
+ if (timeout_s === 0) return reviewGet(id, resolved);
530
569
  try {
531
570
  const row = await pollReview2(resolved, id, {
532
571
  timeoutMs: waitTimeoutMs(timeout_s),
@@ -755,8 +794,8 @@ function stackSummary(stack, entries = []) {
755
794
  if (stack.cycloneOwnerMatch) {
756
795
  overrides.push(`cyclone-owner ${stack.cycloneOwnerMatch}`);
757
796
  }
758
- const { attention, issues } = stackBlockers(stack, entries);
759
- return `${stack.owner}/${stack.repo} \xB7 ${layerLabel} \xB7 ${currentState} \xB7 auto-land ${stack.autoEnqueueWhenReady ? "on" : "off"}${overrides.map((label) => ` \xB7 ${label}`).join("")}${stackBlockersSummary(attention, issues)}`;
797
+ const { attention, issues, busy } = stackBlockers(stack, entries);
798
+ return `${stack.owner}/${stack.repo} \xB7 ${layerLabel} \xB7 ${currentState} \xB7 auto-land ${stack.autoEnqueueWhenReady ? "on" : "off"}${overrides.map((label) => ` \xB7 ${label}`).join("")}${stackBlockersSummary(attention, issues, busy)}`;
760
799
  }
761
800
 
762
801
  // src/tools/stack-list.ts
@@ -803,7 +842,7 @@ var stackWaitSchema = {
803
842
  bounce_id: z2.string().nullable().optional()
804
843
  };
805
844
  function stackWaitSummary(envelope) {
806
- const base = `Stack ${envelope.stackId} \xB7 ${envelope.status}${stackBlockersSummary2(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues)}${envelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`;
845
+ const base = `Stack ${envelope.stackId} \xB7 ${envelope.status}${stackBlockersSummary2(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy)}${envelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`;
807
846
  if (envelope.status !== "waiting" && envelope.status !== "in_progress") return base;
808
847
  const selectors = [
809
848
  `stack_id ${JSON.stringify(envelope.cursor.stackId)}`,
@@ -873,10 +912,10 @@ async function stackStatus(stackId, cfg) {
873
912
  };
874
913
  }
875
914
  const entries = await listMergeQueueEntries2(cfg, { stackId: stack.id });
876
- const { attention, issues, currentCandidate } = stackBlockers2(stack, entries);
915
+ const { attention, issues, currentCandidate, busy, agents } = stackBlockers2(stack, entries);
877
916
  return {
878
917
  summary: stackSummary(stack, entries),
879
- data: { stack, attention, issues, currentCandidate }
918
+ data: { stack, attention, issues, currentCandidate, busy, agents }
880
919
  };
881
920
  } catch (err) {
882
921
  if (isCommandErrorCode5(err, "rate_limited")) {
@@ -938,7 +977,7 @@ async function whoami(cfg) {
938
977
  // src/pr-loop-instructions.ts
939
978
  var MCP_FINDING_DISMISS_MARKER = "mergestorm-loop: dismiss";
940
979
  var MCP_PR_LOOP_INSTRUCTIONS = [
941
- "When you handle Vortex findings from review_get_pr or review_wait_pr:",
980
+ "When you handle Vortex findings from review_wait_pr:",
942
981
  "- Verify each finding against the current checkout. Do not treat the finding text as proven.",
943
982
  "- Prefer the smallest correct patch. Do not refactor around a finding.",
944
983
  "- Patch concrete bugs. A chat-only explanation is not a dismiss.",
@@ -959,13 +998,16 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
959
998
  "- Restack clean does not mean mergeable or CI-green.",
960
999
  "- attention names the blocked PR via prNumber/headSha; currentCandidate is the promote candidate. Attention is not a mutation or authorization to change policy.",
961
1000
  "- verifyHeadSha is a queue verification SHA, not the live PR head.",
1001
+ "- Do not patch a bounced PR while Cyclone or Vortex is still working on it. stack_wait and stack_status enforce this: they return in_progress instead of attention, with busy[] naming the PR, blocker, and agent, and agents carrying that PR's vortexStatus, cycloneStatus, vortexReview, and busy flags. in_progress with a nonempty busy[] means wait. Recheck the live remote PR head right before pushing.",
1002
+ "- seam_pending alone, and a queued Vortex run older than 15 minutes, do not hold. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. A hold can hide a blocker no agent clears, such as a Draft PR; if it looks stuck, refresh stack_status and tell the human.",
1003
+ "- stack.autoEnqueueSettle with action ready or promote means Auto land found Cyclone and Vortex idle on that PR's green head and started its settle clock. action mergeability is published earlier, on a head that may not be green or idle yet.",
962
1004
  "- mg-park-* is a frozen GitHub base, not a repair target: never merge or rebase onto mg-park-*.",
963
1005
  "- Use the mergestorm-bounce-watch skill for the fix-then-watch policy."
964
1006
  ].join("\n");
965
1007
 
966
1008
  // src/server.ts
967
1009
  var MCP_SERVER_NAME = "mergestorm";
968
- var MCP_SERVER_VERSION = "0.1.10";
1010
+ var MCP_SERVER_VERSION = "0.2.0";
969
1011
  function stripNulls(value) {
970
1012
  if (Array.isArray(value)) {
971
1013
  return value.map((item) => stripNulls(item));
@@ -997,7 +1039,10 @@ function createMergestormMcpServer() {
997
1039
  const server2 = new McpServer(
998
1040
  {
999
1041
  name: MCP_SERVER_NAME,
1000
- version: MCP_SERVER_VERSION
1042
+ title: "Mergestorm",
1043
+ version: MCP_SERVER_VERSION,
1044
+ websiteUrl: MCP_WEBSITE_URL,
1045
+ icons: MCP_SERVER_ICONS
1001
1046
  },
1002
1047
  { instructions: `${MCP_PR_LOOP_INSTRUCTIONS}
1003
1048
 
@@ -1010,7 +1055,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1010
1055
  "whoami",
1011
1056
  {
1012
1057
  title: "Who am I",
1013
- description: "Current Mergestorm API key prefix, plan, and API base."
1058
+ description: "Current Mergestorm API key prefix, plan, and API base.",
1059
+ annotations: { readOnlyHint: true }
1014
1060
  },
1015
1061
  async () => {
1016
1062
  try {
@@ -1024,7 +1070,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1024
1070
  "credits",
1025
1071
  {
1026
1072
  title: "Credits",
1027
- description: "Current standard-credit usage and reset time."
1073
+ description: "Current standard-credit usage and reset time.",
1074
+ annotations: { readOnlyHint: true }
1028
1075
  },
1029
1076
  async () => {
1030
1077
  try {
@@ -1041,7 +1088,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1041
1088
  description: "List recent Mergestorm review jobs as job envelopes.",
1042
1089
  inputSchema: {
1043
1090
  limit: z3.number().int().min(1).max(50).optional()
1044
- }
1091
+ },
1092
+ annotations: { readOnlyHint: true }
1045
1093
  },
1046
1094
  async ({ limit }) => {
1047
1095
  try {
@@ -1051,51 +1099,6 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1051
1099
  }
1052
1100
  }
1053
1101
  );
1054
- addTool(
1055
- "review_get",
1056
- {
1057
- title: "Get review",
1058
- description: "Fetch one Mergestorm review job as a job envelope. On rate_limited, wait retry_after_seconds before trying again.",
1059
- inputSchema: {
1060
- job_id: z3.string().min(1)
1061
- }
1062
- },
1063
- async ({ job_id }) => {
1064
- try {
1065
- return ok(await reviewGet(job_id));
1066
- } catch (err) {
1067
- return fail(err);
1068
- }
1069
- }
1070
- );
1071
- addTool(
1072
- "review_get_pr",
1073
- {
1074
- title: "Get GitHub PR Vortex review",
1075
- description: "Fetch the latest GitHub PR Vortex pass from the DB-only endpoint. Review identity is SHA + pass: the envelope carries pass (1 for the first review on a head; a re-review on the same head is the next pass). Pass after_sha to scope to a head, pass to read one exact attempt, or after_pass to read only a later attempt on that head. This tool is read-only. On rate_limited, wait retry_after_seconds before trying again. Verify each finding against the current code; prefer the smallest correct patch; a chat-only skip is not a dismiss (post a PR comment starting with mergestorm-loop: dismiss).",
1076
- inputSchema: {
1077
- owner: z3.string().min(1),
1078
- repo: z3.string().min(1),
1079
- pr_number: z3.number().int().min(1),
1080
- after_sha: z3.string().optional(),
1081
- pass: z3.number().int().min(1).optional(),
1082
- after_pass: z3.number().int().min(1).optional()
1083
- }
1084
- },
1085
- async ({ owner, repo, pr_number, after_sha, pass, after_pass }) => {
1086
- try {
1087
- return ok(
1088
- await reviewGetPr(owner, repo, pr_number, void 0, {
1089
- afterSha: after_sha,
1090
- pass,
1091
- afterPass: after_pass
1092
- })
1093
- );
1094
- } catch (err) {
1095
- return fail(err);
1096
- }
1097
- }
1098
- );
1099
1102
  addTool(
1100
1103
  "review_submit",
1101
1104
  {
@@ -1114,7 +1117,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1114
1117
  idempotency_key: z3.string().min(1).max(128).optional(),
1115
1118
  wait: z3.boolean().optional(),
1116
1119
  timeout_s: z3.number().positive().max(300).optional()
1117
- }
1120
+ },
1121
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
1118
1122
  },
1119
1123
  async (args) => {
1120
1124
  try {
@@ -1128,11 +1132,12 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1128
1132
  "review_wait",
1129
1133
  {
1130
1134
  title: "Wait for review",
1131
- description: "Poll one Mergestorm review job for one 45s slice by default (max 300s). On timeout, returns in_progress; call again with the same job_id until done. Do not pass 300; hosts drop long MCP calls. On rate_limited, wait retry_after_seconds before trying again.",
1135
+ description: "Read one local Mergestorm review job by the job_id that review_submit returned, as a job envelope. timeout_s: 0 reads it once without polling; otherwise it polls for one 45s slice by default (max 300s). On timeout, returns in_progress; call again with the same job_id until done. Do not pass 300; hosts drop long MCP calls. On rate_limited, wait retry_after_seconds before trying again.",
1132
1136
  inputSchema: {
1133
1137
  job_id: z3.string().min(1),
1134
- timeout_s: z3.number().positive().max(300).optional()
1135
- }
1138
+ timeout_s: z3.number().min(0).max(300).optional()
1139
+ },
1140
+ annotations: { readOnlyHint: true }
1136
1141
  },
1137
1142
  async ({ job_id, timeout_s }) => {
1138
1143
  try {
@@ -1146,7 +1151,7 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1146
1151
  "review_wait_pr",
1147
1152
  {
1148
1153
  title: "Wait for GitHub PR Vortex review",
1149
- description: "Poll the DB-only GitHub PR Vortex pass for up to 45s (pass timeout_s to override, max 300). Review identity is SHA + pass. Pass after_sha for the head you pushed. When you already hold an envelope for that same head, also pass after_pass set to its pass so the wait cannot return that attempt again; keep after_pass unchanged across timeout retries and never replace it with the pass of an in-progress envelope. Omit after_pass for a head you have no envelope for (its first pass is 1). This tool is read-only. On timeout, returns in_progress so you can call again. Do not pass 300; hosts drop long MCP calls. On rate_limited, wait retry_after_seconds before trying again. After a resting pass, verify each finding; prefer the smallest correct patch; if you skip, post a PR comment starting with mergestorm-loop: dismiss.",
1154
+ description: "Read the Vortex pass on a GitHub PR from the DB-only endpoint; review identity is head SHA + pass. timeout_s: 0 reads it once without polling; otherwise it polls for one 45s slice by default (max 300) and returns in_progress on timeout so you can call again. Pass after_sha for the head you pushed and pass to read one exact attempt; when you already hold a resting envelope for that head, also pass after_pass set to its pass and keep it unchanged across retries; never replace it with the pass of an in-progress envelope, and omit after_pass for a head you hold no envelope for. With timeout_s: 0, a pass that does not exist yet returns not_found, so after a push use a positive timeout. Do not pass 300; hosts drop long MCP calls. On rate_limited, wait retry_after_seconds before trying again. After a resting pass, verify each finding; prefer the smallest correct patch; if you skip, post a PR comment starting with mergestorm-loop: dismiss.",
1150
1155
  inputSchema: {
1151
1156
  owner: z3.string().min(1),
1152
1157
  repo: z3.string().min(1),
@@ -1154,8 +1159,9 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1154
1159
  after_sha: z3.string().optional(),
1155
1160
  pass: z3.number().int().min(1).optional(),
1156
1161
  after_pass: z3.number().int().min(1).optional(),
1157
- timeout_s: z3.number().positive().max(300).optional()
1158
- }
1162
+ timeout_s: z3.number().min(0).max(300).optional()
1163
+ },
1164
+ annotations: { readOnlyHint: true }
1159
1165
  },
1160
1166
  async ({ owner, repo, pr_number, after_sha, pass, after_pass, timeout_s }) => {
1161
1167
  try {
@@ -1180,7 +1186,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1180
1186
  {
1181
1187
  title: "Adopt PR stack",
1182
1188
  description: "Adopt an open GitHub PR chain into a Mergestorm stack. auto_land is boolean; auto_review and auto_patch accept true, false, or null, where null clears the override. Omitted auto_land seeds from auto_land_default; omitted auto_review and auto_patch overrides are not seeded. Never changes account settings. Adoption requires a Cyclone GitHub App install; without it the API returns cyclone_not_connected \u2014 do not retry, tell the human. Read stack_status with result.stack.id afterward to verify policy and Cyclone ownership. With 2+ PRs, adopt moves the bottom PR's GitHub base to mg-stack-<n> and parks PR3+ on an mg-park-* freeze; a 1-PR stack stays on main. Leave those bases. Never retarget the bottom back to main.",
1183
- inputSchema: stackAdoptSchema
1189
+ inputSchema: stackAdoptSchema,
1190
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
1184
1191
  },
1185
1192
  async (args) => {
1186
1193
  try {
@@ -1194,7 +1201,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1194
1201
  "stack_list",
1195
1202
  {
1196
1203
  title: "List stacks",
1197
- description: "List the current user's Mergestorm stacks. This tool is read-only."
1204
+ description: "List the current user's Mergestorm stacks.",
1205
+ annotations: { readOnlyHint: true }
1198
1206
  },
1199
1207
  async () => {
1200
1208
  try {
@@ -1214,7 +1222,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1214
1222
  auto_land: z3.boolean().optional(),
1215
1223
  auto_review: z3.boolean().nullable().optional(),
1216
1224
  auto_patch: z3.boolean().nullable().optional()
1217
- }
1225
+ },
1226
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1218
1227
  },
1219
1228
  async ({ stack_id, auto_land, auto_review, auto_patch }) => {
1220
1229
  try {
@@ -1234,10 +1243,11 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1234
1243
  "stack_status",
1235
1244
  {
1236
1245
  title: "Get stack status",
1237
- description: "Fetch one owned Mergestorm stack with enriched checks and agent state, plus attention, issues, and currentCandidate from the same blocker rules as stack_wait, read against that stack's own merge queue. This tool is read-only. trunkBranch is mg-stack-<n> for an adopted 2+ PR stack and matches the bottom open PR's GitHub base; an mg-park-* parentBranch is a freeze. Neither is drift to fix.",
1246
+ description: "Fetch one owned Mergestorm stack with enriched checks and agent state, plus attention, issues, and currentCandidate from the same blocker rules as stack_wait, read against that stack's own merge queue. trunkBranch is mg-stack-<n> for an adopted 2+ PR stack and matches the bottom open PR's GitHub base; an mg-park-* parentBranch is a freeze. Neither is drift to fix.",
1238
1247
  inputSchema: {
1239
1248
  stack_id: z3.string().min(1)
1240
- }
1249
+ },
1250
+ annotations: { readOnlyHint: true }
1241
1251
  },
1242
1252
  async ({ stack_id }) => {
1243
1253
  try {
@@ -1251,8 +1261,9 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1251
1261
  "stack_wait",
1252
1262
  {
1253
1263
  title: "Wait for stack attention",
1254
- description: "Wait for a stack to need attention. Returns a mergestorm.stack_watch/v1 envelope; timeout_s: 0 returns one snapshot and timeout_s: 1-45 waits up to that many seconds, returning the current status (waiting or in_progress) if a snapshot was read and no attention is found. A timeout with either of those statuses is not a failure; waiting and in_progress end this slice, and the next call is stack_wait again with the same cursor. A timeout of failed means no assessment was produced. This tool is read-only. Attention never calls for changing a PR's GitHub base.",
1255
- inputSchema: stackWaitSchema
1264
+ description: "Wait for a stack to need attention. Returns a mergestorm.stack_watch/v1 envelope; timeout_s: 0 returns one snapshot and timeout_s: 1-45 waits up to that many seconds, returning the current status (waiting or in_progress) if a snapshot was read and no attention is found. A timeout with either of those statuses is not a failure; waiting and in_progress end this slice, and the next call is stack_wait again with the same cursor. A timeout of failed means no assessment was produced. While Cyclone or Vortex is still working on the blocked PR it returns in_progress, not attention, with busy[] naming the PR, blocker, and agent; agents carries that PR's vortexStatus, cycloneStatus, vortexReview, and busy flags. Attention never calls for changing a PR's GitHub base.",
1265
+ inputSchema: stackWaitSchema,
1266
+ annotations: { readOnlyHint: true }
1256
1267
  },
1257
1268
  async (args, extra) => {
1258
1269
  try {
@@ -1267,10 +1278,11 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1267
1278
  "queue_status",
1268
1279
  {
1269
1280
  title: "Get merge queue status",
1270
- description: "List live merge queue entries (position >= 1) plus the newest bounced entries (position 0) with bounceDetail for the current user, optionally filtered by stack. This tool is read-only.",
1281
+ description: "List live merge queue entries (position >= 1) plus the newest bounced entries (position 0) with bounceDetail for the current user, optionally filtered by stack.",
1271
1282
  inputSchema: {
1272
1283
  stack_id: z3.string().min(1).optional()
1273
- }
1284
+ },
1285
+ annotations: { readOnlyHint: true }
1274
1286
  },
1275
1287
  async ({ stack_id }) => {
1276
1288
  try {
@@ -1284,7 +1296,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1284
1296
  "settings_get",
1285
1297
  {
1286
1298
  title: "Get settings",
1287
- description: "Read the Bearer /api/v1/settings toggles, including auto_patch_enabled, auto_land_settle_seconds (how long Auto land waits before queueing), and cyclone_connected. This tool is read-only."
1299
+ description: "Read the Bearer /api/v1/settings toggles, including auto_patch_enabled, auto_land_settle_seconds (how long Auto land waits before queueing), and cyclone_connected.",
1300
+ annotations: { readOnlyHint: true }
1288
1301
  },
1289
1302
  async () => {
1290
1303
  try {
@@ -1309,7 +1322,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1309
1322
  // Preserve these args so the handler rejects them even alongside a writable key.
1310
1323
  cyclone_connected: z3.unknown().optional(),
1311
1324
  github_connected: z3.unknown().optional()
1312
- }
1325
+ },
1326
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1313
1327
  },
1314
1328
  async (args) => {
1315
1329
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm-mcp",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "description": "Mergestorm MCP server — identity, review, and read-only stack tools over stdio",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
- "mergestorm": "0.3.24",
32
+ "mergestorm": "0.3.25",
33
33
  "zod": "^3.24.2"
34
34
  },
35
35
  "devDependencies": {