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

MCP `map` takes `first: <number>` so an agent or a person can put a ready issue first #8226

Merged opened this

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

Merged

scout/forge:scout/7807-mcp-map-takes-first-number-so-an-agent-o → main 1 commit fork

  • Merged. scout/7807-mcp-map-takes-first-number-so-an-agent-o was merged into main as e82cd75.

Review

Sign in to review this change.
3 files changed +126 −6
docs/MCP.md +1 −1
@@ -107,7 +107,7 @@ for a tunnel and a forge that sleeps.
107107 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
108108 | `limits` | `{ key?, value?, dryRun? }` (forge admin): every limit the forge's owner can hit, with its value, default and bounds; with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); a value out of bounds is refused, applying needs an admin-scope token | yes |
109109 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
110 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too); draws the `ui://forge/map` card | |
110 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too; `first: <number>` puts that ready issue first, for writers: a reader is forbidden, a closed or blocked issue invalid); draws the `ui://forge/map` card | yes |
111111 | `merge` | `{ repo, number, strategy?, dryRun? }` (a person, never an agent: a person merges): land a pull as its page's Merge button does, `merge` (default) or `squash`; planned unless `dryRun` is false: the plan is the pull's `head`, `base`, `mergeable`, `conflicts` and `check` as `pull` reads them; landing is `mergePull`'s, with its refusals (conflict, review or check required, not a writer) in the page's words, and takes an rw token | yes |
112112 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
113113 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
src/http/mcp/tools/map.ts +13 −5
@@ -5,22 +5,30 @@ import { pillarView } from "../../../core/pillars.ts";
55 import { askToLiveFor } from "../../../web/ask_to_live.ts";
66 import { listActiveRuns } from "../../../core/runs.ts";
77 import { mapJson, marksFor, withoutDone } from "../../../web/pages/map.ts";
8 import { good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
8 import { putIssueFirst } from "../../../actions/first.ts";
9 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts";
910
1011 export const tool: Tool = {
1112 name: "map",
12 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too); draws the `ui://forge/map` card",
13 doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`, `done` to list closed issues too; `first: <number>` puts that ready issue first, for writers: a reader is forbidden, a closed or blocked issue invalid); draws the `ui://forge/map` card",
1314 description:
14 "An app's plan: open issues ready or waiting, critical path, edges, crew, `parkedTwice`; `done: true` adds closed issues.",
15 "An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first.",
1516 inputSchema: {
1617 type: "object",
17 properties: { app: APP_ARG, done: { type: "boolean" } },
18 properties: { app: APP_ARG, done: { type: "boolean" }, first: { type: "integer", minimum: 1 } },
1819 required: ["app"],
1920 },
20 writes: false,
21 writes: true,
22 readsWithout: "first",
2123 async run(ctx, user, args) {
2224 const repo = planRepo(ctx, user, args);
2325 if (isAnswer(repo)) return repo;
26 if (args["first"] !== undefined) {
27 const issue = issueArg(ctx, repo, args, "first");
28 if ("ok" in issue) return issue;
29 const put = putIssueFirst(ctx.app, user, repo, issue.number);
30 if (!put.ok) return bad(put.error.kind === "not_found" ? "no_such_issue" : put.error.kind, put.error.message);
31 }
2432 const scope = sealedScope(ctx.app.db, repo, user);
2533 const graph = repoGraph(ctx.app.db, repo.id, { ...scope });
2634 const pillars = pillarView(ctx.app.db, repo);
test/mcp-map-first.test.ts added +112
@@ -0,0 +1,112 @@
1 /** MCP `map` with `first: N` puts a ready issue first for a writer; a reader is forbidden, a closed or blocked issue invalid. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { addDependency } from "../src/core/dependencies.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { promote } from "./helpers/members.ts";
14
15 describe("MCP map first", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let aliceTok: string;
20 let carolTok: string;
21 let id = 1;
22
23 const call = async (token: string, args: unknown) => {
24 const res = await fetch(`${servers.url}/mcp`, {
25 method: "POST",
26 headers: {
27 "content-type": "application/json",
28 accept: "application/json, text/event-stream",
29 authorization: `Bearer ${token}`,
30 },
31 body: JSON.stringify({
32 jsonrpc: "2.0",
33 id: id++,
34 method: "tools/call",
35 params: { name: "map", arguments: args },
36 }),
37 });
38 const body: any = await res.json();
39 const text: string = body.result.content[0].text;
40 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
41 };
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-map-first-"));
45 app = createApp({ dataDir, port: 0 });
46 servers = await serve(app);
47 const mk = async (username: string, first = false) => {
48 const u = await createUser(app.db, {
49 origin: "seed",
50 trusted: true,
51 username,
52 email: `${username}@forge.local`,
53 password: "a-good-passphrase",
54 adminIfFirst: first,
55 });
56 if (!u.ok) throw new Error(u.error);
57 promote(app.db, u.value);
58 return u.value;
59 };
60 const alice = await mk("alice", true);
61 const carol = await mk("carol");
62 aliceTok = createToken(app.db, alice.id, "rw", "rw").token;
63 carolTok = createToken(app.db, carol.id, "rw", "rw").token;
64 const made = await createRepo(app, alice, { name: "one", visibility: "public" });
65 if (!made.ok) throw new Error(String(made.error));
66 const repoId = made.value.id;
67 const open = (title: string) => {
68 const i = createIssue(app.db, { repoId, authorId: alice.id, title });
69 if (!i.ok) throw new Error(i.error);
70 return i.value;
71 };
72 open("one");
73 open("two");
74 open("three");
75 const blocked = open("four");
76 const blocker = open("five");
77 const dep = addDependency(app.db, blocked, blocker, alice.id);
78 if (!dep.ok) throw new Error(String(dep.error));
79 const closed = open("six");
80 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(closed.id);
81 }, 30_000);
82
83 afterAll(async () => {
84 servers.stop();
85 app.close();
86 await rm(dataDir, { recursive: true, force: true });
87 });
88
89 const readyNumbers = (v: any): number[] => v.ready.map((r: any) => r.number ?? r.issue?.number);
90
91 test("a writer puts a ready issue first and the answer lists it first", async () => {
92 const before = await call(aliceTok, { app: "alice/one" });
93 expect(readyNumbers(before.value)[0]).not.toBe(3);
94 const put = await call(aliceTok, { app: "alice/one", first: 3 });
95 expect(put.error).toBeUndefined();
96 expect(readyNumbers(put.value)[0]).toBe(3);
97 expect(readyNumbers((await call(aliceTok, { app: "alice/one" })).value)[0]).toBe(3);
98 });
99
100 test("a reader is forbidden", async () => {
101 const r = await call(carolTok, { app: "alice/one", first: 2 });
102 expect(r.error).not.toContain("invalid");
103 expect(r.error).toContain("forbidden");
104 expect(readyNumbers((await call(carolTok, { app: "alice/one" })).value)[0]).toBe(3);
105 });
106
107 test("a blocked, a closed and a missing issue are invalid or unknown", async () => {
108 expect((await call(aliceTok, { app: "alice/one", first: 4 })).error).toContain("invalid");
109 expect((await call(aliceTok, { app: "alice/one", first: 6 })).error).toContain("invalid");
110 expect((await call(aliceTok, { app: "alice/one", first: 99 })).error).toContain("no_such_issue");
111 });
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs