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

MCP `branches`: list, create and delete a branch, with the same rules as the branches page #3656

Merged opened this

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

Merged

scout/forge:scout/3645-mcp-branches-list-create-and-delete-a-br → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3645-mcp-branches-list-create-and-delete-a-br was merged into main as c7a0211.

Review

Sign in to review this change.
5 files changed +185 −2
docs/MCP.md +1
@@ -41,6 +41,7 @@ for a tunnel and a forge that sleeps.
4141 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4242 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4343 | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes |
44 | `branches` | `{ repo, name?, action?, from? }`: without `name`, lists the branches; with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes |
4445 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4546 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4647 | `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 |
src/http/mcp.ts +60
@@ -106,6 +106,10 @@ import { resolveOid } from "../git/merge.ts";
106106 import { getPull, pullDir, pullMergeState } from "../core/pulls.ts";
107107 import { repoGraph } from "../core/dependencies.ts";
108108 import { pillarView } from "../core/pillars.ts";
109 import { createBranch, deleteBranch } from "../actions/branches.ts";
110 import { listBranches } from "../git/branches.ts";
111 import { repoDir } from "../git/repos.ts";
112 import { CREATE_ERRORS, DELETE_ERRORS } from "../web/pages/branches.ts";
109113 import { askToLiveFor } from "../web/ask_to_live.ts";
110114 import { getAppByRepo } from "../core/apps.ts";
111115 import {
@@ -1394,6 +1398,62 @@ const TOOLS: readonly Tool[] = [
13941398 });
13951399 },
13961400 },
1401 {
1402 name: "branches",
1403 description:
1404 "List a repository's branches, or with `name` and `action` create (from `from`, default the default branch) or delete one, as the branches page does: the default branch is never deleted and only a writer may act.",
1405 inputSchema: {
1406 type: "object",
1407 properties: {
1408 repo: REPO_ARG,
1409 name: { type: "string", maxLength: 255, description: "The branch to create or delete." },
1410 action: { type: "string", enum: ["create", "delete"] },
1411 from: { type: "string", maxLength: 255, description: "For create: where it starts." },
1412 },
1413 required: ["repo"],
1414 },
1415 writes: true,
1416 readsWithout: "name",
1417 async run(ctx, user, args) {
1418 const repo = repoArg(ctx, user, args);
1419 if (isAnswer(repo)) return repo;
1420 const base = `/${repo.ownerName}/${repo.name}/branches`;
1421 if (args["name"] === undefined) {
1422 const listed = await listBranches(
1423 repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
1424 repo.defaultBranch,
1425 );
1426 return good({
1427 branches: listed.ok
1428 ? listed.value.branches.map((b) => ({ name: b.name, default: b.isDefault, ahead: b.ahead, behind: b.behind }))

Minor: when listBranches fails this answers branches: []. An agent reads that as "this repository has no branches" when really the read failed. Answer bad("unavailable", …) instead, so a failure never looks like an empty list.

1429 : [],
1430 url: base,
1431 });
1432 }
1433 const name = args["name"];
1434 const action = args["action"];
1435 const from = args["from"];
1436 if (typeof name !== "string" || name.length > 255)
1437 return bad("invalid", "`name` is a branch name");
1438 if (action !== "create" && action !== "delete")
1439 return bad("invalid", "`action` is create or delete");
1440 if (from !== undefined && (typeof from !== "string" || from.length > 255))
1441 return bad("invalid", "`from` is a branch, tag or commit");
1442 if (action === "create") {
1443 const made = await createBranch(ctx.app, user, repo, {
1444 name,
1445 ...(from === undefined || from === "" ? {} : { from }),
1446 });
1447 if (!made.ok)
1448 return bad(made.error, CREATE_ERRORS[made.error] ?? "That branch was not created.");
1449 return good({ says: `Created ${made.value.name} at ${made.value.oid.slice(0, 7)}.`, url: base });
1450 }
1451 const gone = await deleteBranch(ctx.app, user, repo, name);
1452 if (!gone.ok)
1453 return bad(gone.error, DELETE_ERRORS[gone.error] ?? "That branch was not deleted.");
1454 return good({ says: `Deleted ${gone.value.name}.`, url: base });
1455 },
1456 },
13971457 {
13981458 name: "probe",
13991459 description:
src/web/pages/branches.ts +2 −2
@@ -297,7 +297,7 @@ export async function renderBranches(
297297 });
298298 }
299299
300 const CREATE_ERRORS: Record<string, string> = {
300 export const CREATE_ERRORS: Record<string, string> = {
301301 invalid_name: "That is not a branch name git will take.",
302302 invalid_start: "That start point is not one git will take.",
303303 unknown_start: "That start point does not exist here.",
@@ -340,7 +340,7 @@ export async function branchCreate(ctx: Ctx): Promise<Response> {
340340 });
341341 }
342342
343 const DELETE_ERRORS: Record<string, string> = {
343 export const DELETE_ERRORS: Record<string, string> = {
344344 ...CREATE_ERRORS,
345345 default_branch:
346346 "The default branch cannot be deleted — it is what HEAD points at and what every clone lands on.",
test/mcp-branches.test.ts added +121
@@ -0,0 +1,121 @@
1 /** MCP `branches`: a writer lists, creates and deletes; a reader is refused with the page's words; bad input stops at the boundary. */
2 import { afterAll, beforeAll, 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 { commitToBranch } from "../src/actions/repos.ts";
8 import { CREATE_ERRORS } from "../src/web/pages/branches.ts";
9 import { createRepo } from "../src/core/repos.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 { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 const tok: Record<string, string> = {};
21
22 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
23 if (!r.ok) throw new Error(JSON.stringify(r.error));
24 return r.value;
25 };
26
27 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 accept: "application/json, text/event-stream",
33 authorization: `Bearer ${token}`,
34 },
35 body: JSON.stringify({
36 jsonrpc: "2.0",
37 id: 1,
38 method: "tools/call",
39 params: { name: "branches", arguments: args },
40 }),
41 });
42 const body = (await res.json()) as any;
43 const text: string = body.result.content[0].text;
44 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
45 }
46
47 const names = async (): Promise<string[]> =>
48 ((await mcp(tok["writer"]!, { repo: "ada/svc" })).value.branches as { name: string }[]).map((b) => b.name);
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-branches-mcp-"));
52 app = createApp({ dataDir, port: 0 });
53 servers = await serve(app);
54 const make = (username: string) =>
55 createUser(app.db, {
56 origin: "seed",
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 });
62 const owner = ok(await make("ada"));
63 const reader = ok(await make("rita"));
64 const repo = ok(
65 await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" }),
66 );
67 ok(await commitToBranch(app, owner, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
68 tok["writer"] = createToken(app.db, owner.id, "rw", "rw").token;
69 tok["writerRo"] = createToken(app.db, owner.id, "ro", "ro").token;
70 tok["reader"] = createToken(app.db, reader.id, "rw", "rw").token;
71 }, 30_000);
72
73 afterAll(async () => {
74 servers?.stop();
75 app?.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 test("a writer lists, creates and deletes a branch", async () => {
80 const w = tok["writer"]!;
81 const first = (await mcp(w, { repo: "ada/svc" })).value;
82 expect(first.url).toBe("/ada/svc/branches");
83 expect(first.branches.map((b: any) => b.name)).toEqual(["main"]);
84
85 const made = await mcp(w, { repo: "ada/svc", name: "feature", action: "create" });
86 expect(made.value.says).toStartWith("Created feature at ");
87 expect(await names()).toContain("feature");
88 expect((await mcp(w, { repo: "ada/svc", name: "feature", action: "create" })).error).toContain(
89 CREATE_ERRORS["exists"]!,
90 );
91
92 const gone = await mcp(w, { repo: "ada/svc", name: "feature", action: "delete" });
93 expect(gone.value.says).toBe("Deleted feature.");
94 expect(await names()).not.toContain("feature");
95 });
96
97 test("the default branch is never deleted", async () => {
98 const refused = await mcp(tok["writer"]!, { repo: "ada/svc", name: "main", action: "delete" });
99 expect(refused.error).toContain("default branch cannot be deleted");
100 expect(await names()).toContain("main");
101 });
102
103 test("a reader is refused with the page's words, and a read-only token cannot act", async () => {
104 const tried = await mcp(tok["reader"]!, { repo: "ada/svc", name: "sneaky", action: "create" });
105 expect(tried.error).toContain("You need write access to create a branch.");
106 expect(await names()).not.toContain("sneaky");
107 const ro = await mcp(tok["writerRo"]!, { repo: "ada/svc", name: "sneaky", action: "create" });
108 expect(ro.error).toContain("forbidden");
109 expect(await names()).not.toContain("sneaky");
110 });
111
112 test("bad input is refused at the boundary", async () => {
113 const w = tok["writer"]!;
114 expect((await mcp(w, { repo: "ada/svc", name: "x" })).error).toContain("create or delete");
115 expect((await mcp(w, { repo: "ada/svc", name: 7, action: "create" })).error).toContain("invalid");
116 expect((await mcp(w, { repo: "ada/svc", name: "a b", action: "create" })).error).toContain(
117 CREATE_ERRORS["invalid_name"]!,
118 );
119 expect((await mcp(w, { repo: "ada/svc", name: "y", action: "create", from: 5 })).error).toContain("invalid");
120 expect((await mcp(w, { repo: "nope/none" })).error).toContain("not_found");
121 });
test/mcp.test.ts +1
@@ -194,6 +194,7 @@ describe("the forge over MCP", () => {
194194 "outsiders",
195195 "decisions_by",
196196 "archive",
197 "branches",
197198 "probe",
198199 "following",
199200 "spawn",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs