mergestorm-mcp 0.2.20 → 0.2.22

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 +421 -129
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mergestorm-mcp
2
2
 
3
- Stdio MCP server for Mergestorm. Tools: `whoami`, `credits`, `review_list`, `review_submit`, `review_wait`, `review_wait_pr`, `review_dismiss`, `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`, `review_dismiss`, `issue_create`, `stack_adopt`, `stack_list`, `stack_publish`, `stack_publish_status`, `stack_set`, `stack_status`, `stack_wait`, `queue_status`, `settings_get`, `settings_set`, `repo_settings_get`, `repo_settings_set`.
4
4
 
5
5
  Every tool declares MCP `annotations` for read-only access, destructive changes, idempotence, and external systems. Reads are read-only; submission, adoption, dismissal, and policy changes are writes. Tools that can change existing policy, topology, or review gates are marked potentially destructive even when some inputs only preview or read. Annotations describe effects; the client still controls approval and authorization.
6
6
 
@@ -16,10 +16,14 @@ Every tool declares MCP `annotations` for read-only access, destructive changes,
16
16
  - **Gates.** `gate.seam.cleared` is true only when every finding of that integration review is dismissed and it is still the member's current seam verdict. `gate.vortex.cleared` is true once every Vortex Blocker (error finding) at the live head is dismissed (every Core review merged into that head) and no Vortex review of the head is running or incomplete; with a warning still open the head reads `comment`, which Auto land accepts; otherwise `gate.vortex.reason` and `message` say what still holds it: that head's Vortex verdict becomes approve for Auto land, the stack watch, the dashboard and Cyclone. A partial dismissal clears nothing. CI and Auto land policy are unchanged (`gate.other_gates: "unchanged"`). For a stack PR the result carries `watch` with the next `stack_wait` call.
17
17
  - **No resurfacing.** Vortex does not raise a dismissed finding again on the same diff: the same head, or a restack whose diff is unchanged.
18
18
 
19
- The stack tools expose reads plus one scoped policy write:
19
+ `issue_create({ title, body?, labels?, owner?, repo?, cwd? })` opens a GitHub issue through Mergestorm with no GitHub credentials: the Surge App creates it and names the caller in the body. It defaults to the origin repository of `cwd`. Labels must already exist on the repository. The same title again returns the open issue it already made (`created: false`), so a retry does not duplicate it. Put `Fixes #<number>` in the commit or pull request body.
20
+
21
+ The stack tools expose reads, one scoped policy write, and publication of your own branch:
20
22
 
21
23
  - `stack_adopt({ owner, repo, pr_number, auto_land?, auto_review?, auto_patch?, auto_resolve_conflicts?, auto_fix_ci? })` adopts an open PR chain with optional per-stack policy. Omitted policy does not force Cyclone off. `auto_review`, `auto_patch`, `auto_resolve_conflicts`, and `auto_fix_ci` accept `true`, `false`, or `null`; `null` clears the override. Read `stack_status` with the returned `result.stack.id` to verify policy and Cyclone ownership. Adoption needs the Mergestorm Surge GitHub App on the repository (Cyclone is the auto-patch App). A refusal keeps the code `cyclone_not_connected` or `cyclone_not_installed`; its message, and `error.reason` starting with `surge_` when Surge is the one to install, say which App is missing.
22
24
  - `stack_list` returns the current API key owner's registered stacks.
25
+ - `stack_publish({ cwd?, owner?, repo?, branch?, base?, title?, body?, replace_head?, auto_land? })` publishes a local branch with no GitHub credentials. It reads the branch's commits from the repository at `cwd` and sends them to Mergestorm, which writes them and the branch on GitHub as the Surge App, opens a ready pull request, and registers the stack with Auto patch pinned off, Auto land on for a new stack, and Auto review left to the account. It never runs `gh`, `git push` or `git fetch`. Call it again for every later commit to that branch, and after a lost response: it resumes and does not duplicate the branch, commits, pull request or stack. It refuses with `head_changed` when the branch on GitHub holds work the local branch does not; `replace_head` names the exact GitHub head to replace. Limits: 20 commits, 500 changed paths, 2 MiB a file, 8 MiB in total; no submodules or merge commits. The result carries the verified head commit and tree, the pull request, the stack and its `watch`.
26
+ - `stack_publish_status({ cwd?, owner?, repo?, branch? })` reads how far a publication got (`absent`, `pushed`, `opened`, `registered`) with the head on GitHub, the pull request and the stack's Auto patch and Auto land state. It changes nothing.
23
27
  - `stack_set({ stack_id, auto_land?, auto_review?, auto_patch?, auto_resolve_conflicts?, auto_fix_ci? })` sets per-stack policy on one owned stack. `auto_land` flips Auto land. `auto_review`, `auto_patch`, `auto_resolve_conflicts`, and `auto_fix_ci` pin Vortex auto-review, Cyclone auto-patch, Auto-resolve merge conflicts, or Auto-fix failing CI 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.
24
28
  - `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.
25
29
  - `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 blocker no agent run clears (a merge conflict vs the live parent, a restack `Conflict` or `Restack failed`, a Draft PR, a seam review pending with no review running, "Seam review stuck in reviewing, no review running", "Seam re-review pending, no review running", "Seam verdict is for an older head, no review running", or a review skipped for review quota) is still named during that `in_progress`, in `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"` and `waitingOn` listing the busy agents; act when a snapshot after the agents finish returns `attention`, re-reading the blocker then. For the three stuck-seam blockers, `repair` has kind `seam_review_stuck`: do not patch or push; tell the human to click Continue, which re-queues the seam review at the live head. `repair` is included when another concrete repair hint is available. Blockers the agents can change keep `blocker` and `actAfter` null. If a hold looks stuck, refresh `stack_status` and tell the human.
package/dist/index.js CHANGED
@@ -5,8 +5,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
 
6
6
  // src/server.ts
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
- import { z as z3 } from "zod";
9
- import { CommandError as CommandError11, BEARER_SETTINGS as BEARER_SETTINGS2 } from "mergestorm/client";
8
+ import { z as z5 } from "zod";
9
+ import { CommandError as CommandError14, BEARER_SETTINGS as BEARER_SETTINGS2 } from "mergestorm/client";
10
10
 
11
11
  // src/brand-icons.ts
12
12
  var MARK_RECTS = [
@@ -75,10 +75,59 @@ async function credits(cfg) {
75
75
  };
76
76
  }
77
77
 
78
+ // src/tools/issue-create.ts
79
+ import { CommandError as CommandError2, openStackIssue, parseGithubOriginRepo } from "mergestorm/client";
80
+ import { z } from "zod";
81
+ var issueCreateSchema = {
82
+ title: z.string().trim().min(1).max(256),
83
+ body: z.string().max(6e4).optional(),
84
+ labels: z.array(z.string().trim().min(1).max(50)).max(10).optional(),
85
+ owner: z.string().trim().regex(/^[A-Za-z0-9_.-]{1,100}$/).optional(),
86
+ repo: z.string().trim().regex(/^[A-Za-z0-9_.-]{1,100}$/).optional(),
87
+ cwd: z.string().trim().min(1).optional()
88
+ };
89
+ var inputSchema = z.object(issueCreateSchema);
90
+ async function issueCreate(input, cfg, deps = {}) {
91
+ const parsed = inputSchema.safeParse(input);
92
+ if (!parsed.success || Boolean(parsed.data.owner) !== Boolean(parsed.data.repo)) {
93
+ const message = "issue_create needs a one-line title of at most 256 characters, and owner and repo together";
94
+ return { summary: message, data: { error: { code: "invalid_input", message } }, isError: true };
95
+ }
96
+ const { title, body, labels, owner, repo, cwd } = parsed.data;
97
+ try {
98
+ const target2 = owner && repo ? { owner, repo } : (deps.parseGithubOriginRepo ?? parseGithubOriginRepo)(cwd);
99
+ if (!target2) {
100
+ const message = "Could not read a GitHub owner/repo from origin. Pass owner and repo.";
101
+ return { summary: message, data: { error: { code: "invalid_input", message } }, isError: true };
102
+ }
103
+ const issue = await (deps.openStackIssue ?? openStackIssue)(
104
+ { owner: target2.owner, repo: target2.repo, title, body: body ?? "", labels: labels ?? [] },
105
+ cfg
106
+ );
107
+ return {
108
+ summary: `${issue.created ? "Opened" : "Already open:"} ${target2.owner}/${target2.repo}#${issue.number} ${issue.url}. Put "Fixes #${issue.number}" in the commit or pull request body.`,
109
+ data: { owner: target2.owner, repo: target2.repo, ...issue }
110
+ };
111
+ } catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ return {
114
+ summary: message,
115
+ data: {
116
+ error: {
117
+ code: err instanceof CommandError2 ? err.code ?? "issue_create_failed" : "issue_create_failed",
118
+ message,
119
+ ...err instanceof CommandError2 && err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
120
+ }
121
+ },
122
+ isError: true
123
+ };
124
+ }
125
+ }
126
+
78
127
  // src/tools/queue-status.ts
79
128
  import {
80
129
  apiFetch,
81
- CommandError as CommandError2,
130
+ CommandError as CommandError3,
82
131
  isCommandErrorCode,
83
132
  loadConfig as loadConfig2,
84
133
  rateLimitedMessage,
@@ -106,12 +155,12 @@ async function queueStatus(stackId, cfg) {
106
155
  id ? `/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}` : "/api/v1/stacks/queue"
107
156
  );
108
157
  if (status === 404) {
109
- throw new CommandError2(
158
+ throw new CommandError3(
110
159
  "Merge queue route returned 404. Deploy the API."
111
160
  );
112
161
  }
113
162
  if (status === 429) {
114
- throw new CommandError2(
163
+ throw new CommandError3(
115
164
  rateLimitedMessage(retryAfterSeconds),
116
165
  REVIEW_EXIT.rate_limited,
117
166
  "rate_limited",
@@ -119,13 +168,13 @@ async function queueStatus(stackId, cfg) {
119
168
  );
120
169
  }
121
170
  if (status !== 200) {
122
- throw new CommandError2(
171
+ throw new CommandError3(
123
172
  `Failed to get queue status (HTTP ${status}): ${JSON.stringify(body)}`
124
173
  );
125
174
  }
126
175
  const entries = body.entries;
127
176
  if (!Array.isArray(entries)) {
128
- throw new CommandError2(
177
+ throw new CommandError3(
129
178
  `Failed to get queue status (HTTP 200): ${JSON.stringify(body)}`
130
179
  );
131
180
  }
@@ -190,13 +239,13 @@ import {
190
239
  stackWatchBackgroundCommand,
191
240
  stackWatchObligation
192
241
  } from "mergestorm/client";
193
- import { z } from "zod";
242
+ import { z as z2 } from "zod";
194
243
  var stackWaitSchema = {
195
- stack_id: z.string().trim().min(1),
196
- timeout_s: z.number().finite().min(0).max(45).default(45),
197
- enrolled_head_sha: z.string().nullable().optional(),
198
- after_finished_at: z.string().nullable().optional(),
199
- bounce_id: z.string().nullable().optional()
244
+ stack_id: z2.string().trim().min(1),
245
+ timeout_s: z2.number().finite().min(0).max(45).default(45),
246
+ enrolled_head_sha: z2.string().nullable().optional(),
247
+ after_finished_at: z2.string().nullable().optional(),
248
+ bounce_id: z2.string().nullable().optional()
200
249
  };
201
250
  function repairSummary(repair) {
202
251
  if (!repair) return "";
@@ -233,7 +282,7 @@ function stackWaitStatusLine(envelope) {
233
282
  return `${base} \xB7 watch not finished: only with a confirmed host notification that resumes this task, run ${stackWatchBackgroundCommand(envelope.stackId)} as a background command and end your turn; otherwise call stack_wait again with timeout_s 45 and the same cursor: ${selectors.join(", ")}`;
234
283
  }
235
284
  async function stackWait(input, cfg, deps = {}) {
236
- const args = z.object(stackWaitSchema).parse(input);
285
+ const args = z2.object(stackWaitSchema).parse(input);
237
286
  const cursor = args.enrolled_head_sha !== void 0 || args.after_finished_at !== void 0 || args.bounce_id !== void 0 ? {
238
287
  stackId: args.stack_id,
239
288
  enrolledHeadSha: args.enrolled_head_sha ?? null,
@@ -346,7 +395,7 @@ import { createHash } from "node:crypto";
346
395
  import { realpath } from "node:fs/promises";
347
396
  import { relative, resolve, sep } from "node:path";
348
397
  import {
349
- CommandError as CommandError3,
398
+ CommandError as CommandError4,
350
399
  REVIEW_THREAD_SLUG_RE,
351
400
  ReviewPollTimeoutError,
352
401
  collectReviewInput,
@@ -401,7 +450,7 @@ function assertPinnableSpecialists(specialists) {
401
450
  (id) => !PINNABLE_SPECIALISTS.includes(id)
402
451
  );
403
452
  if (unknown.length === 0) return;
404
- throw new CommandError3(
453
+ throw new CommandError4(
405
454
  `Unknown specialist: ${unknown.join(", ")}. Valid lanes: ${PINNABLE_SPECIALISTS.join(", ")}. governance and seam cannot be requested.`,
406
455
  2,
407
456
  "usage"
@@ -413,7 +462,7 @@ function mcpSandboxRoot() {
413
462
  function waitTimeoutMs(timeout_s, defaultSeconds = MCP_PR_WAIT_DEFAULT_S) {
414
463
  const seconds = timeout_s ?? defaultSeconds;
415
464
  if (!Number.isFinite(seconds) || seconds <= 0) {
416
- throw new CommandError3("timeout_s must be a positive number of seconds", 2, "usage");
465
+ throw new CommandError4("timeout_s must be a positive number of seconds", 2, "usage");
417
466
  }
418
467
  return Math.max(1, Math.round(Math.min(seconds, MCP_WAIT_CEILING_S) * 1e3));
419
468
  }
@@ -446,11 +495,11 @@ async function resolveInput(input) {
446
495
  ]);
447
496
  const relToSandbox = relative(sandboxReal, cwdReal);
448
497
  if (relToSandbox === ".." || relToSandbox.startsWith(`..${sep}`)) {
449
- throw new CommandError3("cwd must be inside the configured sandbox root", 2, "usage");
498
+ throw new CommandError4("cwd must be inside the configured sandbox root", 2, "usage");
450
499
  }
451
500
  } catch (err) {
452
- if (err instanceof CommandError3) throw err;
453
- throw new CommandError3("cwd must be inside the configured sandbox root", 2, "usage");
501
+ if (err instanceof CommandError4) throw err;
502
+ throw new CommandError4("cwd must be inside the configured sandbox root", 2, "usage");
454
503
  }
455
504
  const base = input.base?.trim() || discoverTrunk(cwd);
456
505
  return collectReviewInput(base, head, cwd, sandboxRoot);
@@ -461,10 +510,10 @@ async function reviewSubmit(input, cfg, opts = {}) {
461
510
  const timeoutMs = waitTimeoutMs(input.timeout_s);
462
511
  assertPinnableSpecialists(input.specialists);
463
512
  if (input.thread && !REVIEW_THREAD_SLUG_RE.test(input.thread)) {
464
- throw new CommandError3("thread must be 1\u2013120 chars [A-Za-z0-9._/-]", 2, "usage");
513
+ throw new CommandError4("thread must be 1\u2013120 chars [A-Za-z0-9._/-]", 2, "usage");
465
514
  }
466
515
  if (input.context === "-") {
467
- throw new CommandError3(
516
+ throw new CommandError4(
468
517
  'context "-" (stdin) is not available over MCP; pass the text directly',
469
518
  2,
470
519
  "usage"
@@ -514,7 +563,7 @@ async function reviewSubmit(input, cfg, opts = {}) {
514
563
  };
515
564
  }
516
565
  if (!jobId) {
517
- throw new CommandError3("Review API returned no job_id");
566
+ throw new CommandError4("Review API returned no job_id");
518
567
  }
519
568
  if (!wait) {
520
569
  return payloadFromRow(submitted.row, { jobId, status: submitted.row.status ?? "queued" });
@@ -550,7 +599,7 @@ import {
550
599
 
551
600
  // src/tools/review-get-pr.ts
552
601
  import {
553
- CommandError as CommandError4,
602
+ CommandError as CommandError5,
554
603
  getPrVortexReview,
555
604
  isCommandErrorCode as isCommandErrorCode3
556
605
  } from "mergestorm/client";
@@ -558,7 +607,7 @@ function prReviewTarget(owner, repo, prNumber) {
558
607
  const cleanOwner = owner.trim();
559
608
  const cleanRepo = repo.trim();
560
609
  if (!cleanOwner || !cleanRepo || !Number.isInteger(prNumber) || prNumber <= 0) {
561
- throw new CommandError4(
610
+ throw new CommandError5(
562
611
  "owner, repo, and a positive pr_number are required",
563
612
  2,
564
613
  "usage"
@@ -596,27 +645,27 @@ function payloadFromPrRateLimit(err, owner, repo, prNumber) {
596
645
  };
597
646
  }
598
647
  async function reviewGetPr(owner, repo, prNumber, cfg, opts = {}) {
599
- const target = prReviewTarget(owner, repo, prNumber);
648
+ const target2 = prReviewTarget(owner, repo, prNumber);
600
649
  try {
601
650
  const envelope = await getPrVortexReview(
602
- target.owner,
603
- target.repo,
604
- target.prNumber,
651
+ target2.owner,
652
+ target2.repo,
653
+ target2.prNumber,
605
654
  cfg,
606
655
  { afterSha: opts.afterSha, pass: opts.pass, afterPass: opts.afterPass }
607
656
  );
608
657
  return payloadFromPrReview(
609
- target.owner,
610
- target.repo,
611
- target.prNumber,
658
+ target2.owner,
659
+ target2.repo,
660
+ target2.prNumber,
612
661
  envelope
613
662
  );
614
663
  } catch (err) {
615
664
  const rateLimited = payloadFromPrRateLimit(
616
665
  err,
617
- target.owner,
618
- target.repo,
619
- target.prNumber
666
+ target2.owner,
667
+ target2.repo,
668
+ target2.prNumber
620
669
  );
621
670
  if (rateLimited) return rateLimited;
622
671
  throw err;
@@ -625,10 +674,10 @@ async function reviewGetPr(owner, repo, prNumber, cfg, opts = {}) {
625
674
 
626
675
  // src/tools/review-wait-pr.ts
627
676
  async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opts = {}) {
628
- const target = prReviewTarget(owner, repo, prNumber);
677
+ const target2 = prReviewTarget(owner, repo, prNumber);
629
678
  const resolved = cfg ?? await loadConfig5();
630
679
  if (timeout_s === 0) {
631
- return reviewGetPr(target.owner, target.repo, target.prNumber, resolved, {
680
+ return reviewGetPr(target2.owner, target2.repo, target2.prNumber, resolved, {
632
681
  afterSha,
633
682
  pass: opts.pass,
634
683
  afterPass: opts.afterPass
@@ -637,9 +686,9 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
637
686
  try {
638
687
  const envelope = await pollPrVortexReview(
639
688
  resolved,
640
- target.owner,
641
- target.repo,
642
- target.prNumber,
689
+ target2.owner,
690
+ target2.repo,
691
+ target2.prNumber,
643
692
  {
644
693
  timeoutMs: waitTimeoutMs(timeout_s, MCP_PR_WAIT_DEFAULT_S),
645
694
  afterSha,
@@ -649,9 +698,9 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
649
698
  }
650
699
  );
651
700
  return payloadFromPrReview(
652
- target.owner,
653
- target.repo,
654
- target.prNumber,
701
+ target2.owner,
702
+ target2.repo,
703
+ target2.prNumber,
655
704
  envelope
656
705
  );
657
706
  } catch (err) {
@@ -663,14 +712,14 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
663
712
  observedSha && expectedSha.length >= 7 && (observedSha.startsWith(expectedSha) || expectedSha.startsWith(observedSha))
664
713
  );
665
714
  const findingCount = headShaMatches ? err.lastEnvelope?.finding_count ?? null : null;
666
- const summary = prReviewSummary(target.owner, target.repo, target.prNumber, {
715
+ const summary = prReviewSummary(target2.owner, target2.repo, target2.prNumber, {
667
716
  status,
668
717
  head_sha: err.lastEnvelope?.head_sha ?? null,
669
718
  finding_count: findingCount,
670
719
  phase: err.lastEnvelope?.phase ?? null
671
720
  });
672
721
  return {
673
- summary: err.reviewSeen ? summary : `${summary} \xB7 ${noPrReviewYetMessage(target.owner, target.repo, target.prNumber, { afterSha, pass: opts.pass, afterPass: opts.afterPass })}`,
722
+ summary: err.reviewSeen ? summary : `${summary} \xB7 ${noPrReviewYetMessage(target2.owner, target2.repo, target2.prNumber, { afterSha, pass: opts.pass, afterPass: opts.afterPass })}`,
674
723
  data: {
675
724
  status,
676
725
  head_sha: err.lastEnvelope?.head_sha ?? null,
@@ -683,9 +732,9 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
683
732
  }
684
733
  const rateLimited = payloadFromPrRateLimit(
685
734
  err,
686
- target.owner,
687
- target.repo,
688
- target.prNumber
735
+ target2.owner,
736
+ target2.repo,
737
+ target2.prNumber
689
738
  );
690
739
  if (rateLimited) return rateLimited;
691
740
  throw err;
@@ -694,7 +743,7 @@ async function reviewWaitPr(owner, repo, prNumber, afterSha, timeout_s, cfg, opt
694
743
 
695
744
  // src/tools/review-wait.ts
696
745
  import {
697
- CommandError as CommandError6,
746
+ CommandError as CommandError7,
698
747
  ReviewPollTimeoutError as ReviewPollTimeoutError2,
699
748
  loadConfig as loadConfig6,
700
749
  pollReview as pollReview2
@@ -702,7 +751,7 @@ import {
702
751
 
703
752
  // src/tools/review-get.ts
704
753
  import {
705
- CommandError as CommandError5,
754
+ CommandError as CommandError6,
706
755
  getReview,
707
756
  toReviewJobEnvelope as toReviewJobEnvelope3
708
757
  } from "mergestorm/client";
@@ -716,7 +765,7 @@ async function reviewGet(jobId, cfg) {
716
765
  throw err;
717
766
  }
718
767
  if (!row || typeof row !== "object" || typeof row.status !== "string") {
719
- throw new CommandError5(`Unexpected response for review ${jobId}.`);
768
+ throw new CommandError6(`Unexpected response for review ${jobId}.`);
720
769
  }
721
770
  const envelope = toReviewJobEnvelope3(row, { jobId });
722
771
  const bits = [envelope.status];
@@ -732,7 +781,7 @@ async function reviewWait(jobId, timeout_s, cfg, opts = {}) {
732
781
  const resolved = cfg ?? await loadConfig6();
733
782
  const id = jobId.trim();
734
783
  if (!id) {
735
- throw new CommandError6("job_id is required", 2, "usage");
784
+ throw new CommandError7("job_id is required", 2, "usage");
736
785
  }
737
786
  if (timeout_s === 0) return reviewGet(id, resolved);
738
787
  try {
@@ -757,7 +806,7 @@ async function reviewWait(jobId, timeout_s, cfg, opts = {}) {
757
806
 
758
807
  // src/tools/settings-get.ts
759
808
  import {
760
- CommandError as CommandError7,
809
+ CommandError as CommandError8,
761
810
  getSettings
762
811
  } from "mergestorm/client";
763
812
  function settingsSummary(settings) {
@@ -775,7 +824,7 @@ function settingsSummary(settings) {
775
824
  async function settingsGet(cfg) {
776
825
  const settings = await getSettings(cfg);
777
826
  if (!settings) {
778
- throw new CommandError7(
827
+ throw new CommandError8(
779
828
  "Settings are not available (offline or API too old). Try again or open https://mergestorm.ai/settings"
780
829
  );
781
830
  }
@@ -787,7 +836,7 @@ async function settingsGet(cfg) {
787
836
 
788
837
  // src/tools/settings-set.ts
789
838
  import {
790
- CommandError as CommandError8,
839
+ CommandError as CommandError9,
791
840
  BEARER_SETTINGS,
792
841
  SETTINGS_WRITABLE_KEYS,
793
842
  patchSettings
@@ -799,7 +848,7 @@ var SETTINGS_READ_ONLY_KEYS = [
799
848
  function settingsPatchFromArgs(args) {
800
849
  for (const key of SETTINGS_READ_ONLY_KEYS) {
801
850
  if (args[key] !== void 0) {
802
- throw new CommandError8(`${key} is read-only and cannot be set.`, 2, "usage");
851
+ throw new CommandError9(`${key} is read-only and cannot be set.`, 2, "usage");
803
852
  }
804
853
  }
805
854
  const patch = {};
@@ -810,7 +859,7 @@ function settingsPatchFromArgs(args) {
810
859
  if ("kind" in row && (row.kind === "seconds" || row.kind === "count")) {
811
860
  if (typeof value !== "number" || !Number.isInteger(value) || value < row.min || value > row.max) {
812
861
  const unit = row.kind === "seconds" ? " of seconds" : "";
813
- throw new CommandError8(
862
+ throw new CommandError9(
814
863
  `${key} takes a whole number${unit} from ${row.min} through ${row.max}.`,
815
864
  2,
816
865
  "usage"
@@ -821,17 +870,17 @@ function settingsPatchFromArgs(args) {
821
870
  }
822
871
  if ("kind" in row) {
823
872
  const valid = row.kind === "logins" ? Array.isArray(value) && value.every((login) => typeof login === "string") : typeof value === "string" && row.values.includes(value);
824
- if (!valid) throw new CommandError8(`Invalid value for ${key}.`, 2, "usage");
873
+ if (!valid) throw new CommandError9(`Invalid value for ${key}.`, 2, "usage");
825
874
  Object.assign(patch, { [key]: value });
826
875
  continue;
827
876
  }
828
877
  if (typeof value !== "boolean") {
829
- throw new CommandError8(`${key} takes a boolean.`, 2, "usage");
878
+ throw new CommandError9(`${key} takes a boolean.`, 2, "usage");
830
879
  }
831
880
  Object.assign(patch, { [key]: value });
832
881
  }
833
882
  if (Object.keys(patch).length === 0) {
834
- throw new CommandError8(
883
+ throw new CommandError9(
835
884
  `At least one settings key is required: ${SETTINGS_WRITABLE_KEYS.join(", ")}.`,
836
885
  2,
837
886
  "usage"
@@ -848,9 +897,71 @@ async function settingsSet(args, cfg) {
848
897
  };
849
898
  }
850
899
 
900
+ // src/tools/repo-settings.ts
901
+ import {
902
+ CommandError as CommandError10,
903
+ REPO_SETTINGS_KEYS,
904
+ listRepoSettings,
905
+ patchRepoSettings
906
+ } from "mergestorm/client";
907
+ var ARG_BY_KEY = {
908
+ auto_patch_enabled: "auto_patch",
909
+ auto_review_enabled: "auto_review",
910
+ auto_land_default: "auto_land"
911
+ };
912
+ function parseRepo(raw) {
913
+ const match = typeof raw === "string" ? /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(raw.trim()) : null;
914
+ if (!match) throw new CommandError10("repo takes <owner>/<repo>.", 2, "usage");
915
+ return { owner: match[1], repo: match[2] };
916
+ }
917
+ function words(value) {
918
+ return value === null ? "inherit" : value ? "on" : "off";
919
+ }
920
+ function repoSettingsSummary(entry) {
921
+ return `${entry.owner}/${entry.repo}: ${REPO_SETTINGS_KEYS.map((key) => `${ARG_BY_KEY[key]} ${words(entry[key])}`).join(" \xB7 ")}`;
922
+ }
923
+ function repoSettingsPatchFromArgs(args) {
924
+ const patch = {};
925
+ for (const key of REPO_SETTINGS_KEYS) {
926
+ const value = args[ARG_BY_KEY[key]];
927
+ if (value === void 0) continue;
928
+ if (value !== null && typeof value !== "boolean") {
929
+ throw new CommandError10(`${ARG_BY_KEY[key]} takes true, false, or null.`, 2, "usage");
930
+ }
931
+ patch[key] = value;
932
+ }
933
+ if (Object.keys(patch).length === 0) {
934
+ throw new CommandError10(
935
+ `At least one of ${Object.values(ARG_BY_KEY).join(", ")} is required.`,
936
+ 2,
937
+ "usage"
938
+ );
939
+ }
940
+ return patch;
941
+ }
942
+ async function repoSettingsGet(args, cfg) {
943
+ const wanted = args.repo === void 0 ? null : parseRepo(args.repo);
944
+ const all = await listRepoSettings(cfg);
945
+ if (!wanted) {
946
+ return {
947
+ summary: all.length === 0 ? "No repositories are connected to this account." : all.map(repoSettingsSummary).join("\n"),
948
+ data: { repos: all }
949
+ };
950
+ }
951
+ const slug = `${wanted.owner}/${wanted.repo}`.toLowerCase();
952
+ const entry = all.find((candidate) => `${candidate.owner}/${candidate.repo}`.toLowerCase() === slug);
953
+ if (!entry) throw new CommandError10(`${wanted.owner}/${wanted.repo} is not connected to this account.`);
954
+ return { summary: repoSettingsSummary(entry), data: { ...entry } };
955
+ }
956
+ async function repoSettingsSet(args, cfg) {
957
+ const { owner, repo } = parseRepo(args.repo);
958
+ const entry = await patchRepoSettings(owner, repo, repoSettingsPatchFromArgs(args), cfg);
959
+ return { summary: repoSettingsSummary(entry), data: { ...entry } };
960
+ }
961
+
851
962
  // src/tools/stack-adopt.ts
852
- import { adoptStack, CommandError as CommandError9, stackWatchObligation as stackWatchObligation4 } from "mergestorm/client";
853
- import { z as z2 } from "zod";
963
+ import { adoptStack, CommandError as CommandError11, stackWatchObligation as stackWatchObligation4 } from "mergestorm/client";
964
+ import { z as z3 } from "zod";
854
965
 
855
966
  // src/tools/stack-set.ts
856
967
  import { setStackPolicy, stackWatchObligation as stackWatchObligation3 } from "mergestorm/client";
@@ -918,18 +1029,18 @@ async function stackSet(stackId, input, cfg) {
918
1029
 
919
1030
  // src/tools/stack-adopt.ts
920
1031
  var stackAdoptSchema = {
921
- owner: z2.string().trim().min(1),
922
- repo: z2.string().trim().min(1),
923
- pr_number: z2.number().int().min(1),
924
- auto_land: z2.boolean().optional(),
925
- auto_review: z2.boolean().nullable().optional(),
926
- auto_patch: z2.boolean().nullable().optional(),
927
- auto_resolve_conflicts: z2.boolean().nullable().optional(),
928
- auto_fix_ci: z2.boolean().nullable().optional()
1032
+ owner: z3.string().trim().min(1),
1033
+ repo: z3.string().trim().min(1),
1034
+ pr_number: z3.number().int().min(1),
1035
+ auto_land: z3.boolean().optional(),
1036
+ auto_review: z3.boolean().nullable().optional(),
1037
+ auto_patch: z3.boolean().nullable().optional(),
1038
+ auto_resolve_conflicts: z3.boolean().nullable().optional(),
1039
+ auto_fix_ci: z3.boolean().nullable().optional()
929
1040
  };
930
- var inputSchema = z2.object(stackAdoptSchema);
1041
+ var inputSchema2 = z3.object(stackAdoptSchema);
931
1042
  async function stackAdopt(input, cfg) {
932
- const parsed = inputSchema.safeParse(input);
1043
+ const parsed = inputSchema2.safeParse(input);
933
1044
  if (!parsed.success) {
934
1045
  const message = "stack_adopt requires owner, repo, a positive pr_number, and valid policy values";
935
1046
  return { summary: message, data: { error: { code: "invalid_input", message } }, isError: true };
@@ -959,11 +1070,11 @@ async function stackAdopt(input, cfg) {
959
1070
  summary: message,
960
1071
  data: {
961
1072
  error: {
962
- code: err instanceof CommandError9 ? err.code ?? "stack_adopt_failed" : "stack_adopt_failed",
1073
+ code: err instanceof CommandError11 ? err.code ?? "stack_adopt_failed" : "stack_adopt_failed",
963
1074
  message,
964
- ...err instanceof CommandError9 ? { exit_code: err.exitCode } : {},
965
- ...err instanceof CommandError9 && err.reason !== void 0 ? { reason: err.reason } : {},
966
- ...err instanceof CommandError9 && err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
1075
+ ...err instanceof CommandError11 ? { exit_code: err.exitCode } : {},
1076
+ ...err instanceof CommandError11 && err.reason !== void 0 ? { reason: err.reason } : {},
1077
+ ...err instanceof CommandError11 && err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
967
1078
  }
968
1079
  },
969
1080
  isError: true
@@ -1032,6 +1143,102 @@ async function stackList(cfg) {
1032
1143
  }
1033
1144
  }
1034
1145
 
1146
+ // src/tools/stack-publish.ts
1147
+ import {
1148
+ CommandError as CommandError12,
1149
+ publishWorkingBranch,
1150
+ stackWatchObligation as stackWatchObligation5,
1151
+ workingBranchPublication
1152
+ } from "mergestorm/client";
1153
+ import { z as z4 } from "zod";
1154
+ var target = {
1155
+ cwd: z4.string().trim().min(1).optional(),
1156
+ owner: z4.string().trim().min(1).optional(),
1157
+ repo: z4.string().trim().min(1).optional(),
1158
+ branch: z4.string().trim().min(1).optional()
1159
+ };
1160
+ var stackPublishSchema = {
1161
+ ...target,
1162
+ base: z4.string().trim().min(1).optional(),
1163
+ title: z4.string().trim().min(1).max(256).optional(),
1164
+ body: z4.string().max(6e4).optional(),
1165
+ replace_head: z4.string().regex(/^[0-9a-f]{40}$/).optional(),
1166
+ auto_land: z4.boolean().optional()
1167
+ };
1168
+ var stackPublishStatusSchema = target;
1169
+ var publishInput = z4.object(stackPublishSchema);
1170
+ var statusInput = z4.object(stackPublishStatusSchema);
1171
+ function failure(err, fallback) {
1172
+ const message = err instanceof Error ? err.message : String(err);
1173
+ return {
1174
+ summary: message,
1175
+ data: {
1176
+ error: {
1177
+ code: err instanceof CommandError12 ? err.code ?? fallback : fallback,
1178
+ message,
1179
+ ...err instanceof CommandError12 && err.retryAfterSeconds !== void 0 ? { retry_after_seconds: err.retryAfterSeconds } : {}
1180
+ }
1181
+ },
1182
+ isError: true
1183
+ };
1184
+ }
1185
+ function targetOf(input, cfg) {
1186
+ return {
1187
+ ...input.cwd ? { cwd: input.cwd } : {},
1188
+ ...input.owner ? { owner: input.owner } : {},
1189
+ ...input.repo ? { repo: input.repo } : {},
1190
+ ...input.branch ? { branch: input.branch } : {},
1191
+ ...cfg ? { cfg } : {}
1192
+ };
1193
+ }
1194
+ async function stackPublish(input, cfg, deps) {
1195
+ const parsed = publishInput.safeParse(input);
1196
+ if (!parsed.success || Boolean(parsed.data.owner) !== Boolean(parsed.data.repo)) {
1197
+ const message = "stack_publish takes owner and repo together, and replace_head as a full 40-character commit SHA";
1198
+ return { summary: message, data: { error: { code: "invalid_input", message } }, isError: true };
1199
+ }
1200
+ try {
1201
+ const result = await publishWorkingBranch(
1202
+ {
1203
+ ...targetOf(parsed.data, cfg),
1204
+ ...parsed.data.base ? { base: parsed.data.base } : {},
1205
+ ...parsed.data.title ? { title: parsed.data.title } : {},
1206
+ ...parsed.data.body !== void 0 ? { body: parsed.data.body } : {},
1207
+ ...parsed.data.replace_head ? { replaceHead: parsed.data.replace_head } : {},
1208
+ ...typeof parsed.data.auto_land === "boolean" ? { autoLand: parsed.data.auto_land } : {}
1209
+ },
1210
+ deps
1211
+ );
1212
+ const watch = stackWatchObligation5({ stackId: result.stack.id, terminal: null, unread: true, freshCursor: true });
1213
+ const moved = result.branchCreated ? "created" : result.branchUpdated ? "updated" : "already current";
1214
+ return {
1215
+ summary: withWatchText(
1216
+ watch,
1217
+ `Published ${result.owner}/${result.repo} ${result.branch} (${moved}) at ${result.head.sha}, PR #${result.pr.number}, stack ${result.stack.id} with Auto patch off`
1218
+ ),
1219
+ data: { result, watch }
1220
+ };
1221
+ } catch (err) {
1222
+ return failure(err, "stack_publish_failed");
1223
+ }
1224
+ }
1225
+ async function stackPublishStatus(input, cfg, deps) {
1226
+ const parsed = statusInput.safeParse(input);
1227
+ if (!parsed.success || Boolean(parsed.data.owner) !== Boolean(parsed.data.repo)) {
1228
+ const message = "stack_publish_status takes owner and repo together";
1229
+ return { summary: message, data: { error: { code: "invalid_input", message } }, isError: true };
1230
+ }
1231
+ try {
1232
+ const status = await workingBranchPublication(targetOf(parsed.data, cfg), deps);
1233
+ return {
1234
+ summary: `${status.owner}/${status.repo} ${status.branch}: ${status.state}${status.head ? ` at ${status.head.sha}` : ""}${status.pr ? `, PR #${status.pr.number}` : ""}${status.stack ? `, stack ${status.stack.id}` : ""}`,
1235
+ data: { status }
1236
+ };
1237
+ } catch (err) {
1238
+ return failure(err, "stack_publish_status_failed");
1239
+ }
1240
+ }
1241
+
1035
1242
  // src/tools/stack-status.ts
1036
1243
  import {
1037
1244
  getEnrichedStack,
@@ -1039,14 +1246,14 @@ import {
1039
1246
  isCommandErrorCode as isCommandErrorCode5,
1040
1247
  stackBlockers as stackBlockers2,
1041
1248
  stackTerminalReason,
1042
- stackWatchObligation as stackWatchObligation5
1249
+ stackWatchObligation as stackWatchObligation6
1043
1250
  } from "mergestorm/client";
1044
1251
  async function stackStatus(stackId, cfg) {
1045
1252
  try {
1046
1253
  const stack = await getEnrichedStack(stackId, cfg);
1047
1254
  if (!stack) {
1048
1255
  const message = `Stack not found or not owned by the current user: ${stackId}`;
1049
- const watch2 = stackWatchObligation5({ stackId, terminal: "not_found" });
1256
+ const watch2 = stackWatchObligation6({ stackId, terminal: "not_found" });
1050
1257
  return {
1051
1258
  summary: withWatchText(watch2, message),
1052
1259
  data: {
@@ -1062,7 +1269,7 @@ async function stackStatus(stackId, cfg) {
1062
1269
  }
1063
1270
  const entries = await listMergeQueueEntries2(cfg, { stackId: stack.id });
1064
1271
  const { attention, held, issues, currentCandidate, busy, agents, repair, landGatePending, queueWait } = stackBlockers2(stack, entries);
1065
- const watch = stackWatchObligation5({
1272
+ const watch = stackWatchObligation6({
1066
1273
  stackId: stack.id,
1067
1274
  terminal: stackTerminalReason(stack),
1068
1275
  cursor: { enrolledHeadSha: currentCandidate?.headSha ?? null },
@@ -1075,7 +1282,7 @@ async function stackStatus(stackId, cfg) {
1075
1282
  };
1076
1283
  } catch (err) {
1077
1284
  if (isCommandErrorCode5(err, "rate_limited")) {
1078
- const watch = stackWatchObligation5({
1285
+ const watch = stackWatchObligation6({
1079
1286
  stackId,
1080
1287
  terminal: null,
1081
1288
  status: "rate_limited",
@@ -1101,7 +1308,7 @@ async function stackStatus(stackId, cfg) {
1101
1308
 
1102
1309
  // src/tools/whoami.ts
1103
1310
  import {
1104
- CommandError as CommandError10,
1311
+ CommandError as CommandError13,
1105
1312
  apiBase,
1106
1313
  configPath,
1107
1314
  getMe as getMe2,
@@ -1112,7 +1319,7 @@ async function whoami(cfg, serverVersion) {
1112
1319
  const resolved = cfg ?? await loadConfig7();
1113
1320
  const key = resolveApiKey(resolved);
1114
1321
  if (!key) {
1115
- throw new CommandError10(
1322
+ throw new CommandError13(
1116
1323
  "No API key. Run `mergestorm login` (agents: `mergestorm login --start`, then `--finish`) or set MERGESTORM_API_KEY.",
1117
1324
  1,
1118
1325
  "missing_api_key"
@@ -1120,7 +1327,7 @@ async function whoami(cfg, serverVersion) {
1120
1327
  }
1121
1328
  const me = await getMe2(resolved);
1122
1329
  if (!me) {
1123
- throw new CommandError10("Live account details unavailable. Could not verify account with /me.");
1330
+ throw new CommandError13("Live account details unavailable. Could not verify account with /me.");
1124
1331
  }
1125
1332
  const data = {
1126
1333
  key_prefix: me.key.prefix,
@@ -1195,7 +1402,7 @@ var MCP_STACK_WATCH_INSTRUCTIONS = [
1195
1402
 
1196
1403
  // src/server.ts
1197
1404
  var MCP_SERVER_NAME = "mergestorm";
1198
- var MCP_SERVER_VERSION = "0.2.20";
1405
+ var MCP_SERVER_VERSION = "0.2.22";
1199
1406
  var MCP_SERVER_INSTRUCTIONS = [
1200
1407
  MCP_STACK_DONE_INSTRUCTIONS,
1201
1408
  MCP_PR_LOOP_INSTRUCTIONS,
@@ -1214,14 +1421,14 @@ function ok(payload) {
1214
1421
  };
1215
1422
  }
1216
1423
  function fail(err) {
1217
- const message = err instanceof CommandError11 ? err.code ? `Mergestorm request failed (${err.code}${err.reason ? `, reason ${err.reason}` : ""}): ${err.message}.` : err.message : err instanceof Error ? err.message : String(err);
1424
+ const message = err instanceof CommandError14 ? err.code ? `Mergestorm request failed (${err.code}${err.reason ? `, reason ${err.reason}` : ""}): ${err.message}.` : err.message : err instanceof Error ? err.message : String(err);
1218
1425
  return ok({
1219
1426
  isError: true,
1220
1427
  summary: message,
1221
1428
  data: {
1222
1429
  error: {
1223
1430
  message: err instanceof Error ? err.message : String(err),
1224
- ...err instanceof CommandError11 ? {
1431
+ ...err instanceof CommandError14 ? {
1225
1432
  exit_code: err.exitCode,
1226
1433
  ...err.code !== void 0 ? { code: err.code } : {},
1227
1434
  ...err.reason !== void 0 ? { reason: err.reason } : {},
@@ -1279,7 +1486,7 @@ function createMergestormMcpServer() {
1279
1486
  title: "List reviews",
1280
1487
  description: "List recent Mergestorm review jobs as job envelopes.",
1281
1488
  inputSchema: {
1282
- limit: z3.number().int().min(1).max(50).optional()
1489
+ limit: z5.number().int().min(1).max(50).optional()
1283
1490
  },
1284
1491
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1285
1492
  },
@@ -1297,18 +1504,18 @@ function createMergestormMcpServer() {
1297
1504
  title: "Submit review",
1298
1505
  description: "Submit a local git diff (or an explicit diff) as a Mergestorm review job. Does not wait by default; returns job_id immediately after submission. Explicit wait: true polls one 45s slice by default. Record job_id and loop review_wait until done. Do not pass 300; hosts drop long MCP calls. On rate_limited, wait retry_after_seconds and inspect or wait for an in-flight review before resubmitting. specialists may be security, performance, architecture, tests, data, api, frontend. governance and seam cannot be requested.",
1299
1506
  inputSchema: {
1300
- cwd: z3.string().optional(),
1301
- base: z3.string().optional(),
1302
- head: z3.string().optional(),
1303
- diff: z3.string().optional(),
1304
- router: z3.enum(["off", "standard", "max", "manual"]).optional(),
1305
- specialists: z3.array(z3.string().min(1)).max(5).optional(),
1306
- context: z3.string().optional(),
1307
- context_files: z3.array(z3.string().min(1)).max(10).optional(),
1308
- thread: z3.string().min(1).max(120).optional(),
1309
- idempotency_key: z3.string().min(1).max(128).optional(),
1310
- wait: z3.boolean().optional(),
1311
- timeout_s: z3.number().positive().max(300).optional()
1507
+ cwd: z5.string().optional(),
1508
+ base: z5.string().optional(),
1509
+ head: z5.string().optional(),
1510
+ diff: z5.string().optional(),
1511
+ router: z5.enum(["off", "standard", "max", "manual"]).optional(),
1512
+ specialists: z5.array(z5.string().min(1)).max(5).optional(),
1513
+ context: z5.string().optional(),
1514
+ context_files: z5.array(z5.string().min(1)).max(10).optional(),
1515
+ thread: z5.string().min(1).max(120).optional(),
1516
+ idempotency_key: z5.string().min(1).max(128).optional(),
1517
+ wait: z5.boolean().optional(),
1518
+ timeout_s: z5.number().positive().max(300).optional()
1312
1519
  },
1313
1520
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
1314
1521
  },
@@ -1326,8 +1533,8 @@ function createMergestormMcpServer() {
1326
1533
  title: "Wait for review",
1327
1534
  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.",
1328
1535
  inputSchema: {
1329
- job_id: z3.string().min(1),
1330
- timeout_s: z3.number().min(0).max(300).optional()
1536
+ job_id: z5.string().min(1),
1537
+ timeout_s: z5.number().min(0).max(300).optional()
1331
1538
  },
1332
1539
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1333
1540
  },
@@ -1345,13 +1552,13 @@ function createMergestormMcpServer() {
1345
1552
  title: "Wait for GitHub PR Vortex review",
1346
1553
  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, or none when no review exists for that PR and head yet (call once more; if it is none again, Vortex is not reviewing that head: comment @mergestorm-vortex review on the PR, run mg review, or push a new commit). 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.",
1347
1554
  inputSchema: {
1348
- owner: z3.string().min(1),
1349
- repo: z3.string().min(1),
1350
- pr_number: z3.number().int().min(1),
1351
- after_sha: z3.string().optional(),
1352
- pass: z3.number().int().min(1).optional(),
1353
- after_pass: z3.number().int().min(1).optional(),
1354
- timeout_s: z3.number().min(0).max(300).optional()
1555
+ owner: z5.string().min(1),
1556
+ repo: z5.string().min(1),
1557
+ pr_number: z5.number().int().min(1),
1558
+ after_sha: z5.string().optional(),
1559
+ pass: z5.number().int().min(1).optional(),
1560
+ after_pass: z5.number().int().min(1).optional(),
1561
+ timeout_s: z5.number().min(0).max(300).optional()
1355
1562
  },
1356
1563
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1357
1564
  },
@@ -1379,16 +1586,16 @@ function createMergestormMcpServer() {
1379
1586
  title: "Dismiss Vortex findings",
1380
1587
  description: `Record an audited dismissal of Vortex findings you verified are wrong or not actionable on a GitHub PR. Identity is exact: head_sha must be the PR's live head, review_id a Vortex review (Core or seam) made at that head, and finding_ids the GitHub review comment ids of that review (offdiff-<n> for a body-only finding), or scope "review" with no finding_ids to dismiss every finding of that review. reason must say why (at least 20 characters); evidence_url is optional. Call with preview: true first to list the review's finding ids and the seam gate without writing. The caller's linked GitHub account needs write access to the repository. A moved head, another review, an unknown id or a missing permission is refused and nothing is written. Retries are idempotent. The seam gate clears only when every finding of that integration review is dismissed; the Vortex gate at the head clears only once every Vortex Blocker (error finding) at that head is dismissed and no Vortex review of it is running (gate.vortex.cleared, else gate.vortex.message); CI and Auto land policy are unchanged. Vortex does not raise a dismissed finding again on the same diff. Never dismiss a finding you did not check.` + STACK_WATCH_RESULT_NOTE,
1381
1588
  inputSchema: {
1382
- owner: z3.string().min(1),
1383
- repo: z3.string().min(1),
1384
- pr_number: z3.number().int().min(1),
1385
- head_sha: z3.string().regex(/^[0-9a-fA-F]{40}$/),
1386
- review_id: z3.number().int().min(1),
1387
- finding_ids: z3.array(z3.string().min(1)).max(50).optional(),
1388
- scope: z3.enum(["findings", "review"]).optional(),
1389
- reason: z3.string().optional(),
1390
- evidence_url: z3.string().url().optional(),
1391
- preview: z3.boolean().optional()
1589
+ owner: z5.string().min(1),
1590
+ repo: z5.string().min(1),
1591
+ pr_number: z5.number().int().min(1),
1592
+ head_sha: z5.string().regex(/^[0-9a-fA-F]{40}$/),
1593
+ review_id: z5.number().int().min(1),
1594
+ finding_ids: z5.array(z5.string().min(1)).max(50).optional(),
1595
+ scope: z5.enum(["findings", "review"]).optional(),
1596
+ reason: z5.string().optional(),
1597
+ evidence_url: z5.string().url().optional(),
1598
+ preview: z5.boolean().optional()
1392
1599
  },
1393
1600
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
1394
1601
  },
@@ -1400,6 +1607,22 @@ function createMergestormMcpServer() {
1400
1607
  }
1401
1608
  }
1402
1609
  );
1610
+ addTool(
1611
+ "issue_create",
1612
+ {
1613
+ title: "Open GitHub issue",
1614
+ description: "Open a GitHub issue through Mergestorm with no GitHub credentials: the Surge App creates it and names you in the body. Use it for the issue a pull request must have before it is opened, then put Fixes #<number> in the commit or pull request body. Defaults to the origin repository of cwd; pass owner and repo together to name another. labels must already exist on the repository; an unknown one is refused with unknown_label and nothing is created. Calling it again with the same title returns the open issue it already made (created: false) when that issue is among the newest 100 open issues of the repository; an older one, or two calls at the same time, can still create a duplicate, so check the returned number before retrying after a lost response. Needs write access to the repository through your linked GitHub account and Surge installed on it.",
1615
+ inputSchema: issueCreateSchema,
1616
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
1617
+ },
1618
+ async (args) => {
1619
+ try {
1620
+ return ok(await issueCreate(args));
1621
+ } catch (err) {
1622
+ return fail(err);
1623
+ }
1624
+ }
1625
+ );
1403
1626
  addTool(
1404
1627
  "stack_adopt",
1405
1628
  {
@@ -1431,18 +1654,50 @@ function createMergestormMcpServer() {
1431
1654
  }
1432
1655
  }
1433
1656
  );
1657
+ addTool(
1658
+ "stack_publish",
1659
+ {
1660
+ title: "Publish branch through Mergestorm",
1661
+ description: "Publish a local branch without any GitHub credentials. Reads the branch's commits from the local repository at cwd (never runs gh, git push or git fetch) and sends them to Mergestorm, which writes the exact commits and the branch on GitHub as the Surge App, opens a ready pull request when the branch has none, and registers the stack with Auto patch pinned off, Auto land on for a new stack, and Auto review left to the account. These are your own changes; Cyclone does not write or patch them. Use it for the first publication and for every later commit to that branch. Defaults: owner and repo from origin, the checked-out branch, the repository's trunk as base, title and body from the tip commit. Mergestorm verifies each commit's Git tree before it moves the branch and returns the verified head and tree. It refuses with head_changed when the branch on GitHub holds work your local branch does not, base_changed when the base commit left the base branch, protected_branch for trunk and Mergestorm-owned branches, and payload_too_large beyond 20 commits, 500 changed paths, 2 MiB a file or 8 MiB in total; submodules and merge commits are not carried. replace_head replaces that exact GitHub head with the local branch and discards its commits. Safe to call again after a lost response or a failure: it resumes and never duplicates the branch, commits, pull request or stack." + STACK_WATCH_RESULT_NOTE,
1662
+ inputSchema: stackPublishSchema,
1663
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
1664
+ },
1665
+ async (args) => {
1666
+ try {
1667
+ return ok(await stackPublish(args));
1668
+ } catch (err) {
1669
+ return fail(err);
1670
+ }
1671
+ }
1672
+ );
1673
+ addTool(
1674
+ "stack_publish_status",
1675
+ {
1676
+ title: "Read branch publication",
1677
+ description: "Read how far a branch published with stack_publish got: absent, pushed (branch on GitHub, no pull request), opened (pull request open, stack not registered) or registered, with the head commit and tree on GitHub, the pull request and the stack's Auto patch and Auto land state. Changes nothing. Call it after a lost stack_publish response, then call stack_publish again to resume.",
1678
+ inputSchema: stackPublishStatusSchema,
1679
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1680
+ },
1681
+ async (args) => {
1682
+ try {
1683
+ return ok(await stackPublishStatus(args));
1684
+ } catch (err) {
1685
+ return fail(err);
1686
+ }
1687
+ }
1688
+ );
1434
1689
  addTool(
1435
1690
  "stack_set",
1436
1691
  {
1437
1692
  title: "Set stack policy",
1438
1693
  description: "Set per-stack automation on one owned Mergestorm stack: auto_land arms or disarms Auto land; auto_review, auto_patch, auto_resolve_conflicts, and auto_fix_ci pin Vortex auto-review, Cyclone auto-patch, Auto-resolve merge conflicts, or Auto-fix failing CI for this stack only (true or false), and null clears the pin so the stack follows the account setting again. Pass at least one key. Never changes account settings." + STACK_WATCH_RESULT_NOTE,
1439
1694
  inputSchema: {
1440
- stack_id: z3.string().min(1),
1441
- auto_land: z3.boolean().optional(),
1442
- auto_review: z3.boolean().nullable().optional(),
1443
- auto_patch: z3.boolean().nullable().optional(),
1444
- auto_resolve_conflicts: z3.boolean().nullable().optional(),
1445
- auto_fix_ci: z3.boolean().nullable().optional()
1695
+ stack_id: z5.string().min(1),
1696
+ auto_land: z5.boolean().optional(),
1697
+ auto_review: z5.boolean().nullable().optional(),
1698
+ auto_patch: z5.boolean().nullable().optional(),
1699
+ auto_resolve_conflicts: z5.boolean().nullable().optional(),
1700
+ auto_fix_ci: z5.boolean().nullable().optional()
1446
1701
  },
1447
1702
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
1448
1703
  },
@@ -1468,7 +1723,7 @@ function createMergestormMcpServer() {
1468
1723
  title: "Get stack status",
1469
1724
  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. repair names the concrete fix for attention; landGatePending is a wait, not attention. held names a blocker no agent run clears while Vortex or Cyclone is still busy on that PR (actAfter agents_idle, waitingOn); repair then describes the fix to plan, not to apply yet. Auto land off after a merge_failed, gh_error, or ci_timeout bounce (the stack's autoLandOff reads reason bounced) returns attention with the blocker "Auto land off after <kind> bounce", the bounce in issues[], and a repair of kind auto_land_off: fix the cause, then turn Auto land back on or enqueue. A Vortex request_changes handoff at the live head returns attention with blocker "Vortex findings need a person" and repair kind vortex_findings; verify its findings against the live head.` + STACK_WATCH_RESULT_NOTE,
1470
1725
  inputSchema: {
1471
- stack_id: z3.string().min(1)
1726
+ stack_id: z5.string().min(1)
1472
1727
  },
1473
1728
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1474
1729
  },
@@ -1503,7 +1758,7 @@ function createMergestormMcpServer() {
1503
1758
  title: "Get merge queue status",
1504
1759
  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.",
1505
1760
  inputSchema: {
1506
- stack_id: z3.string().min(1).optional()
1761
+ stack_id: z5.string().min(1).optional()
1507
1762
  },
1508
1763
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1509
1764
  },
@@ -1515,6 +1770,43 @@ function createMergestormMcpServer() {
1515
1770
  }
1516
1771
  }
1517
1772
  );
1773
+ addTool(
1774
+ "repo_settings_get",
1775
+ {
1776
+ title: "Get repository settings",
1777
+ description: "Read the repository-level Auto patch, Auto review and Auto land values for the repositories connected to this account. Each value is true, false, or null; null means not set, so the account setting applies. Auto patch and Auto review precedence is the stack override (stack_set), then the repository value, then the account setting (settings_get). The Auto land repository value only seeds new stacks at adoption; it does not override an existing stack. Pass repo as owner/repo for one repository, or omit it for all.",
1778
+ inputSchema: { repo: z5.string().min(3).optional() },
1779
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1780
+ },
1781
+ async (args) => {
1782
+ try {
1783
+ return ok(await repoSettingsGet(args ?? {}));
1784
+ } catch (err) {
1785
+ return fail(err);
1786
+ }
1787
+ }
1788
+ );
1789
+ addTool(
1790
+ "repo_settings_set",
1791
+ {
1792
+ title: "Update repository settings",
1793
+ description: "Set Auto patch (Cyclone), Auto review (Vortex) or the Auto land default for every stack and pull request in one repository connected to this account, and return the stored result. true or false sets the repository value; null clears it so the account setting applies again. A stack override (stack_set) still wins for that stack. auto_patch and auto_review apply to open stacks without an override from their next decision; nothing is cancelled or re-queued. auto_land only seeds new stacks. Only the account that connected the repository can change it. Never changes account settings.",
1794
+ inputSchema: {
1795
+ repo: z5.string().min(3),
1796
+ auto_patch: z5.boolean().nullable().optional(),
1797
+ auto_review: z5.boolean().nullable().optional(),
1798
+ auto_land: z5.boolean().nullable().optional()
1799
+ },
1800
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
1801
+ },
1802
+ async (args) => {
1803
+ try {
1804
+ return ok(await repoSettingsSet(args ?? {}));
1805
+ } catch (err) {
1806
+ return fail(err);
1807
+ }
1808
+ }
1809
+ );
1518
1810
  addTool(
1519
1811
  "settings_get",
1520
1812
  {
@@ -1539,12 +1831,12 @@ function createMergestormMcpServer() {
1539
1831
  ...Object.fromEntries(
1540
1832
  BEARER_SETTINGS2.map((row) => [
1541
1833
  row.key,
1542
- ("kind" in row ? row.kind === "logins" ? z3.array(z3.string()) : row.kind === "seconds" || row.kind === "count" ? z3.number().int().min(row.min).max(row.max) : z3.enum(row.values) : z3.boolean()).optional()
1834
+ ("kind" in row ? row.kind === "logins" ? z5.array(z5.string()) : row.kind === "seconds" || row.kind === "count" ? z5.number().int().min(row.min).max(row.max) : z5.enum(row.values) : z5.boolean()).optional()
1543
1835
  ])
1544
1836
  ),
1545
1837
  // Preserve these args so the handler rejects them even alongside a writable key.
1546
- cyclone_connected: z3.unknown().optional(),
1547
- github_connected: z3.unknown().optional()
1838
+ cyclone_connected: z5.unknown().optional(),
1839
+ github_connected: z5.unknown().optional()
1548
1840
  },
1549
1841
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
1550
1842
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm-mcp",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
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.52",
32
+ "mergestorm": "0.3.56",
33
33
  "zod": "^3.24.2"
34
34
  },
35
35
  "devDependencies": {