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

MCP: propose to the parent forge and list proposals #2440

Merged opened this

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

Merged

scout/forge:scout/2231-mcp-propose-to-the-parent-forge-and-list → main 2 commits fork

  • Merged. scout/2231-mcp-propose-to-the-parent-forge-and-list was merged into main as 675840b.

Review

Sign in to review this change.
4 files changed +293 −1
README.md +3 −1
@@ -119,7 +119,9 @@ The page and `GET …/following/proposals` show each proposal as open, merged or
119119 closed, read from the parent (the token may read back only pulls it opened) and
120120 cached for a minute (only a signed-in viewer makes the parent get asked); a merged or closed one is final. A parent that does not
121121 answer shows the last state with "parent unreachable" — never an error page.
122 In the terminal, the Chain card lists the proposals and `p` proposes. No dependency was added. Left out: an MCP tool, and a UI to paste
122 In the terminal, the Chain card lists the proposals and `p` proposes. The MCP `propose_to_parent_forge` tool (writer only) proposes with the page's
123 words, and `forge_proposals` lists each proposal with its state and "parent
124 unreachable" where that applies. No dependency was added. Left out: a UI to paste
123125 the token into `parent.json` (part 2 of #2182 covers minting and handing over).
124126
125127 Minting, listing and revoking go through one action
src/http/mcp.ts +49
@@ -138,6 +138,7 @@ import {
138138 import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts";
139139 import { appsOf, asksAnswer } from "../web/studio/asks.ts";
140140 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
141 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../actions/upstream.ts";
141142 import { listProposals, mintProposal, repoByName, revokeProposal } from "../actions/proposal_tokens.ts";
142143 import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts";
143144 import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts";
@@ -1455,6 +1456,54 @@ const TOOLS: readonly Tool[] = [
14551456 );
14561457 },
14571458 },
1459 {
1460 name: "propose_to_parent_forge",
1461 description:
1462 "Offer this forge's landed change to its parent forge: the default branch is pushed to `child/<name>/…` there and a pull opens, naming the change. Asking again for the same tip answers with the pull already made. A writer only. Needs the parent's proposal token (kept in parent.json); the same words as the Propose button on the following page.",
1463 inputSchema: {
1464 type: "object",
1465 properties: { repo: REPO_ARG },
1466 required: ["repo"],
1467 },
1468 writes: true,
1469 async run(ctx, user, args) {
1470 const repo = repoArg(ctx, user, args);
1471 if (isAnswer(repo)) return repo;
1472 const made = await proposeToParentForge(ctx.app, user, repo);
1473 if (made.ok)
1474 return good({
1475 says: made.value.opened
1476 ? `Proposed to the parent forge as #${made.value.number}.`
1477 : `Already proposed to the parent forge as #${made.value.number}.`,
1478 opened: made.value.opened,
1479 proposal: made.value,
1480 });
1481 const e = made.error;
1482 if (e === "no_parent_forge") return bad(e, "this forge has no parent forge to propose to");
1483 if (e === "nothing_to_propose") return bad(e, "nothing here is ahead of the parent forge");
1484 if (typeof e === "string") return bad(e, "you need write access to propose from here");
1485 return e.kind === "unreachable"
1486 ? bad("parent_unreachable", "parent unreachable: nothing was proposed")
1487 : bad("refused", `the parent forge did not take the proposal: ${e.reason}`);
1488 },
1489 },
1490 {
1491 name: "forge_proposals",
1492 description:
1493 "What this forge has proposed to its parent forge, newest first: each with its pull at the parent, its number, and its state there (open, merged or closed). `reachable` is false, and the line says \"parent unreachable\", when the parent did not answer and the last known state stands. The same as GET /api/v1/repos/:owner/:name/following/proposals.",
1494 inputSchema: {
1495 type: "object",
1496 properties: { repo: REPO_ARG },
1497 required: ["repo"],
1498 },
1499 writes: false,
1500 async run(ctx, user, args) {
1501 const repo = repoArg(ctx, user, args);
1502 if (isAnswer(repo)) return repo;
1503 const items = await forgeProposals(ctx.app, repo, Date.now(), true);
1504 return good({ items: items.map((p) => ({ ...p, says: forgeProposalLine(p) })) });
1505 },
1506 },
14581507 {
14591508 name: "converge",
14601509 description:
test/mcp-upstream.test.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * The MCP doors to proposing across forges (#2231): one tool proposes to the
3 * parent forge, one read lists the proposals; a writer proposes and lists, a
4 * reader lists and is refused the proposal, and the same tip is the same pull.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { mergePull, openPull } from "../src/actions/pulls.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { mintProposalToken } from "../src/core/proposal_token.ts";
15 import { getPull } from "../src/core/pulls.ts";
16 import { getRepo, type Repo } from "../src/core/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { runGit } from "../src/git/env.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { proposalLimiter } from "../src/http/proposal_gate.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { keepParentCredential } from "../src/spawn/parent_credential.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const PASSWORD = "a-long-upstream-passphrase";
29
30 describe("MCP proposes to the parent forge and lists proposals", () => {
31 let dir = "";
32 let parent: App;
33 let child: App;
34 let parentServer: Servers;
35 let childServer: Servers;
36 let alice: User;
37 let kid: User;
38 let lib: Repo;
39 let forge: Repo;
40 let secret = "";
41
42 const person = async (app: App, username: string) => {
43 const made = await createUser(app.db, {
44 trusted: true,
45 username,
46 email: `${username}@forge.local`,
47 password: PASSWORD,
48 adminIfFirst: true,
49 });
50 if (!made.ok) throw new Error(made.error);
51 return made.value;
52 };
53
54 /** A change landing on the child: a branch, a pull, a merge. */
55 const land = async (branch: string, title: string) => {
56 const c = await commitToBranch(child, kid, forge, {
57 branch,
58 base: "main",
59 message: title,
60 files: [{ path: `${branch}.md`, content: `${title}\n` }],
61 });
62 if (!c.ok) throw new Error(JSON.stringify(c.error));
63 const opened = await openPull(child, kid, forge, {
64 title,
65 baseBranch: "main",
66 headBranch: branch,
67 });
68 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
69 const merged = await mergePull(child, kid, forge, opened.value, "merge");
70 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
71 return opened.value;
72 };
73
74 const forgeCount = (): number =>
75 child.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM upstream_proposal").get()!.n;
76 const parentPull = (number: number) => getPull(parent.db, lib.id, number)!;
77
78 beforeAll(async () => {
79 dir = await mkdtemp(join(tmpdir(), "forge-upstream-"));
80 parent = createApp({ dataDir: join(dir, "parent"), port: 0, host: "parent.test" });
81 parentServer = await serve(parent);
82 alice = await person(parent, "alice");
83 const made = await createRepo(parent, alice, { name: "lib", visibility: "public" });
84 if (!made.ok) throw new Error(String(made.error));
85 lib = made.value;
86 const seeded = await commitToBranch(parent, alice, lib, {
87 branch: "main",
88 message: "Start",
89 files: [{ path: "README.md", content: "hi\n" }],
90 });
91 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
92 const minted = mintProposalToken(parent.db, alice.id, lib.id, {
93 child: "kid",
94 origin: "https://kid.example.com/",
95 });
96 if (!minted.ok) throw new Error(minted.error);
97 secret = minted.value.token;
98 // Many presses in one minute are the test's doing, not a flood.
99 proposalLimiter(parent).setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true });
100
101 child = createApp({
102 dataDir: join(dir, "child"),
103 port: 0,
104 host: "kid.test",
105 url: "https://kid.example.com",
106 });
107 childServer = await serve(child);
108 kid = await person(child, "kid");
109 const f = await createRepo(child, kid, { name: "forge", visibility: "public" });
110 if (!f.ok) throw new Error(String(f.error));
111 // The child's code is a copy of the parent's: the same history.
112 const there = repoDir(child.reposRoot, "kid", "forge");
113 await rm(there, { recursive: true, force: true });
114 const cloned = await runGit(
115 ["clone", "--bare", repoDir(parent.reposRoot, "alice", "lib"), there],
116 {},
117 );
118 if (!cloned.ok) throw new Error(String(cloned.error));
119 forge = getRepo(child.db, "kid", "forge")!;
120 const tip = await runGit(["rev-parse", "main"], { cwd: there });
121 if (!tip.ok) throw new Error(String(tip.error));
122 await Bun.write(
123 join(child.config.dataDir, "parent.json"),
124 JSON.stringify({
125 upstream: parentServer.url,
126 name: "kid",
127 forgeCode: {
128 repo: "kid/forge",
129 upstream: "alice/lib",
130 mode: "tell-me",
131 tip: tip.value.trim(),
132 chain: [],
133 },
134 }),
135 );
136 const kept = await keepParentCredential(child, { child: "kid", token: secret });
137 if (!kept.ok) throw new Error(kept.error);
138 });
139
140 afterAll(async () => {
141 await childServer.stop();
142 await parentServer.stop();
143 await rm(dir, { recursive: true, force: true });
144 });
145
146 const call = async (token: string, name: string) => {
147 const res = await fetch(`${childServer.url}/mcp`, {
148 method: "POST",
149 headers: {
150 "content-type": "application/json",
151 accept: "application/json, text/event-stream",
152 authorization: `Bearer ${token}`,
153 },
154 body: JSON.stringify({
155 jsonrpc: "2.0",
156 id: 1,
157 method: "tools/call",
158 params: { name, arguments: { repo: "kid/forge" } },
159 }),
160 });
161 const body = (await res.json()) as any;
162 const text = body.result.content[0].text as string;
163 return { error: body.result.isError === true, text };
164 };
165
166 test("tools/list names both", async () => {
167 const token = createToken(child.db, kid.id, "rw", "rw").token;
168 const res = await fetch(`${childServer.url}/mcp`, {
169 method: "POST",
170 headers: {
171 "content-type": "application/json",
172 accept: "application/json, text/event-stream",
173 authorization: `Bearer ${token}`,
174 },
175 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
176 });
177 const tools = ((await res.json()) as any).result.tools as { name: string; annotations: any }[];
178 const by = (n: string) => tools.find((t) => t.name === n);
179 expect(by("propose_to_parent_forge")?.annotations.readOnlyHint).toBe(false);
180 expect(by("forge_proposals")?.annotations.readOnlyHint).toBe(true);
181 });
182
183 test("with nothing ahead, the tool says what the page says", async () => {
184 const writer = createToken(child.db, kid.id, "rw", "rw").token;
185 const none = await call(writer, "propose_to_parent_forge");
186 expect(none.error).toBe(true);
187 expect(none.text).toBe("nothing_to_propose: nothing here is ahead of the parent forge");
188 expect(JSON.parse((await call(writer, "forge_proposals")).text).items).toEqual([]);
189 });
190
191 test("a writer proposes and lists; the same tip is the same pull", async () => {
192 const writer = createToken(child.db, kid.id, "rw", "rw").token;
193 await land("feat", "Add feat");
194 const made = await call(writer, "propose_to_parent_forge");
195 expect(made.error).toBe(false);
196 const first = JSON.parse(made.text);
197 expect(first.opened).toBe(true);
198 expect(parentPull(first.proposal.number).title).toBe("Add feat");
199
200 const again = JSON.parse((await call(writer, "propose_to_parent_forge")).text);
201 expect(again.opened).toBe(false);
202 expect(again.proposal.number).toBe(first.proposal.number);
203
204 const listed = JSON.parse((await call(writer, "forge_proposals")).text).items;
205 expect(listed).toHaveLength(1);
206 expect(listed[0].number).toBe(first.proposal.number);
207 expect(listed[0].state).toBe("open");
208 expect(listed[0].url).toBe(`${parentServer.url}/alice/lib/pulls/${first.proposal.number}`);
209 });
210
211 test("a reader is refused the proposal and can still list", async () => {
212 const made = await createUser(child.db, {
213 trusted: true,
214 username: "reader",
215 email: "[email protected]",
216 password: PASSWORD,
217 });
218 if (!made.ok) throw new Error(made.error);
219 const reader = createToken(child.db, made.value.id, "rw", "rw").token;
220 const readOnly = createToken(child.db, kid.id, "ro", "ro").token;
221 for (const token of [reader, readOnly]) {
222 const refused = await call(token, "propose_to_parent_forge");
223 expect(refused.error).toBe(true);
224 expect(refused.text).toMatch(/^forbidden: /);
225 const listed = await call(token, "forge_proposals");
226 expect(listed.error).toBe(false);
227 expect(JSON.parse(listed.text).items).toHaveLength(1);
228 }
229 });
230
231 test("a parent that does not answer reads as unreachable in the list", async () => {
232 const writer = createToken(child.db, kid.id, "rw", "rw").token;
233 child.db.query("UPDATE upstream_proposal SET checked_at = 0").run();
234 await parentServer.stop();
235 const listed = JSON.parse((await call(writer, "forge_proposals")).text).items;
236 expect(listed[0].reachable).toBe(false);
237 expect(listed[0].says).toContain("parent unreachable");
238 });
239 });
test/mcp.test.ts +2
@@ -197,6 +197,8 @@ describe("the forge over MCP", () => {
197197 "claim_link",
198198 "take_updates",
199199 "sever",
200 "propose_to_parent_forge",
201 "forge_proposals",
200202 "converge",
201203 "family",
202204 "next_steps",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs