Public: anyone can read this; the crew can change it.
The MCP plans in one call: `plan` opens several small issues and links them #355
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/349-the-mcp-plans-in-one-call-plan-opens-sev → main 1 commit fork
4 files
changed +362
−0
docs/MCP.md +25
| @@ -40,6 +40,7 @@ for a tunnel and a forge that sleeps. | ||
| 40 | 40 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | | |
| 41 | 41 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | |
| 42 | 42 | | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes | |
| 43 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | | |
| 43 | 44 | |
| 44 | 45 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 45 | 46 | to relay as it is. |
| @@ -61,3 +62,27 @@ does not speak yet; that is the next step here, and it changes nothing above | ||
| 61 | 62 | person pastes. |
| 62 | 63 | |
| 63 | 64 | `src/http/mcp.ts`, `src/actions/tell.ts`, `test/mcp.test.ts`. |
| 65 | ||
| 66 | ## `plan` | |
| 67 | ||
| 68 | Opens several small issues and their links in one transaction. Each item is | |
| 69 | `{ key, title, body?, blockedBy? }`; `blockedBy` lists keys of the same call or | |
| 70 | numbers of issues already open. A loop, an unknown key, an unknown number, an | |
| 71 | over-long title or body, or more than 12 items refuses the whole plan, naming | |
| 72 | the item, and nothing is opened. Issues belong to the token's owner and emit | |
| 73 | the same events as opening and linking one by one. It needs an `rw` token. | |
| 74 | ||
| 75 | json | |
| 76 | { "app": "alice/game", "issues": [ | |
| 77 | { "key": "board", "title": "Draw the board", "body": "8x8 grid" }, | |
| 78 | { "key": "moves", "title": "Legal moves" }, | |
| 79 | { "key": "play", "title": "Play a game", "blockedBy": ["board", "moves"] } ] } | |
| 80 | ||
| 81 | ||
| 82 | answers | |
| 83 | ||
| 84 | json | |
| 85 | { "says": "Opened 3 issues: board is #7, moves is #8, play is #9.", | |
| 86 | "opened": [{ "key": "board", "number": 7 }, { "key": "moves", "number": 8 }, { "key": "play", "number": 9 }], | |
| 87 | "ready": [7, 8] } | |
| 88 |
src/actions/plan.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | /** | |
| 2 | * Opening a plan: several small issues and the links between them, all or | |
| 3 | * nothing. Every refusal names the item that was wrong, and nothing is | |
| 4 | * written (or announced) until the whole plan is known to be good. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { isCrew } from "../core/agents.ts"; | |
| 9 | import { addDependency, repoGraph, readyNow } from "../core/dependencies.ts"; | |
| 10 | import { | |
| 11 | createIssue, | |
| 12 | getIssue, | |
| 13 | MAX_BODY, | |
| 14 | MAX_TITLE, | |
| 15 | type Issue, | |
| 16 | } from "../core/issues.ts"; | |
| 17 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; | |
| 18 | import type { User } from "../core/users.ts"; | |
| 19 | import { transact } from "../kernel/db.ts"; | |
| 20 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 21 | import { isMirror } from "../origins/write-through.ts"; | |
| 22 | ||
| 23 | export const PLAN_MAX = 12; | |
| 24 | const KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/; | |
| 25 | ||
| 26 | export interface PlanItem { | |
| 27 | readonly key: string; | |
| 28 | readonly title: string; | |
| 29 | readonly body: string; | |
| 30 | /** Keys of this plan, or numbers of issues already open in the repository. */ | |
| 31 | readonly blockedBy: readonly (string | number)[]; | |
| 32 | } | |
| 33 | ||
| 34 | /** A refusal: a stable code, and a sentence that names the item at fault. */ | |
| 35 | export interface PlanError { | |
| 36 | readonly error: string; | |
| 37 | readonly message: string; | |
| 38 | } | |
| 39 | ||
| 40 | const refuse = (error: string, message: string) => err({ error, message }); | |
| 41 | ||
| 42 | /** The plan as the wire gave it, checked into plain items. */ | |
| 43 | export function parsePlan(raw: unknown): Result<PlanItem[], PlanError> { | |
| 44 | if (!Array.isArray(raw) || raw.length === 0) | |
| 45 | return refuse("invalid", "issues must be a non-empty list"); | |
| 46 | if (raw.length > PLAN_MAX) | |
| 47 | return refuse("too_many", `a plan is at most ${PLAN_MAX} issues`); | |
| 48 | const items: PlanItem[] = []; | |
| 49 | const seen = new Set<string>(); | |
| 50 | for (const [i, entry] of raw.entries()) { | |
| 51 | const at = `issues[${i}]`; | |
| 52 | if (entry === null || typeof entry !== "object" || Array.isArray(entry)) | |
| 53 | return refuse("invalid", `${at} must be an object`); | |
| 54 | const o = entry as Record<string, unknown>; | |
| 55 | if (typeof o["key"] !== "string" || !KEY.test(o["key"])) | |
| 56 | return refuse( | |
| 57 | "invalid", | |
| 58 | `${at}: key must be a short name starting with a letter`, | |
| 59 | ); | |
| 60 | const key = o["key"]; | |
| 61 | if (seen.has(key)) | |
| 62 | return refuse("duplicate_key", `${at}: key "${key}" is used twice`); | |
| 63 | seen.add(key); | |
| 64 | const title = typeof o["title"] === "string" ? o["title"].trim() : ""; | |
| 65 | if (title === "") return refuse("empty_title", `"${key}" needs a title`); | |
| 66 | if (title.length > MAX_TITLE) | |
| 67 | return refuse( | |
| 68 | "title_too_long", | |
| 69 | `"${key}": title is over ${MAX_TITLE} characters`, | |
| 70 | ); | |
| 71 | const body = o["body"] ?? ""; | |
| 72 | if (typeof body !== "string") | |
| 73 | return refuse("invalid", `"${key}": body must be text`); | |
| 74 | if (body.length > MAX_BODY) | |
| 75 | return refuse( | |
| 76 | "body_too_long", | |
| 77 | `"${key}": body is over ${MAX_BODY} characters`, | |
| 78 | ); | |
| 79 | const waits = o["blockedBy"] ?? []; | |
| 80 | if (!Array.isArray(waits) || waits.length > PLAN_MAX * 2) | |
| 81 | return refuse("invalid", `"${key}": blockedBy must be a short list`); | |
| 82 | for (const w of waits) { | |
| 83 | const good = | |
| 84 | typeof w === "string" || | |
| 85 | (typeof w === "number" && Number.isInteger(w) && w > 0); | |
| 86 | if (!good) | |
| 87 | return refuse( | |
| 88 | "invalid", | |
| 89 | `"${key}": blockedBy takes keys or issue numbers`, | |
| 90 | ); | |
| 91 | } | |
| 92 | items.push({ key, title, body, blockedBy: waits as (string | number)[] }); | |
| 93 | } | |
| 94 | return ok(items); | |
| 95 | } | |
| 96 | ||
| 97 | export interface Planned { | |
| 98 | readonly opened: { key: string; number: number }[]; | |
| 99 | /** Numbers of the open issues nothing open stands in front of. */ | |
| 100 | readonly ready: number[]; | |
| 101 | } | |
| 102 | ||
| 103 | /** Which key sits on a loop among the plan's own links, if any. */ | |
| 104 | function loopAt(items: readonly PlanItem[]): string | undefined { | |
| 105 | const by = new Map(items.map((i) => [i.key, i])); | |
| 106 | const state = new Map<string, "walking" | "done">(); | |
| 107 | const visit = (key: string): string | undefined => { | |
| 108 | if (state.get(key) === "done") return undefined; | |
| 109 | if (state.get(key) === "walking") return key; | |
| 110 | state.set(key, "walking"); | |
| 111 | for (const w of by.get(key)!.blockedBy) | |
| 112 | if (typeof w === "string") { | |
| 113 | const hit = visit(w); | |
| 114 | if (hit !== undefined) return hit; | |
| 115 | } | |
| 116 | state.set(key, "done"); | |
| 117 | return undefined; | |
| 118 | }; | |
| 119 | for (const i of items) { | |
| 120 | const hit = visit(i.key); | |
| 121 | if (hit !== undefined) return hit; | |
| 122 | } | |
| 123 | return undefined; | |
| 124 | } | |
| 125 | ||
| 126 | export function openPlan( | |
| 127 | app: App, | |
| 128 | actor: User | undefined, | |
| 129 | repo: Repo, | |
| 130 | items: readonly PlanItem[], | |
| 131 | ): Result<Planned, PlanError> { | |
| 132 | if (!actor || !canRead(app.db, repo, actor)) | |
| 133 | return refuse("not_found", "no such app or repository (owner/name)"); | |
| 134 | if (!canWrite(app.db, repo, actor) && !isCrew(app.db, repo.id, actor.id)) | |
| 135 | return refuse("forbidden", "you need write access to plan here"); | |
| 136 | if (isMirror(app, repo)) | |
| 137 | return refuse( | |
| 138 | "mirrored", | |
| 139 | "this repository is mirrored; plan at its origin", | |
| 140 | ); | |
| 141 | ||
| 142 | const keys = new Set(items.map((i) => i.key)); | |
| 143 | const existing = new Map<number, Issue>(); | |
| 144 | for (const item of items) | |
| 145 | for (const w of item.blockedBy) { | |
| 146 | if (typeof w === "string") { | |
| 147 | if (!keys.has(w)) | |
| 148 | return refuse( | |
| 149 | "unknown_key", | |
| 150 | `"${item.key}" waits for "${w}", which is not in this plan`, | |
| 151 | ); | |
| 152 | if (w === item.key) | |
| 153 | return refuse( | |
| 154 | "self", | |
| 155 | `"${item.key}": ${"an issue cannot wait for itself"}`, | |
| 156 | ); | |
| 157 | } else { | |
| 158 | const found = getIssue(app.db, repo.id, w); | |
| 159 | if (!found || found.kind !== "issue") | |
| 160 | return refuse( | |
| 161 | "no_such_issue", | |
| 162 | `"${item.key}" waits for #${w}, which is not an issue here`, | |
| 163 | ); | |
| 164 | existing.set(w, found); | |
| 165 | } | |
| 166 | } | |
| 167 | const loop = loopAt(items); | |
| 168 | if (loop !== undefined) | |
| 169 | return refuse("cycle", `"${loop}" is on a loop: that would make a loop`); | |
| 170 | ||
| 171 | const opened: { key: string; number: number }[] = []; | |
| 172 | const failed = transact(app.db, (): PlanError | undefined => { | |
| 173 | app.db.exec("SAVEPOINT plan"); | |
| 174 | const made = new Map<string, Issue>(); | |
| 175 | const undo = (e: PlanError) => { | |
| 176 | app.db.exec("ROLLBACK TO plan"); | |
| 177 | app.db.exec("RELEASE plan"); | |
| 178 | return e; | |
| 179 | }; | |
| 180 | for (const item of items) { | |
| 181 | const created = createIssue(app.db, { | |
| 182 | repoId: repo.id, | |
| 183 | authorId: actor.id, | |
| 184 | title: item.title, | |
| 185 | body: item.body, | |
| 186 | }); | |
| 187 | if (!created.ok) | |
| 188 | return undo({ | |
| 189 | error: created.error, | |
| 190 | message: `"${item.key}": ${created.error}`, | |
| 191 | }); | |
| 192 | made.set(item.key, created.value); | |
| 193 | } | |
| 194 | for (const item of items) | |
| 195 | for (const w of item.blockedBy) { | |
| 196 | const blocker = typeof w === "string" ? made.get(w)! : existing.get(w)!; | |
| 197 | const linked = addDependency( | |
| 198 | app.db, | |
| 199 | made.get(item.key)!, | |
| 200 | blocker, | |
| 201 | actor.id, | |
| 202 | ); | |
| 203 | if (!linked.ok) | |
| 204 | return undo({ | |
| 205 | error: linked.error, | |
| 206 | message: `"${item.key}" cannot wait for ${String(w)}: ${linked.error}`, | |
| 207 | }); | |
| 208 | } | |
| 209 | app.db.exec("RELEASE plan"); | |
| 210 | for (const item of items) | |
| 211 | opened.push({ key: item.key, number: made.get(item.key)!.number }); | |
| 212 | return undefined; | |
| 213 | }); | |
| 214 | if (failed) return err(failed); | |
| 215 | ||
| 216 | // Announced only once the plan is committed, the same events as one by one. | |
| 217 | const number = new Map(opened.map((o) => [o.key, o.number])); | |
| 218 | for (const item of items) { | |
| 219 | app.bus.append({ | |
| 220 | kind: "issue.opened", | |
| 221 | repoId: repo.id, | |
| 222 | actorId: actor.id, | |
| 223 | payload: { number: number.get(item.key)! }, | |
| 224 | }); | |
| 225 | for (const w of item.blockedBy) | |
| 226 | app.bus.append({ | |
| 227 | kind: "issue.blocked", | |
| 228 | repoId: repo.id, | |
| 229 | actorId: actor.id, | |
| 230 | payload: { | |
| 231 | number: number.get(item.key)!, | |
| 232 | blockedBy: typeof w === "string" ? number.get(w)! : w, | |
| 233 | }, | |
| 234 | }); | |
| 235 | } | |
| 236 | return ok({ | |
| 237 | opened, | |
| 238 | ready: readyNow(repoGraph(app.db, repo.id)).map((r) => r.issue.number), | |
| 239 | }); | |
| 240 | } |
src/http/mcp.ts +49
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | linkIssue, |
| 25 | 25 | unlinkIssue, |
| 26 | 26 | } from "../actions/dependencies.ts"; |
| 27 | import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts"; | |
| 27 | 28 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 28 | 29 | import { tell, toldWords } from "../actions/tell.ts"; |
| 29 | 30 | import { appsRuntime } from "../apps/runtime.ts"; |
| @@ -535,6 +536,54 @@ const TOOLS: readonly Tool[] = [ | ||
| 535 | 536 | : bad(unlinked.error, "you need write access to plan here"); |
| 536 | 537 | }, |
| 537 | 538 | }, |
| 539 | { | |
| 540 | name: "plan", | |
| 541 | description: `Open several small issues and link them in one call: all of them or none. Each item has a key, a title, a body and optionally blockedBy, a list of keys of this plan or numbers of issues already open. A bad link (loop, unknown key) refuses the whole plan and names the item. At most ${PLAN_MAX} issues. Answers with the numbers opened, by key, and what is ready now.`, | |
| 542 | inputSchema: { | |
| 543 | type: "object", | |
| 544 | properties: { | |
| 545 | app: APP_ARG, | |
| 546 | issues: { | |
| 547 | type: "array", | |
| 548 | minItems: 1, | |
| 549 | maxItems: PLAN_MAX, | |
| 550 | items: { | |
| 551 | type: "object", | |
| 552 | properties: { | |
| 553 | key: { | |
| 554 | type: "string", | |
| 555 | description: "A short name for this item, unique in the plan.", | |
| 556 | }, | |
| 557 | title: { type: "string" }, | |
| 558 | body: { type: "string" }, | |
| 559 | blockedBy: { | |
| 560 | type: "array", | |
| 561 | items: { type: ["string", "integer"] }, | |
| 562 | description: "Keys in this plan, or numbers of open issues.", | |
| 563 | }, | |
| 564 | }, | |
| 565 | required: ["key", "title"], | |
| 566 | }, | |
| 567 | }, | |
| 568 | }, | |
| 569 | required: ["app", "issues"], | |
| 570 | }, | |
| 571 | writes: true, | |
| 572 | run(ctx, user, args) { | |
| 573 | const repo = planRepo(ctx, user, args); | |
| 574 | if (isAnswer(repo)) return repo; | |
| 575 | const items = parsePlan(args["issues"]); | |
| 576 | if (!items.ok) return bad(items.error.error, items.error.message); | |
| 577 | const planned = openPlan(ctx.app, user, repo, items.value); | |
| 578 | if (!planned.ok) return bad(planned.error.error, planned.error.message); | |
| 579 | const { opened, ready } = planned.value; | |
| 580 | return good({ | |
| 581 | says: `Opened ${opened.length} issue${opened.length === 1 ? "" : "s"}: ${opened.map((o) => `${o.key} is #${o.number}`).join(", ")}.`, | |
| 582 | opened, | |
| 583 | ready, | |
| 584 | }); | |
| 585 | }, | |
| 586 | }, | |
| 538 | 587 | ]; |
| 539 | 588 | |
| 540 | 589 | function rpcOne( |
test/mcp.test.ts +48
| @@ -167,6 +167,7 @@ describe("the forge over MCP", () => { | ||
| 167 | 167 | "map", |
| 168 | 168 | "link", |
| 169 | 169 | "unlink", |
| 170 | "plan", | |
| 170 | 171 | ]); |
| 171 | 172 | const byName = new Map<string, any>( |
| 172 | 173 | listed.body.result.tools.map((t: any) => [t.name, t]), |
| @@ -449,6 +450,53 @@ describe("the forge over MCP", () => { | ||
| 449 | 450 | expect(after.ready.map((i: any) => i.number)).toContain(b); |
| 450 | 451 | }); |
| 451 | 452 | |
| 453 | test("plan opens linked issues all or none", async () => { | |
| 454 | const issues = [ | |
| 455 | { key: "a", title: "First" }, | |
| 456 | { key: "b", title: "Second", body: "two" }, | |
| 457 | { key: "c", title: "Third", blockedBy: ["a", "b"] }, | |
| 458 | ]; | |
| 459 | const done = (await tool(rw, "plan", { app: "alice/game", issues })).value; | |
| 460 | const n = Object.fromEntries( | |
| 461 | done.opened.map((o: any) => [o.key, o.number]), | |
| 462 | ); | |
| 463 | expect(done.ready).toEqual(expect.arrayContaining([n.a, n.b])); | |
| 464 | expect(done.ready).not.toContain(n.c); | |
| 465 | const map = (await tool(ro, "map", { app: "alice/game" })).value; | |
| 466 | expect(map.edges).toContainEqual({ issue: n.c, blockedBy: n.a }); | |
| 467 | expect(map.edges).toContainEqual({ issue: n.c, blockedBy: n.b }); | |
| 468 | ||
| 469 | const count = async () => | |
| 470 | (await tool(ro, "map", { app: "alice/game" })).value.issues.length; | |
| 471 | const before = await count(); | |
| 472 | const loop = await tool(rw, "plan", { | |
| 473 | app: "alice/game", | |
| 474 | issues: [ | |
| 475 | { key: "x", title: "X", blockedBy: ["y"] }, | |
| 476 | { key: "y", title: "Y", blockedBy: ["x"] }, | |
| 477 | ], | |
| 478 | }); | |
| 479 | expect(loop.error).toContain("cycle"); | |
| 480 | const unknown = await tool(rw, "plan", { | |
| 481 | app: "alice/game", | |
| 482 | issues: [{ key: "x", title: "X", blockedBy: ["nope"] }], | |
| 483 | }); | |
| 484 | expect(unknown.error).toContain('"nope"'); | |
| 485 | const many = await tool(rw, "plan", { | |
| 486 | app: "alice/game", | |
| 487 | issues: Array.from({ length: 13 }, (_, i) => ({ | |
| 488 | key: `k${i}`, | |
| 489 | title: "t", | |
| 490 | })), | |
| 491 | }); | |
| 492 | expect(many.error).toContain("too_many"); | |
| 493 | expect(await count()).toBe(before); | |
| 494 | ||
| 495 | expect( | |
| 496 | (await tool(ro, "plan", { app: "alice/game", issues })).error, | |
| 497 | ).toContain("forbidden"); | |
| 498 | }); | |
| 499 | ||
| 452 | 500 | test("a browser session is not a way in, even signed in", async () => { |
| 453 | 501 | const signin = await fetch(`${base}/signin`, { |
| 454 | 502 | method: "POST", |
new above
offline