mergestorm-mcp 0.1.9 → 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 +250 -214
  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),
@@ -592,6 +631,17 @@ function settingsPatchFromArgs(args) {
592
631
  const value = args[key];
593
632
  if (value === void 0) continue;
594
633
  const row = BEARER_SETTINGS.find((row2) => row2.key === key);
634
+ if ("kind" in row && row.kind === "seconds") {
635
+ if (typeof value !== "number" || !Number.isInteger(value) || value < row.min || value > row.max) {
636
+ throw new CommandError8(
637
+ `${key} takes a whole number of seconds from ${row.min} through ${row.max}.`,
638
+ 2,
639
+ "usage"
640
+ );
641
+ }
642
+ Object.assign(patch, { [key]: value });
643
+ continue;
644
+ }
595
645
  if ("kind" in row) {
596
646
  const valid = row.kind === "logins" ? Array.isArray(value) && value.every((login) => typeof login === "string") : typeof value === "string" && row.values.includes(value);
597
647
  if (!valid) throw new CommandError8(`Invalid value for ${key}.`, 2, "usage");
@@ -744,8 +794,8 @@ function stackSummary(stack, entries = []) {
744
794
  if (stack.cycloneOwnerMatch) {
745
795
  overrides.push(`cyclone-owner ${stack.cycloneOwnerMatch}`);
746
796
  }
747
- const { attention, issues } = stackBlockers(stack, entries);
748
- 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)}`;
749
799
  }
750
800
 
751
801
  // src/tools/stack-list.ts
@@ -791,6 +841,17 @@ var stackWaitSchema = {
791
841
  after_finished_at: z2.string().nullable().optional(),
792
842
  bounce_id: z2.string().nullable().optional()
793
843
  };
844
+ function stackWaitSummary(envelope) {
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" : ""}`;
846
+ if (envelope.status !== "waiting" && envelope.status !== "in_progress") return base;
847
+ const selectors = [
848
+ `stack_id ${JSON.stringify(envelope.cursor.stackId)}`,
849
+ `enrolled_head_sha ${JSON.stringify(envelope.cursor.enrolledHeadSha)}`,
850
+ ...envelope.cursor.afterFinishedAt !== void 0 ? [`after_finished_at ${JSON.stringify(envelope.cursor.afterFinishedAt)}`] : [],
851
+ ...envelope.cursor.bounceId !== void 0 ? [`bounce_id ${JSON.stringify(envelope.cursor.bounceId)}`] : []
852
+ ];
853
+ return `${base} \xB7 watch not finished: call stack_wait again with timeout_s 45 and the same cursor: ${selectors.join(", ")}`;
854
+ }
794
855
  async function stackWait(input, cfg, deps = {}) {
795
856
  const args = z2.object(stackWaitSchema).parse(input);
796
857
  const cursor = args.enrolled_head_sha !== void 0 || args.after_finished_at !== void 0 || args.bounce_id !== void 0 ? {
@@ -809,7 +870,7 @@ async function stackWait(input, cfg, deps = {}) {
809
870
  } catch (err) {
810
871
  if (err instanceof StackWatchError) {
811
872
  return {
812
- summary: `Stack ${err.lastEnvelope.stackId} \xB7 ${err.lastEnvelope.status}${stackBlockersSummary2(err.lastEnvelope.blocker && err.lastEnvelope.prNumber != null ? { prNumber: err.lastEnvelope.prNumber, blocker: err.lastEnvelope.blocker } : null, err.lastEnvelope.issues)}${err.lastEnvelope.assessment === "unavailable" ? " \xB7 assessment unavailable" : ""}`,
873
+ summary: stackWaitSummary(err.lastEnvelope),
813
874
  data: {
814
875
  ...err.lastEnvelope,
815
876
  ...err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
@@ -821,7 +882,7 @@ async function stackWait(input, cfg, deps = {}) {
821
882
  envelope = err.lastEnvelope;
822
883
  }
823
884
  return {
824
- summary: `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" : ""}`,
885
+ summary: stackWaitSummary(envelope),
825
886
  data: envelope
826
887
  };
827
888
  }
@@ -851,10 +912,10 @@ async function stackStatus(stackId, cfg) {
851
912
  };
852
913
  }
853
914
  const entries = await listMergeQueueEntries2(cfg, { stackId: stack.id });
854
- const { attention, issues, currentCandidate } = stackBlockers2(stack, entries);
915
+ const { attention, issues, currentCandidate, busy, agents } = stackBlockers2(stack, entries);
855
916
  return {
856
917
  summary: stackSummary(stack, entries),
857
- data: { stack, attention, issues, currentCandidate }
918
+ data: { stack, attention, issues, currentCandidate, busy, agents }
858
919
  };
859
920
  } catch (err) {
860
921
  if (isCommandErrorCode5(err, "rate_limited")) {
@@ -916,7 +977,7 @@ async function whoami(cfg) {
916
977
  // src/pr-loop-instructions.ts
917
978
  var MCP_FINDING_DISMISS_MARKER = "mergestorm-loop: dismiss";
918
979
  var MCP_PR_LOOP_INSTRUCTIONS = [
919
- "When you handle Vortex findings from review_get_pr or review_wait_pr:",
980
+ "When you handle Vortex findings from review_wait_pr:",
920
981
  "- Verify each finding against the current checkout. Do not treat the finding text as proven.",
921
982
  "- Prefer the smallest correct patch. Do not refactor around a finding.",
922
983
  "- Patch concrete bugs. A chat-only explanation is not a dismiss.",
@@ -937,13 +998,16 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
937
998
  "- Restack clean does not mean mergeable or CI-green.",
938
999
  "- attention names the blocked PR via prNumber/headSha; currentCandidate is the promote candidate. Attention is not a mutation or authorization to change policy.",
939
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.",
940
1004
  "- mg-park-* is a frozen GitHub base, not a repair target: never merge or rebase onto mg-park-*.",
941
1005
  "- Use the mergestorm-bounce-watch skill for the fix-then-watch policy."
942
1006
  ].join("\n");
943
1007
 
944
1008
  // src/server.ts
945
1009
  var MCP_SERVER_NAME = "mergestorm";
946
- var MCP_SERVER_VERSION = "0.1.9";
1010
+ var MCP_SERVER_VERSION = "0.2.0";
947
1011
  function stripNulls(value) {
948
1012
  if (Array.isArray(value)) {
949
1013
  return value.map((item) => stripNulls(item));
@@ -975,7 +1039,10 @@ function createMergestormMcpServer() {
975
1039
  const server2 = new McpServer(
976
1040
  {
977
1041
  name: MCP_SERVER_NAME,
978
- version: MCP_SERVER_VERSION
1042
+ title: "Mergestorm",
1043
+ version: MCP_SERVER_VERSION,
1044
+ websiteUrl: MCP_WEBSITE_URL,
1045
+ icons: MCP_SERVER_ICONS
979
1046
  },
980
1047
  { instructions: `${MCP_PR_LOOP_INSTRUCTIONS}
981
1048
 
@@ -988,7 +1055,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
988
1055
  "whoami",
989
1056
  {
990
1057
  title: "Who am I",
991
- 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 }
992
1060
  },
993
1061
  async () => {
994
1062
  try {
@@ -1002,7 +1070,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1002
1070
  "credits",
1003
1071
  {
1004
1072
  title: "Credits",
1005
- description: "Current standard-credit usage and reset time."
1073
+ description: "Current standard-credit usage and reset time.",
1074
+ annotations: { readOnlyHint: true }
1006
1075
  },
1007
1076
  async () => {
1008
1077
  try {
@@ -1019,7 +1088,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1019
1088
  description: "List recent Mergestorm review jobs as job envelopes.",
1020
1089
  inputSchema: {
1021
1090
  limit: z3.number().int().min(1).max(50).optional()
1022
- }
1091
+ },
1092
+ annotations: { readOnlyHint: true }
1023
1093
  },
1024
1094
  async ({ limit }) => {
1025
1095
  try {
@@ -1029,51 +1099,6 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1029
1099
  }
1030
1100
  }
1031
1101
  );
1032
- addTool(
1033
- "review_get",
1034
- {
1035
- title: "Get review",
1036
- description: "Fetch one Mergestorm review job as a job envelope. On rate_limited, wait retry_after_seconds before trying again.",
1037
- inputSchema: {
1038
- job_id: z3.string().min(1)
1039
- }
1040
- },
1041
- async ({ job_id }) => {
1042
- try {
1043
- return ok(await reviewGet(job_id));
1044
- } catch (err) {
1045
- return fail(err);
1046
- }
1047
- }
1048
- );
1049
- addTool(
1050
- "review_get_pr",
1051
- {
1052
- title: "Get GitHub PR Vortex review",
1053
- 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).",
1054
- inputSchema: {
1055
- owner: z3.string().min(1),
1056
- repo: z3.string().min(1),
1057
- pr_number: z3.number().int().min(1),
1058
- after_sha: z3.string().optional(),
1059
- pass: z3.number().int().min(1).optional(),
1060
- after_pass: z3.number().int().min(1).optional()
1061
- }
1062
- },
1063
- async ({ owner, repo, pr_number, after_sha, pass, after_pass }) => {
1064
- try {
1065
- return ok(
1066
- await reviewGetPr(owner, repo, pr_number, void 0, {
1067
- afterSha: after_sha,
1068
- pass,
1069
- afterPass: after_pass
1070
- })
1071
- );
1072
- } catch (err) {
1073
- return fail(err);
1074
- }
1075
- }
1076
- );
1077
1102
  addTool(
1078
1103
  "review_submit",
1079
1104
  {
@@ -1092,7 +1117,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1092
1117
  idempotency_key: z3.string().min(1).max(128).optional(),
1093
1118
  wait: z3.boolean().optional(),
1094
1119
  timeout_s: z3.number().positive().max(300).optional()
1095
- }
1120
+ },
1121
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
1096
1122
  },
1097
1123
  async (args) => {
1098
1124
  try {
@@ -1106,11 +1132,12 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1106
1132
  "review_wait",
1107
1133
  {
1108
1134
  title: "Wait for review",
1109
- 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.",
1110
1136
  inputSchema: {
1111
1137
  job_id: z3.string().min(1),
1112
- timeout_s: z3.number().positive().max(300).optional()
1113
- }
1138
+ timeout_s: z3.number().min(0).max(300).optional()
1139
+ },
1140
+ annotations: { readOnlyHint: true }
1114
1141
  },
1115
1142
  async ({ job_id, timeout_s }) => {
1116
1143
  try {
@@ -1124,7 +1151,7 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1124
1151
  "review_wait_pr",
1125
1152
  {
1126
1153
  title: "Wait for GitHub PR Vortex review",
1127
- 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.",
1128
1155
  inputSchema: {
1129
1156
  owner: z3.string().min(1),
1130
1157
  repo: z3.string().min(1),
@@ -1132,8 +1159,9 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1132
1159
  after_sha: z3.string().optional(),
1133
1160
  pass: z3.number().int().min(1).optional(),
1134
1161
  after_pass: z3.number().int().min(1).optional(),
1135
- timeout_s: z3.number().positive().max(300).optional()
1136
- }
1162
+ timeout_s: z3.number().min(0).max(300).optional()
1163
+ },
1164
+ annotations: { readOnlyHint: true }
1137
1165
  },
1138
1166
  async ({ owner, repo, pr_number, after_sha, pass, after_pass, timeout_s }) => {
1139
1167
  try {
@@ -1158,7 +1186,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1158
1186
  {
1159
1187
  title: "Adopt PR stack",
1160
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.",
1161
- inputSchema: stackAdoptSchema
1189
+ inputSchema: stackAdoptSchema,
1190
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
1162
1191
  },
1163
1192
  async (args) => {
1164
1193
  try {
@@ -1172,7 +1201,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1172
1201
  "stack_list",
1173
1202
  {
1174
1203
  title: "List stacks",
1175
- 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 }
1176
1206
  },
1177
1207
  async () => {
1178
1208
  try {
@@ -1192,7 +1222,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1192
1222
  auto_land: z3.boolean().optional(),
1193
1223
  auto_review: z3.boolean().nullable().optional(),
1194
1224
  auto_patch: z3.boolean().nullable().optional()
1195
- }
1225
+ },
1226
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1196
1227
  },
1197
1228
  async ({ stack_id, auto_land, auto_review, auto_patch }) => {
1198
1229
  try {
@@ -1212,10 +1243,11 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1212
1243
  "stack_status",
1213
1244
  {
1214
1245
  title: "Get stack status",
1215
- 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.",
1216
1247
  inputSchema: {
1217
1248
  stack_id: z3.string().min(1)
1218
- }
1249
+ },
1250
+ annotations: { readOnlyHint: true }
1219
1251
  },
1220
1252
  async ({ stack_id }) => {
1221
1253
  try {
@@ -1229,8 +1261,9 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1229
1261
  "stack_wait",
1230
1262
  {
1231
1263
  title: "Wait for stack attention",
1232
- 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. A timeout of failed means no assessment was produced. This tool is read-only. Attention never calls for changing a PR's GitHub base.",
1233
- 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 }
1234
1267
  },
1235
1268
  async (args, extra) => {
1236
1269
  try {
@@ -1245,10 +1278,11 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1245
1278
  "queue_status",
1246
1279
  {
1247
1280
  title: "Get merge queue status",
1248
- 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.",
1249
1282
  inputSchema: {
1250
1283
  stack_id: z3.string().min(1).optional()
1251
- }
1284
+ },
1285
+ annotations: { readOnlyHint: true }
1252
1286
  },
1253
1287
  async ({ stack_id }) => {
1254
1288
  try {
@@ -1262,7 +1296,8 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1262
1296
  "settings_get",
1263
1297
  {
1264
1298
  title: "Get settings",
1265
- description: "Read the Bearer /api/v1/settings toggles, including auto_patch_enabled 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 }
1266
1301
  },
1267
1302
  async () => {
1268
1303
  try {
@@ -1276,18 +1311,19 @@ ${MCP_STACK_WATCH_INSTRUCTIONS}` }
1276
1311
  "settings_set",
1277
1312
  {
1278
1313
  title: "Update settings",
1279
- description: "Update writable Bearer /api/v1/settings values and return the stored result. At least one key is required. cyclone_connected and github_connected are read-only and cannot be set.",
1314
+ description: "Update writable Bearer /api/v1/settings values and return the stored result. At least one key is required. auto_land_settle_seconds is a whole number of seconds from 15 through 300; it applies to settle clocks that start after the write. cyclone_connected and github_connected are read-only and cannot be set.",
1280
1315
  inputSchema: {
1281
1316
  ...Object.fromEntries(
1282
1317
  BEARER_SETTINGS2.map((row) => [
1283
1318
  row.key,
1284
- ("kind" in row ? row.kind === "logins" ? z3.array(z3.string()) : z3.enum(row.values) : z3.boolean()).optional()
1319
+ ("kind" in row ? row.kind === "logins" ? z3.array(z3.string()) : row.kind === "seconds" ? z3.number().int().min(row.min).max(row.max) : z3.enum(row.values) : z3.boolean()).optional()
1285
1320
  ])
1286
1321
  ),
1287
1322
  // Preserve these args so the handler rejects them even alongside a writable key.
1288
1323
  cyclone_connected: z3.unknown().optional(),
1289
1324
  github_connected: z3.unknown().optional()
1290
- }
1325
+ },
1326
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }
1291
1327
  },
1292
1328
  async (args) => {
1293
1329
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm-mcp",
3
- "version": "0.1.9",
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.23",
32
+ "mergestorm": "0.3.25",
33
33
  "zod": "^3.24.2"
34
34
  },
35
35
  "devDependencies": {