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

Merged opened this

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

Merged

scout/forge:scout/2658-through-mcp-an-agent-reads-how-a-forge-s → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2658-through-mcp-an-agent-reads-how-a-forge-s was merged into main as 9e9f45b.

Review

Sign in to review this change.
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
195195 422, and a severed forge is 409; each leaves `parent.json` untouched.
196196
197197 **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`
200200 pull whatever the follow mode, and never moves the default branch; a conflict
201201 opens the converge issue instead, and nothing behind answers "Already up to date."
202202
docs/MCP.md +1 −1
@@ -45,7 +45,7 @@ for a tunnel and a forge that sleeps.
4545 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4646 | `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 |
4747 | `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 |
4949 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
5050 | `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 | |
5151 | `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";
3333 import { SIGNUP_MODES } from "../settings/signup.ts";
3434 import { QUOTA_KEYS } from "../settings/quotas.ts";
3535 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";
3737 import { relationOf } from "../actions/forks.ts";
3838 import { chainOfRepo, FOLLOW_MODES, forgeLinkOf } from "../spawn/chain.ts";
3939 import { watchMilestone } from "../actions/watches.ts";
@@ -1534,7 +1534,7 @@ const TOOLS: readonly Tool[] = [
15341534 {
15351535 name: "take_updates",
15361536 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.",
15381538 inputSchema: {
15391539 type: "object",
15401540 properties: { repo: REPO_ARG },
@@ -1544,6 +1544,14 @@ const TOOLS: readonly Tool[] = [
15441544 async run(ctx, user, args) {
15451545 const repo = repoArg(ctx, user, args);
15461546 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) {

Low: the API route decides between the two paths with (await forgeLinkOf(ctx.app))?.repo === owner/name. This line decides with forkParentFor(db, repo, user) === undefined && chainOfRepo(...), which differs in two ways. (1) forkParentFor depends 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) chainOfRepo builds the whole chainView, including a git tipOf, 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.

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 not_following, including unauthorized. The writes gate means a caller always has a user here, so it reads correctly in practice. FOLLOW_MODE_REFUSALS[across.error].message (or a matching table for take) would keep the words identical across doors.

1554 }
15471555 const took = await takeOrTell(ctx.app, user, repo);
15481556 if (took.ok)
15491557 return good({
test/spawn-chain.test.ts +36
@@ -280,6 +280,42 @@ describe("the chain of forges", () => {
280280 expect(await tip()).toBe(before);
281281 });
282282
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
283319 test("take: the terminal's t key is the owner's alone, shown when behind, and toasts the pull", async () => {
284320 const owner = getUserByName(grand.db, "alice")!;
285321 const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "ttyreader", email: "[email protected]" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs