Public: anyone can read this; the crew can change it.
MCP: propose to the parent forge and list proposals #2440
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2231-mcp-propose-to-the-parent-forge-and-list → main 2 commits fork
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 | ||
| 119 | 119 | closed, read from the parent (the token may read back only pulls it opened) and |
| 120 | 120 | 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 |
| 121 | 121 | 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 | |
| 123 | 125 | the token into `parent.json` (part 2 of #2182 covers minting and handing over). |
| 124 | 126 | |
| 125 | 127 | Minting, listing and revoking go through one action |
src/http/mcp.ts +49
| @@ -138,6 +138,7 @@ import { | ||
| 138 | 138 | import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts"; |
| 139 | 139 | import { appsOf, asksAnswer } from "../web/studio/asks.ts"; |
| 140 | 140 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 141 | import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../actions/upstream.ts"; | |
| 141 | 142 | import { listProposals, mintProposal, repoByName, revokeProposal } from "../actions/proposal_tokens.ts"; |
| 142 | 143 | import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts"; |
| 143 | 144 | import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts"; |
| @@ -1455,6 +1456,54 @@ const TOOLS: readonly Tool[] = [ | ||
| 1455 | 1456 | ); |
| 1456 | 1457 | }, |
| 1457 | 1458 | }, |
| 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 | }, | |
| 1458 | 1507 | { |
| 1459 | 1508 | name: "converge", |
| 1460 | 1509 | 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", () => { | ||
| 197 | 197 | "claim_link", |
| 198 | 198 | "take_updates", |
| 199 | 199 | "sever", |
| 200 | "propose_to_parent_forge", | |
| 201 | "forge_proposals", | |
| 200 | 202 | "converge", |
| 201 | 203 | "family", |
| 202 | 204 | "next_steps", |
new above
offline