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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5003-mcp-confirm-wait-a-person-says-an-issue → main 2 commits fork 4 threads · 4 unresolved
4 files
changed +192
−0
docs/MCP.md +1
| @@ -82,6 +82,7 @@ for a tunnel and a forge that sleeps. | ||
| 82 | 82 | | `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 | | |
| 83 | 83 | | `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 | |
| 84 | 84 | | `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 | | |
| 85 | 86 | | `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 | |
| 86 | 87 | | `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 | |
| 87 | 88 | | `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 { | ||
| 64 | 64 | TAKEN_UP, |
| 65 | 65 | } from "../actions/issues.ts"; |
| 66 | 66 | import { |
| 67 | confirmIssue, | |
| 67 | 68 | dependencyWords, |
| 68 | 69 | linkIssue, |
| 69 | 70 | unlinkIssue, |
| @@ -3550,6 +3551,39 @@ const TOOLS: readonly Tool[] = [ | ||
| 3550 | 3551 | }); |
| 3551 | 3552 | }, |
| 3552 | 3553 | }, |
| 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" }, | |
| 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 |
||
| 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 | }, | |
| 3553 | 3587 | { |
| 3554 | 3588 | name: "attach_look", |
| 3555 | 3589 | 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", () => { | ||
| 251 | 251 | "usage", |
| 252 | 252 | "labels", |
| 253 | 253 | "wait_for", |
| 254 | "confirm_wait", | |
| 254 | 255 | "attach_look", |
| 255 | 256 | "hold", |
| 256 | 257 | "plan", |
new above
offline
Nit:
wait_fordeclaresminimum: 1on its issue numbers, so giveissueandblockerthe same to keep the schemas alike.issueArgalready refuses non-positive numbers, so this only affects what clients see in the schema.