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

MCP `confirm_wait`: a person says an issue may go ahead although what it waited for closed dropped or split #5028

Merged opened this

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

Merged

scout/forge:scout/5003-mcp-confirm-wait-a-person-says-an-issue → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/5003-mcp-confirm-wait-a-person-says-an-issue was merged into main as 84b1f43.

Review

Sign in to review this change.
4 files changed +192 −0
docs/MCP.md +1
@@ -82,6 +82,7 @@ for a tunnel and a forge that sleeps.
8282 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
8383 | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes |
8484 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
85 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
8586 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
8687 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
8788 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
src/http/mcp.ts +34
@@ -64,6 +64,7 @@ import {
6464 TAKEN_UP,
6565 } from "../actions/issues.ts";
6666 import {
67 confirmIssue,
6768 dependencyWords,
6869 linkIssue,
6970 unlinkIssue,
@@ -3550,6 +3551,39 @@ const TOOLS: readonly Tool[] = [
35503551 });
35513552 },
35523553 },
3554 {
3555 name: "confirm_wait",
3556 description:
3557 "Let an issue go ahead though its blocker closed dropped or split.",
3558 inputSchema: {
3559 type: "object",
3560 properties: {
3561 app: APP_ARG,
3562 issue: { type: "integer" },

Nit: wait_for declares minimum: 1 on its issue numbers, so give issue and blocker the same to keep the schemas alike. issueArg already refuses non-positive numbers, so this only affects what clients see in the schema.

3563 blocker: { type: "integer" },
3564 },
3565 required: ["app", "issue", "blocker"],
3566 },
3567 writes: true,
3568 run(ctx, user, args) {
3569 const repo = planRepo(ctx, user, args);
3570 if (isAnswer(repo)) return repo;
3571 const issue = issueArg(ctx, repo, args, "issue");
3572 if ("ok" in issue) return issue;
3573 const blocker = issueArg(ctx, repo, args, "blocker");
3574 if ("ok" in blocker) return blocker;
3575 const done = confirmIssue(ctx.app, user, repo, issue, blocker);
3576 if (!done.ok)
3577 return bad(
3578 done.error,
3579 "You need write access, and not a place on the crew, to confirm a wait.",

Every refusal gets the same text, "You need write access…", including unauthorized and not_found, where it is misleading. A small { forbidden: …, not_found: …, unauthorized: … } map would make it match the route's respondAccess. Optional.

3580 );
3581 return good({
3582 says: `#${issue.number} may go ahead without #${blocker.number}.`,
3583 url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/issues/${issue.number}`,
3584 });
3585 },
3586 },
35533587 {
35543588 name: "attach_look",
35553589 description:
test/mcp-confirm-wait.test.ts added +156
@@ -0,0 +1,156 @@
1 /**
2 * The `confirm_wait` tool is the API's confirm action over MCP: a writer
3 * unparks an issue held by a dropped blocker; readers, strangers and the
4 * crew are refused as the route refuses them; bad input is refused.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { addCrew, createAgentRow } from "../src/core/agents.ts";
13 import { addDependency } from "../src/core/dependencies.ts";
14 import { getIssue } from "../src/core/issues.ts";
15 import { setIssueState } from "../src/actions/issues.ts";
16 import { createApp as insertApp } from "../src/core/apps.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { promote } from "./helpers/members.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21
22 describe("confirm_wait over MCP", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let alice: User;
27 let repo: any;
28 let rw: string;
29 let ro: string;
30 let bobRw: string;
31 let crewTok: string;
32 let id = 1;
33
34 const post = async (token: string, method: string, params: unknown) => {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: {
38 "content-type": "application/json",
39 accept: "application/json, text/event-stream",
40 authorization: `Bearer ${token}`,
41 },
42 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
43 });
44 return (await res.json()) as any;
45 };
46 const tool = async (token: string, name: string, args: unknown = {}) => {
47 const body = await post(token, "tools/call", { name, arguments: args });
48 const text: string = body.result.content[0].text;
49 return body.result.isError === true
50 ? { error: text }
51 : { value: JSON.parse(text) };
52 };
53 const tell = async (text: string): Promise<number> =>
54 (await tool(rw, "tell", { repo: "alice/game", text })).value.issue;
55 /** An issue waiting on a blocker that closed dropped. */
56 const parked = async () => {
57 const blocker = await tell("plan");
58 const waiter = await tell("built on it");
59 addDependency(
60 app.db,
61 getIssue(app.db, repo.id, waiter)!,
62 getIssue(app.db, repo.id, blocker)!,
63 alice.id,
64 );
65 const closed = setIssueState(app, alice, repo, getIssue(app.db, repo.id, blocker)!, "closed", {
66 reason: "dropped",
67 });
68 if (!closed.ok) throw new Error(String(closed.error));
69 return { blocker, waiter };
70 };
71 const parkedBecause = async (waiter: number) =>
72 (await tool(rw, "map", { app: "alice/game" })).value.issues.find(
73 (i: any) => i.number === waiter,
74 ).parkedBecause;
75
76 beforeAll(async () => {
77 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-confirm-"));
78 app = createApp({ dataDir, port: 0 });
79 servers = await serve(app);
80 const a = await createUser(app.db, {
81 origin: "seed",
82 trusted: true,
83 username: "alice",
84 email: "[email protected]",
85 password: "a-good-passphrase",
86 adminIfFirst: true,
87 });
88 if (!a.ok) throw new Error(a.error);
89 alice = a.value;
90 rw = createToken(app.db, alice.id, "rw", "rw").token;
91 ro = createToken(app.db, alice.id, "ro", "ro").token;
92 const b = await createUser(app.db, {
93 origin: "seed",
94 trusted: true,
95 username: "bob",
96 email: "[email protected]",
97 password: "a-good-passphrase",
98 });
99 if (!b.ok) throw new Error(b.error);
100 promote(app.db, b.value);
101 bobRw = createToken(app.db, b.value.id, "rw", "rw").token;
102 const bot = await createUser(app.db, {
103 username: "botty",
104 email: "[email protected]",
105 agent: true,
106 origin: "agent",
107 });
108 if (!bot.ok) throw new Error(bot.error);
109 createAgentRow(app.db, { userId: bot.value.id, brief: "botty." });
110 crewTok = createToken(app.db, bot.value.id, "rw", "rw").token;
111 const made = await createRepo(app, alice, { name: "game", visibility: "private" });
112 if (!made.ok) throw new Error(String(made.error));
113 repo = made.value;
114 const rec = insertApp(app.db, { repoId: repo.id, slug: "game", access: "private" });
115 if (!rec.ok) throw new Error(rec.error);
116 addCrew(app.db, repo.id, bot.value.id, alice.id);
117 }, 30_000);
118
119 afterAll(async () => {
120 servers.stop();
121 app.close();
122 await rm(dataDir, { recursive: true, force: true });
123 });
124
125 test("is listed with a schema", async () => {
126 const listed = await post(rw, "tools/list", {});
127 const t = listed.result.tools.find((x: any) => x.name === "confirm_wait");
128 expect(t.inputSchema.required).toEqual(["app", "issue", "blocker"]);
129 });
130
131 test("a writer confirms and the map no longer shows it parked", async () => {
132 const { blocker, waiter } = await parked();
133 expect(await parkedBecause(waiter)).toBe(`#${blocker} was dropped`);
134 const done = await tool(rw, "confirm_wait", { app: "alice/game", issue: waiter, blocker });
135 expect(done.value.says).toBe(`#${waiter} may go ahead without #${blocker}.`);
136 expect(await parkedBecause(waiter)).toBeUndefined();
137 });
138
139 test("a reader, a stranger and the crew are refused, and nothing changes", async () => {
140 const { blocker, waiter } = await parked();
141 const args = { app: "alice/game", issue: waiter, blocker };
142 expect((await tool(ro, "confirm_wait", args)).error).toContain("forbidden");
143 expect((await tool(bobRw, "confirm_wait", args)).error).toBeDefined();
144 expect((await tool(crewTok, "confirm_wait", args)).error).toContain("forbidden");
145 expect(await parkedBecause(waiter)).toBe(`#${blocker} was dropped`);
146 });
147
148 test("bad input is refused at the boundary", async () => {
149 const w = (args: Record<string, unknown>) =>
150 tool(rw, "confirm_wait", { app: "alice/game", ...args });
151 expect((await w({ issue: 1, blocker: 9999 })).error).toContain("no_such_issue");
152 expect((await w({ issue: "1", blocker: 1 })).error).toContain("no_such_issue");
153 expect((await w({ issue: 1, blocker: 1.5 })).error).toContain("no_such_issue");
154 expect((await tool(rw, "confirm_wait", { issue: 1, blocker: 1 })).error).toBeDefined();
155 });
156 });
test/mcp.test.ts +1
@@ -251,6 +251,7 @@ describe("the forge over MCP", () => {
251251 "usage",
252252 "labels",
253253 "wait_for",
254 "confirm_wait",
254255 "attach_look",
255256 "hold",
256257 "plan",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs