Public: anyone can read this; the crew can change it.

MCP `close`: close or reopen an issue with the same rules as PATCH …/issues/{number} #4179

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3361-mcp-close-close-or-reopen-an-issue-with → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/3361-mcp-close-close-or-reopen-an-issue-with was merged into main as 4bad0c3.

Review

Sign in to review this change.
5 files changed +142 −31
docs/MCP.md +1
@@ -35,6 +35,7 @@ for a tunnel and a forge that sleeps.
3535 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | |
3636 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks` | |
3737 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
38 | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes |
3839 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | no |
3940 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | no |
4041 | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded | yes |
src/actions/issues.ts +25
@@ -27,6 +27,7 @@ import { moveBlockersToChildren } from "../core/dependencies.ts";
2727 import {
2828 addComment as insertComment,
2929 createIssue,
30 CLOSE_REASONS,
3031 getIssue,
3132 getIssueById,
3233 getLabel,
@@ -615,6 +616,30 @@ export interface Closing {
615616 readonly triage?: boolean;
616617 }
617618
619 /**
620 * `reason` and `into` of a close request, or undefined when they are not
621 * the shapes they must be (a bad reason word, `into` not a list of numbers).
622 */
623 export function closingFrom(input: Record<string, unknown>): Closing | undefined {
624 const reason = input.reason;
625 if (reason !== undefined && !CLOSE_REASONS.includes(reason as CloseReason))
626 return undefined;
627 let into: number[] | undefined;
628 if (input.into !== undefined) {
629 if (
630 !Array.isArray(input.into) ||
631 input.into.length > 50 ||
632 !input.into.every((n) => Number.isInteger(n) && n > 0)
633 )
634 return undefined;
635 into = input.into as number[];
636 }
637 return {
638 ...(reason === undefined ? {} : { reason: reason as CloseReason }),
639 ...(into === undefined ? {} : { into }),
640 };
641 }
642
618643 /**
619644 * Replace an issue's (or pull's) label set. Writers only — labels are the
620645 * work queue. Labels may be named by id or by name; an unknown one rejects
src/http/api.ts +2 −30
@@ -347,12 +347,8 @@ import { isEnvName } from "../apps/data.ts";
347347 import { getAppByRepo } from "../core/apps.ts";
348348 import { crewRole, isCrew, type Agent } from "../core/agents.ts";
349349 import { listApps, type AppRecord } from "../core/apps.ts";
350 import {
351 CLOSE_REASONS,
352 listAssignedTo,
353 type CloseReason,
354 } from "../core/issues.ts";
355 import type { Closing } from "../actions/issues.ts";
350 import { listAssignedTo } from "../core/issues.ts";
351 import { closingFrom } from "../actions/issues.ts";
356352 import {
357353 checkSummary,
358354 checksForHead,
@@ -841,30 +837,6 @@ const NOTHING_TO_CHANGE = () =>
841837 "send a new title or body, a state, or both",
842838 );
843839
844 /**
845 * `reason` and `into` of a close request, or undefined when they are not
846 * the shapes they must be (a bad reason word, `into` not a list of numbers).
847 */
848 function closingFrom(input: Record<string, unknown>): Closing | undefined {
849 const reason = input.reason;
850 if (reason !== undefined && !CLOSE_REASONS.includes(reason as CloseReason))
851 return undefined;
852 let into: number[] | undefined;
853 if (input.into !== undefined) {
854 if (
855 !Array.isArray(input.into) ||
856 input.into.length > 50 ||
857 !input.into.every((n) => Number.isInteger(n) && n > 0)
858 )
859 return undefined;
860 into = input.into as number[];
861 }
862 return {
863 ...(reason === undefined ? {} : { reason: reason as CloseReason }),
864 ...(into === undefined ? {} : { into }),
865 };
866 }
867
868840 /**
869841 * Which status a branch refusal deserves. A conflict is something that
870842 * changed under the caller (or was already there); everything else is the
src/http/mcp.ts +51 −1
@@ -45,9 +45,11 @@ import { chainOfRepo, FOLLOW_MODES, forgeLinkOf } from "../spawn/chain.ts";
4545 import { watchMilestone } from "../actions/watches.ts";
4646 import {
4747 addCommentThrough,
48 closingFrom,
4849 editIssue,
4950 setIssueLabelsThrough,
5051 setIssueSealed,
52 setIssueStateThrough,
5153 TAKEN_UP,
5254 } from "../actions/issues.ts";
5355 import {
@@ -103,6 +105,7 @@ import { share } from "../feed/share.ts";
103105 import { parseTargets } from "./feed.ts";
104106 import { appsRuntime, envsFor } from "../apps/runtime.ts";
105107 import {
108 CLOSE_REASONS,
106109 getIssue,
107110 getIssueById,
108111 labelsFor,
@@ -293,7 +296,6 @@ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
293296
294297 const REPO_ARG = {
295298 type: "string",
296 description: "owner/name",

Low, scope: dropping description: "owner/name" from REPO_ARG takes the format hint off all 37 tools' repo argument. That isn't part of #3361. It was cut only to fit the 40 KB tools/list ceiling, and main has since trimmed the same descriptions. If there is room now, put it back. If there isn't, get the bytes from prose descriptions: the shape of an argument is what a harness reads first.

297299 } as const;
298300
299301 /** A repository the caller may read, by `owner/name`. */
@@ -950,6 +952,54 @@ const TOOLS: readonly Tool[] = [
950952 });
951953 },
952954 },
955 {
956 name: "close",
957 description:
958 "Close an issue or pull, or reopen it (`state` open); the PATCH route's refusals apply. `reason`: done, dropped, or split (with `into`).",
959 inputSchema: {
960 type: "object",
961 properties: {
962 repo: REPO_ARG,
963 number: { type: "integer", minimum: 1 },
964 state: { type: "string", enum: ["closed", "open"] },
965 reason: { type: "string", enum: [...CLOSE_REASONS] },
966 into: {
967 type: "array",
968 items: { type: "integer", minimum: 1 },
969 maxItems: 50,
970 },
971 },
972 required: ["repo", "number"],
973 },
974 writes: true,
975 async run(ctx, user, args) {
976 const repo = repoArg(ctx, user, args);
977 if (isAnswer(repo)) return repo;
978 const issue = issueArg(ctx, repo, args, "number");
979 if ("ok" in issue) return issue;
980 const state = args["state"] ?? "closed";
981 if (state !== "closed" && state !== "open")
982 return bad("invalid_state", "state is closed or open");
983 // The PATCH route's own check of `reason` and `into`.
984 const closing = closingFrom(args);
985 if (closing === undefined)
986 return bad("invalid_reason", "a reason is done, dropped, or split (with `into`: up to 50 issue numbers)");
987 const changed = await setIssueStateThrough(ctx.app, user, repo, issue, state, closing);
988 if (!changed.ok)

Low: any error other than forbidden gets the message "that state could not be set". The code is still passed on, so nothing is lost for a program. But a harness reading the message can't tell already_merged (reopening a merged pull) from mirrored, origin_unreachable, or invalid_reason (e.g. split into a pull number). The pulls PATCH also tells a planner the rule it works by ("a planner may close only an agent's pull whose issue is closed"). A small map from code to sentence would make this tool as instructive as the route.

989 return bad(
990 changed.error,
991 changed.error === "forbidden"
992 ? "this is not yours to close or reopen"
993 : "that state could not be set",
994 );
995 return good({
996 says: `#${issue.number} is ${changed.value.state}.`,
997 number: issue.number,
998 state: changed.value.state,
999 closeReason: changed.value.closeReason,
1000 });
1001 },
1002 },
9531003 {
9541004 name: "read_file",
9551005 description:
test/mcp.test.ts +63
@@ -13,6 +13,8 @@ import { createApp, type App } from "../src/app.ts";
1313 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1414 import { pullDir } from "../src/core/pulls.ts";
1515 import { resolveOid } from "../src/git/merge.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
1618 import { openPull } from "../src/actions/pulls.ts";
1719 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1820 import { createApp as insertApp } from "../src/core/apps.ts";
@@ -181,6 +183,7 @@ describe("the forge over MCP", () => {
181183 "pulls",
182184 "variant",
183185 "edit",
186 "close",
184187 "read_file",
185188 "asks",
186189 "asks_act",
@@ -456,6 +459,66 @@ describe("the forge over MCP", () => {
456459 ).toContain("not_found");
457460 });
458461
462 test("close is the PATCH route's action: the same refusals, the new state answered", async () => {
463 const made = await createAgent(app, alice, { username: "closer", brief: "x" });
464 if (!made.ok) throw new Error(String(made.error));
465 expect(addToCrew(app, alice, game, "closer").ok).toBe(true);
466 setCrewRole(app.db, game.id, made.value.user.id, "planner");
467 const botToken = createToken(app.db, made.value.user.id, "bot", "rw").token;
468 const mine = (title: string) => {
469 const o = openIssue(app, made.value.user, game, { title });
470 if (!o.ok) throw new Error(String(o.error));
471 return o.value;
472 };
473 const patch = async (token: string, number: number, payload: unknown) => {
474 const res = await fetch(`${base}/api/v1/repos/alice/game/issues/${number}`, {
475 method: "PATCH",
476 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
477 body: JSON.stringify(payload),
478 });
479 return { status: res.status, json: (await res.json()) as any };
480 };
481
482 // Done with no commit naming it: refused, with the API's code.
483 const noNote = mine("a thing nobody did");
484 const viaApi = await patch(botToken, noNote.number, { state: "closed", reason: "done" });
485 expect(viaApi.json.error).toBe("forbidden");
486 const refused = await tool(botToken, "close", { repo: "alice/game", number: noNote.number, reason: "done" });
487 expect(refused.error).toContain("forbidden");
488
489 // Its own, dropped: closed, and the answer says so.
490 const own = mine("a thing to drop");
491 const dropped = await tool(botToken, "close", {
492 repo: "alice/game",
493 number: own.number,
494 reason: "dropped",
495 });
496 expect(dropped.value).toMatchObject({ number: own.number, state: "closed", closeReason: "dropped" });
497
498 // A person's ask is not an agent's to drop.
499 const asked = openIssue(app, alice, game, { title: "a person asks" });
500 if (!asked.ok) throw new Error(String(asked.error));
501 expect(
502 (await tool(botToken, "close", { repo: "alice/game", number: asked.value.number, reason: "dropped" })).error,
503 ).toContain("forbidden");
504 // Nor does an agent reopen.
505 expect((await tool(botToken, "close", { repo: "alice/game", number: own.number, state: "open" })).error).toContain(
506 "forbidden",
507 );
508
509 // A person reopens it; closing defaults to done.
510 const reopened = await tool(rw, "close", { repo: "alice/game", number: own.number, state: "open" });
511 expect(reopened.value).toMatchObject({ state: "open", closeReason: null });
512 const closed = await tool(rw, "close", { repo: "alice/game", number: own.number });
513 expect(closed.value).toMatchObject({ state: "closed", closeReason: "done" });
514
515 // The boundary: bad words are refused before the action.
516 expect((await tool(rw, "close", { repo: "alice/game", number: own.number, state: "x" })).error).toContain("invalid_state");
517 expect((await tool(rw, "close", { repo: "alice/game", number: own.number, reason: "x" })).error).toContain("invalid_reason");
518 expect((await tool(rw, "close", { repo: "alice/game", number: own.number, into: [0] })).error).toContain("invalid_reason");
519 expect((await tool(ro, "close", { repo: "alice/game", number: own.number })).error).toContain("forbidden: this token may read, not act");
520 });
521
459522 test("issue reads body, comments, labels and what it waits for; a sealed one is not found; a reader is marked untrusted", async () => {
460523 const mk = (title: string, body = "", sealed = false) => {
461524 const r = createIssue(app.db, { repoId: game.id, authorId: alice.id, title, body, sealed });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs