Public: anyone can read this; the crew can change it.
The MCP reads and edits the plan: `map`, `link` and `unlink` tools #353
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/348-the-mcp-reads-and-edits-the-plan-map-lin → main 1 commit fork
5 files
changed +189
−10
docs/MCP.md +3
| @@ -37,6 +37,9 @@ for a tunnel and a forge that sleeps. | ||
| 37 | 37 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 38 | 38 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | |
| 39 | 39 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 40 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | | | |
| 41 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | | |
| 42 | | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes | | |
| 40 | 43 | |
| 41 | 44 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 42 | 45 | to relay as it is. |
src/actions/dependencies.ts +8
| @@ -18,6 +18,14 @@ import type { User } from "../core/users.ts"; | ||
| 18 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 19 | 19 | import type { AccessError } from "./access.ts"; |
| 20 | 20 | |
| 21 | /** What a refused link says, in the words the API and the MCP share. */ | |
| 22 | export const dependencyWords: Record<string, string> = { | |
| 23 | self: "an issue cannot wait for itself", | |
| 24 | cycle: "that would make a loop", | |
| 25 | cross_repo: "dependencies stay inside one repository", | |
| 26 | not_an_issue: "only issues can depend on issues", | |
| 27 | }; | |
| 28 | ||
| 21 | 29 | function requirePlanner( |
| 22 | 30 | app: App, |
| 23 | 31 | actor: User | undefined, |
src/http/api.ts +5 −8
| @@ -191,7 +191,11 @@ import { | ||
| 191 | 191 | } from "../actions/crew.ts"; |
| 192 | 192 | import { assignIssue } from "../actions/issues.ts"; |
| 193 | 193 | import { steerRepoRun } from "../actions/runs.ts"; |
| 194 | import { linkIssue, unlinkIssue } from "../actions/dependencies.ts"; | |
| 194 | import { | |
| 195 | dependencyWords, | |
| 196 | linkIssue, | |
| 197 | unlinkIssue, | |
| 198 | } from "../actions/dependencies.ts"; | |
| 195 | 199 | import { dependenciesOf, repoGraph } from "../core/dependencies.ts"; |
| 196 | 200 | import { decisionStats, listDecisions } from "../core/decisions.ts"; |
| 197 | 201 | import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts"; |
| @@ -255,13 +259,6 @@ function publicKey(key: UserKey) { | ||
| 255 | 259 | }; |
| 256 | 260 | } |
| 257 | 261 | |
| 258 | const dependencyWords: Record<string, string> = { | |
| 259 | self: "an issue cannot wait for itself", | |
| 260 | cycle: "that would make a loop", | |
| 261 | cross_repo: "dependencies stay inside one repository", | |
| 262 | not_an_issue: "only issues can depend on issues", | |
| 263 | }; | |
| 264 | ||
| 265 | 262 | function withLabels(ctx: Ctx, issue: Issue) { |
| 266 | 263 | return { ...issue, labels: labelsFor(ctx.app.db, issue.id) }; |
| 267 | 264 | } |
src/http/mcp.ts +118 −2
| @@ -19,10 +19,16 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { relationOf, takeUpdates } from "../actions/forks.ts"; |
| 21 | 21 | import { addCommentThrough } from "../actions/issues.ts"; |
| 22 | import { | |
| 23 | dependencyWords, | |
| 24 | linkIssue, | |
| 25 | unlinkIssue, | |
| 26 | } from "../actions/dependencies.ts"; | |
| 22 | 27 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 23 | 28 | import { tell, toldWords } from "../actions/tell.ts"; |
| 24 | 29 | import { appsRuntime } from "../apps/runtime.ts"; |
| 25 | 30 | import { getIssue, getIssueById } from "../core/issues.ts"; |
| 31 | import { repoGraph } from "../core/dependencies.ts"; | |
| 26 | 32 | import { getAppByRepo } from "../core/apps.ts"; |
| 27 | 33 | import { |
| 28 | 34 | canRead, |
| @@ -37,6 +43,7 @@ import { getUsersByIds, type User } from "../core/users.ts"; | ||
| 37 | 43 | import { audit } from "../contract/audit.ts"; |
| 38 | 44 | import { probeApp } from "../contract/probes.ts"; |
| 39 | 45 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| 46 | import { mapJson, marksFor } from "../web/pages/map.ts"; | |
| 40 | 47 | import { appsOf } from "../web/studio/asks.ts"; |
| 41 | 48 | import type { Ctx, Handler } from "./context.ts"; |
| 42 | 49 | import { body, json } from "./respond.ts"; |
| @@ -47,8 +54,7 @@ const SERVER = { name: "forge", version: "1" }; | ||
| 47 | 54 | |
| 48 | 55 | type Args = Record<string, unknown>; |
| 49 | 56 | type Answer = |
| 50 | | { ok: true; value: unknown } | |
| 51 | | { ok: false; error: string; message: string }; | |
| 57 | { ok: true; value: unknown } | { ok: false; error: string; message: string }; | |
| 52 | 58 | |
| 53 | 59 | const good = (value: unknown): Answer => ({ ok: true, value }); |
| 54 | 60 | const bad = (error: string, message: string): Answer => ({ |
| @@ -86,6 +92,25 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer { | ||
| 86 | 92 | : bad("not_found", "no such app or repository (owner/name)"); |
| 87 | 93 | } |
| 88 | 94 | |
| 95 | const APP_ARG = { | |
| 96 | type: "string", | |
| 97 | description: "The app, as owner/name.", | |
| 98 | } as const; | |
| 99 | ||
| 100 | /** The app a plan tool names, under `app`, read like any repository. */ | |
| 101 | const planRepo = (ctx: Ctx, user: User, args: Args): Repo | Answer => | |
| 102 | repoArg(ctx, user, { repo: args["app"] }); | |
| 103 | ||
| 104 | /** The issue a plan tool names by number, in that repository. */ | |
| 105 | function issueArg(ctx: Ctx, repo: Repo, args: Args, key: string) { | |
| 106 | const n = args[key]; | |
| 107 | const issue = | |
| 108 | typeof n === "number" && Number.isInteger(n) && n > 0 | |
| 109 | ? getIssue(ctx.app.db, repo.id, n) | |
| 110 | : undefined; | |
| 111 | return issue ?? bad("no_such_issue", `no issue #${String(n)}`); | |
| 112 | } | |
| 113 | ||
| 89 | 114 | const isAnswer = (v: Repo | Answer): v is Answer => "ok" in v; |
| 90 | 115 | |
| 91 | 116 | const TOOLS: readonly Tool[] = [ |
| @@ -419,6 +444,97 @@ const TOOLS: readonly Tool[] = [ | ||
| 419 | 444 | }); |
| 420 | 445 | }, |
| 421 | 446 | }, |
| 447 | { | |
| 448 | name: "map", | |
| 449 | description: | |
| 450 | "An app's plan: its open issues as ready now, or waiting on others (with what each waits for), the critical path, every edge, and where the crew is. The same data as the map page and /api/v1/map.", | |
| 451 | inputSchema: { | |
| 452 | type: "object", | |
| 453 | properties: { app: APP_ARG }, | |
| 454 | required: ["app"], | |
| 455 | }, | |
| 456 | writes: false, | |
| 457 | run(ctx, user, args) { | |
| 458 | const repo = planRepo(ctx, user, args); | |
| 459 | if (isAnswer(repo)) return repo; | |
| 460 | const graph = repoGraph(ctx.app.db, repo.id); | |
| 461 | return good( | |
| 462 | mapJson( | |
| 463 | repo, | |
| 464 | graph, | |
| 465 | marksFor( | |
| 466 | ctx.app.db, | |
| 467 | repo.id, | |
| 468 | graph, | |
| 469 | listActiveRuns(ctx.app.db, [repo.id]), | |
| 470 | ), | |
| 471 | ), | |
| 472 | ); | |
| 473 | }, | |
| 474 | }, | |
| 475 | { | |
| 476 | name: "link", | |
| 477 | description: | |
| 478 | "Say that an issue waits for another in the same app. Refused when it would make a loop, crosses repositories, or names an issue that does not exist.", | |
| 479 | inputSchema: { | |
| 480 | type: "object", | |
| 481 | properties: { | |
| 482 | app: APP_ARG, | |
| 483 | issue: { type: "integer", minimum: 1 }, | |
| 484 | blockedBy: { type: "integer", minimum: 1 }, | |
| 485 | }, | |
| 486 | required: ["app", "issue", "blockedBy"], | |
| 487 | }, | |
| 488 | writes: true, | |
| 489 | run(ctx, user, args) { | |
| 490 | const repo = planRepo(ctx, user, args); | |
| 491 | if (isAnswer(repo)) return repo; | |
| 492 | const issue = issueArg(ctx, repo, args, "issue"); | |
| 493 | if ("ok" in issue) return issue; | |
| 494 | const blocker = issueArg(ctx, repo, args, "blockedBy"); | |
| 495 | if ("ok" in blocker) return blocker; | |
| 496 | const linked = linkIssue(ctx.app, user, repo, issue, blocker); | |
| 497 | return linked.ok | |
| 498 | ? good({ | |
| 499 | says: `#${issue.number} now waits for #${blocker.number}.`, | |
| 500 | issue: issue.number, | |
| 501 | blockedBy: blocker.number, | |
| 502 | }) | |
| 503 | : bad( | |
| 504 | linked.error, | |
| 505 | dependencyWords[linked.error] ?? | |
| 506 | "you need write access to plan here", | |
| 507 | ); | |
| 508 | }, | |
| 509 | }, | |
| 510 | { | |
| 511 | name: "unlink", | |
| 512 | description: "Say that an issue no longer waits for another.", | |
| 513 | inputSchema: { | |
| 514 | type: "object", | |
| 515 | properties: { | |
| 516 | app: APP_ARG, | |
| 517 | issue: { type: "integer", minimum: 1 }, | |
| 518 | blockedBy: { type: "integer", minimum: 1 }, | |
| 519 | }, | |
| 520 | required: ["app", "issue", "blockedBy"], | |
| 521 | }, | |
| 522 | writes: true, | |
| 523 | run(ctx, user, args) { | |
| 524 | const repo = planRepo(ctx, user, args); | |
| 525 | if (isAnswer(repo)) return repo; | |
| 526 | const issue = issueArg(ctx, repo, args, "issue"); | |
| 527 | if ("ok" in issue) return issue; | |
| 528 | const blocker = issueArg(ctx, repo, args, "blockedBy"); | |
| 529 | if ("ok" in blocker) return blocker; | |
| 530 | const unlinked = unlinkIssue(ctx.app, user, repo, issue, blocker); | |
| 531 | return unlinked.ok | |
| 532 | ? good({ | |
| 533 | says: `#${issue.number} no longer waits for #${blocker.number}.`, | |
| 534 | }) | |
| 535 | : bad(unlinked.error, "you need write access to plan here"); | |
| 536 | }, | |
| 537 | }, | |
| 422 | 538 | ]; |
| 423 | 539 | |
| 424 | 540 | function rpcOne( |
test/mcp.test.ts +55
| @@ -164,6 +164,9 @@ describe("the forge over MCP", () => { | ||
| 164 | 164 | "following", |
| 165 | 165 | "take_updates", |
| 166 | 166 | "converge", |
| 167 | "map", | |
| 168 | "link", | |
| 169 | "unlink", | |
| 167 | 170 | ]); |
| 168 | 171 | const byName = new Map<string, any>( |
| 169 | 172 | listed.body.result.tools.map((t: any) => [t.name, t]), |
| @@ -394,6 +397,58 @@ describe("the forge over MCP", () => { | ||
| 394 | 397 | expect(stale.error).toContain("already ended"); |
| 395 | 398 | }); |
| 396 | 399 | |
| 400 | test("map, link and unlink are the plan's own actions", async () => { | |
| 401 | const a = (await tool(rw, "tell", { repo: "alice/game", text: "first" })) | |
| 402 | .value.issue; | |
| 403 | const b = (await tool(rw, "tell", { repo: "alice/game", text: "second" })) | |
| 404 | .value.issue; | |
| 405 | const listed = await call(rw, "tools/list"); | |
| 406 | const hints = Object.fromEntries( | |
| 407 | listed.body.result.tools.map((t: any) => [ | |
| 408 | t.name, | |
| 409 | t.annotations.readOnlyHint, | |
| 410 | ]), | |
| 411 | ); | |
| 412 | expect([hints.map, hints.link, hints.unlink]).toEqual([true, false, false]); | |
| 413 | ||
| 414 | expect( | |
| 415 | (await tool(rw, "link", { app: "alice/game", issue: b, blockedBy: a })) | |
| 416 | .value.blockedBy, | |
| 417 | ).toBe(a); | |
| 418 | const map = (await tool(ro, "map", { app: "alice/game" })).value; | |
| 419 | expect(map.ready.map((i: any) => i.number)).toContain(a); | |
| 420 | expect(map.blockedOn.map((i: any) => i.number)).toContain(a); | |
| 421 | expect(map.issues.find((i: any) => i.number === b).word).toBe("blocked"); | |
| 422 | expect(map.edges).toContainEqual({ issue: b, blockedBy: a }); | |
| 423 | ||
| 424 | // The API's words, for a loop, a stranger issue, and a read token. | |
| 425 | expect( | |
| 426 | (await tool(rw, "link", { app: "alice/game", issue: a, blockedBy: b })) | |
| 427 | .error, | |
| 428 | ).toBe("cycle: that would make a loop"); | |
| 429 | expect( | |
| 430 | (await tool(rw, "link", { app: "alice/game", issue: a, blockedBy: 999 })) | |
| 431 | .error, | |
| 432 | ).toBe("no_such_issue: no issue #999"); | |
| 433 | expect( | |
| 434 | (await tool(ro, "link", { app: "alice/game", issue: a, blockedBy: b })) | |
| 435 | .error, | |
| 436 | ).toContain("forbidden"); | |
| 437 | // A private app is no such app to someone who cannot read it. | |
| 438 | expect((await tool(bobRw, "map", { app: "alice/game" })).error).toContain( | |
| 439 | "not_found", | |
| 440 | ); | |
| 441 | expect( | |
| 442 | (await tool(bobRw, "link", { app: "alice/game", issue: b, blockedBy: a })) | |
| 443 | .error, | |
| 444 | ).toContain("not_found"); | |
| 445 | ||
| 446 | await tool(rw, "unlink", { app: "alice/game", issue: b, blockedBy: a }); | |
| 447 | const after = (await tool(ro, "map", { app: "alice/game" })).value; | |
| 448 | expect(after.edges).not.toContainEqual({ issue: b, blockedBy: a }); | |
| 449 | expect(after.ready.map((i: any) => i.number)).toContain(b); | |
| 450 | }); | |
| 451 | ||
| 397 | 452 | test("a browser session is not a way in, even signed in", async () => { |
| 398 | 453 | const signin = await fetch(`${base}/signin`, { |
| 399 | 454 | method: "POST", |
new above
offline