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

The MCP reads and edits the plan: `map`, `link` and `unlink` tools #353

Merged opened this

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

Merged

scout/forge:scout/348-the-mcp-reads-and-edits-the-plan-map-lin → main 1 commit fork

  • Merged. scout/348-the-mcp-reads-and-edits-the-plan-map-lin was merged into main as e63de54.

Review

Sign in to review this change.
5 files changed +189 −10
docs/MCP.md +3
@@ -37,6 +37,9 @@ for a tunnel and a forge that sleeps.
3737 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
3838 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes |
3939 | `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 |
4043
4144 Answers are JSON in a text block; most carry a `says` sentence short enough
4245 to relay as it is.
src/actions/dependencies.ts +8
@@ -18,6 +18,14 @@ import type { User } from "../core/users.ts";
1818 import { err, ok, type Result } from "../kernel/result.ts";
1919 import type { AccessError } from "./access.ts";
2020
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
2129 function requirePlanner(
2230 app: App,
2331 actor: User | undefined,
src/http/api.ts +5 −8
@@ -191,7 +191,11 @@ import {
191191 } from "../actions/crew.ts";
192192 import { assignIssue } from "../actions/issues.ts";
193193 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";
195199 import { dependenciesOf, repoGraph } from "../core/dependencies.ts";
196200 import { decisionStats, listDecisions } from "../core/decisions.ts";
197201 import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts";
@@ -255,13 +259,6 @@ function publicKey(key: UserKey) {
255259 };
256260 }
257261
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
265262 function withLabels(ctx: Ctx, issue: Issue) {
266263 return { ...issue, labels: labelsFor(ctx.app.db, issue.id) };
267264 }
src/http/mcp.ts +118 −2
@@ -19,10 +19,16 @@
1919
2020 import { relationOf, takeUpdates } from "../actions/forks.ts";
2121 import { addCommentThrough } from "../actions/issues.ts";
22 import {
23 dependencyWords,
24 linkIssue,
25 unlinkIssue,
26 } from "../actions/dependencies.ts";
2227 import { cancelRepoRun } from "../actions/runs.ts";
2328 import { tell, toldWords } from "../actions/tell.ts";
2429 import { appsRuntime } from "../apps/runtime.ts";
2530 import { getIssue, getIssueById } from "../core/issues.ts";
31 import { repoGraph } from "../core/dependencies.ts";
2632 import { getAppByRepo } from "../core/apps.ts";
2733 import {
2834 canRead,
@@ -37,6 +43,7 @@ import { getUsersByIds, type User } from "../core/users.ts";
3743 import { audit } from "../contract/audit.ts";
3844 import { probeApp } from "../contract/probes.ts";
3945 import { convergeFork, convergeState } from "../crew/converge.ts";
46 import { mapJson, marksFor } from "../web/pages/map.ts";
4047 import { appsOf } from "../web/studio/asks.ts";
4148 import type { Ctx, Handler } from "./context.ts";
4249 import { body, json } from "./respond.ts";
@@ -47,8 +54,7 @@ const SERVER = { name: "forge", version: "1" };
4754
4855 type Args = Record<string, unknown>;
4956 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 };
5258
5359 const good = (value: unknown): Answer => ({ ok: true, value });
5460 const bad = (error: string, message: string): Answer => ({
@@ -86,6 +92,25 @@ function repoArg(ctx: Ctx, user: User, args: Args): Repo | Answer {
8692 : bad("not_found", "no such app or repository (owner/name)");
8793 }
8894
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
89114 const isAnswer = (v: Repo | Answer): v is Answer => "ok" in v;
90115
91116 const TOOLS: readonly Tool[] = [
@@ -419,6 +444,97 @@ const TOOLS: readonly Tool[] = [
419444 });
420445 },
421446 },
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 },
422538 ];
423539
424540 function rpcOne(
test/mcp.test.ts +55
@@ -164,6 +164,9 @@ describe("the forge over MCP", () => {
164164 "following",
165165 "take_updates",
166166 "converge",
167 "map",
168 "link",
169 "unlink",
167170 ]);
168171 const byName = new Map<string, any>(
169172 listed.body.result.tools.map((t: any) => [t.name, t]),
@@ -394,6 +397,58 @@ describe("the forge over MCP", () => {
394397 expect(stale.error).toContain("already ended");
395398 });
396399
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
397452 test("a browser session is not a way in, even signed in", async () => {
398453 const signin = await fetch(`${base}/signin`, {
399454 method: "POST",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs