Public: anyone can read this; the crew can change it.
Through MCP an agent reads how a forge stands against its parent forge and takes its new code #2661
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2658-through-mcp-an-agent-reads-how-a-forge-s → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +49
−5
README.md +2 −2
| @@ -195,8 +195,8 @@ is appended. A non-admin is refused (403), a value outside the two modes is | ||
| 195 | 195 | 422, and a severed forge is 409; each leaves `parent.json` untouched. |
| 196 | 196 | |
| 197 | 197 | **Take the N new commits** (shown to the admin on `/<org>/forge/following` when |
| 198 | the parent is ahead, `POST /api/v1/repos/:owner/:name/following/take`, or `t` on | |
| 199 | the chain screen in the terminal) opens, or updates, the one `forge/take-updates` | |
| 198 | the parent is ahead, `POST /api/v1/repos/:owner/:name/following/take`, the MCP `take_updates` | |
| 199 | tool, or `t` on the chain screen in the terminal) opens, or updates, the one `forge/take-updates` | |
| 200 | 200 | pull whatever the follow mode, and never moves the default branch; a conflict |
| 201 | 201 | opens the converge issue instead, and nothing behind answers "Already up to date." |
| 202 | 202 |
docs/MCP.md +1 −1
| @@ -45,7 +45,7 @@ for a tunnel and a forge that sleeps. | ||
| 45 | 45 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 46 | 46 | | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes | |
| 47 | 47 | | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes | |
| 48 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes | | |
| 48 | | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes | | |
| 49 | 49 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 50 | 50 | | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | | |
| 51 | 51 | | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes | |
src/http/mcp.ts +10 −2
| @@ -33,7 +33,7 @@ import { changeSignup, readSignup } from "../actions/signup.ts"; | ||
| 33 | 33 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| 34 | 34 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| 35 | 35 | import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 36 | import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 36 | import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts"; | |
| 37 | 37 | import { relationOf } from "../actions/forks.ts"; |
| 38 | 38 | import { chainOfRepo, FOLLOW_MODES, forgeLinkOf } from "../spawn/chain.ts"; |
| 39 | 39 | import { watchMilestone } from "../actions/watches.ts"; |
| @@ -1534,7 +1534,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1534 | 1534 | { |
| 1535 | 1535 | name: "take_updates", |
| 1536 | 1536 | description: |
| 1537 | "Take the parent's updates into a fork: a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files, and the crew is told: the converge issue is opened (or found) and named in the answer.", | |
| 1537 | "Take the parent's updates into a fork: on a spawned forge's code repository, the parent forge's new commits open as one pull (the default branch never moves); otherwise a fast-forward, or a merge when git merges cleanly. A conflict is refused with its files, and the crew is told: the converge issue is opened (or found) and named in the answer.", | |
| 1538 | 1538 | inputSchema: { |
| 1539 | 1539 | type: "object", |
| 1540 | 1540 | properties: { repo: REPO_ARG }, |
| @@ -1544,6 +1544,14 @@ const TOOLS: readonly Tool[] = [ | ||
| 1544 | 1544 | async run(ctx, user, args) { |
| 1545 | 1545 | const repo = repoArg(ctx, user, args); |
| 1546 | 1546 | if (isAnswer(repo)) return repo; |
| 1547 | // No parent in this forge but a chain across forges: the parent's code comes as a pull. | |
| 1548 | if (forkParentFor(ctx.app.db, repo, user) === undefined && (await chainOfRepo(ctx.app, repo)) !== undefined) { | |
| 1549 | const across = await takeForgeCode(ctx.app, user, repo); | |
| 1550 | if (across.ok) return good({ ...across.value, ...takeForgeWords(across.value) }); | |
| 1551 | return across.error === "not_following" | |
| 1552 | ? bad("not_following", "this repository follows nothing across forges") | |
| 1553 | : bad(across.error, "only this repository's admin can take the parent forge's code"); | |
Minor, and not blocking. This message is used for every error other than |
||
| 1554 | } | |
| 1547 | 1555 | const took = await takeOrTell(ctx.app, user, repo); |
| 1548 | 1556 | if (took.ok) |
| 1549 | 1557 | return good({ |
test/spawn-chain.test.ts +36
| @@ -280,6 +280,42 @@ describe("the chain of forges", () => { | ||
| 280 | 280 | expect(await tip()).toBe(before); |
| 281 | 281 | }); |
| 282 | 282 | |
| 283 | test("MCP: following carries upstream, and take_updates opens the take pull for the admin alone", async () => { | |
| 284 | const owner = getUserByName(grand.db, "alice")!; | |
| 285 | const rpc = async (id: string, name: string) => { | |
| 286 | const res = await fetch(`${servers.url}/mcp`, { | |
| 287 | method: "POST", | |
| 288 | headers: { | |
| 289 | "content-type": "application/json", | |
| 290 | accept: "application/json, text/event-stream", | |
| 291 | authorization: `Bearer ${createToken(grand.db, id, "mcp", "rw").token}`, | |
| 292 | }, | |
| 293 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: { repo: "tot/forge" } } }), | |
| 294 | }); | |
| 295 | const r = ((await res.json()) as any).result; | |
| 296 | return { isError: r.isError === true, value: r.isError === true ? r.content[0].text : JSON.parse(r.content[0].text) }; | |
| 297 | }; | |
| 298 | const dir = repoDir(grand.reposRoot, "tot", "forge"); | |
| 299 | const tip = async () => { | |
| 300 | const r = await runGit(["rev-parse", "refs/heads/main"], { cwd: dir }); | |
| 301 | return r.ok ? r.value.trim() : ""; | |
| 302 | }; | |
| 303 | const before = await tip(); | |
| 304 | const stands = await rpc(owner.id, "following"); | |
| 305 | expect(stands.value.upstream?.behind).toBe(1); | |
| 306 | ||
| 307 | const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "mcpreader", email: "[email protected]" }); | |
| 308 | if (!reader.ok) throw new Error("reader"); | |
| 309 | expect((await rpc(reader.value.id, "take_updates")).isError).toBe(true); | |
| 310 | ||
| 311 | const took = await rpc(owner.id, "take_updates"); | |
| 312 | expect(took.isError).toBe(false); | |
| 313 | const pull = pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH); | |
| 314 | expect(took.value.kind).toBe("pull"); | |
| 315 | expect(took.value.message).toBe(`1 commit from your parent is in pull #${pull!.number}.`); | |
| 316 | expect(await tip()).toBe(before); | |
| 317 | }); | |
| 318 | ||
| 283 | 319 | test("take: the terminal's t key is the owner's alone, shown when behind, and toasts the pull", async () => { |
| 284 | 320 | const owner = getUserByName(grand.db, "alice")!; |
| 285 | 321 | const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "ttyreader", email: "[email protected]" }); |
new above
offline
Low: the API route decides between the two paths with
(await forgeLinkOf(ctx.app))?.repo === owner/name. This line decides withforkParentFor(db, repo, user) === undefined && chainOfRepo(...), which differs in two ways. (1)forkParentFordepends on the viewer: it returns undefined when the caller can't read an in-forge parent, so in that edge case two callers would take different paths. (2)chainOfRepobuilds the wholechainView, including a gittipOf, only to test whether it exists. Use the API's test here instead:if ((await forgeLinkOf(ctx.app))?.repo ===${repo.ownerName}/${repo.name}). That keeps the two doors routing identically and is cheaper.