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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3645-mcp-branches-list-create-and-delete-a-br → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +185
−2
docs/MCP.md +1
| @@ -41,6 +41,7 @@ for a tunnel and a forge that sleeps. | ||
| 41 | 41 | | `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 | |
| 42 | 42 | | `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 | |
| 43 | 43 | | `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 | | |
| 44 | 45 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 45 | 46 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 46 | 47 | | `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"; | ||
| 106 | 106 | import { getPull, pullDir, pullMergeState } from "../core/pulls.ts"; |
| 107 | 107 | import { repoGraph } from "../core/dependencies.ts"; |
| 108 | 108 | 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"; | |
| 109 | 113 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| 110 | 114 | import { getAppByRepo } from "../core/apps.ts"; |
| 111 | 115 | import { |
| @@ -1394,6 +1398,62 @@ const TOOLS: readonly Tool[] = [ | ||
| 1394 | 1398 | }); |
| 1395 | 1399 | }, |
| 1396 | 1400 | }, |
| 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 })) | |
| 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 | }, | |
| 1397 | 1457 | { |
| 1398 | 1458 | name: "probe", |
| 1399 | 1459 | description: |
src/web/pages/branches.ts +2 −2
| @@ -297,7 +297,7 @@ export async function renderBranches( | ||
| 297 | 297 | }); |
| 298 | 298 | } |
| 299 | 299 | |
| 300 | const CREATE_ERRORS: Record<string, string> = { | |
| 300 | export const CREATE_ERRORS: Record<string, string> = { | |
| 301 | 301 | invalid_name: "That is not a branch name git will take.", |
| 302 | 302 | invalid_start: "That start point is not one git will take.", |
| 303 | 303 | unknown_start: "That start point does not exist here.", |
| @@ -340,7 +340,7 @@ export async function branchCreate(ctx: Ctx): Promise<Response> { | ||
| 340 | 340 | }); |
| 341 | 341 | } |
| 342 | 342 | |
| 343 | const DELETE_ERRORS: Record<string, string> = { | |
| 343 | export const DELETE_ERRORS: Record<string, string> = { | |
| 344 | 344 | ...CREATE_ERRORS, |
| 345 | 345 | default_branch: |
| 346 | 346 | "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", () => { | ||
| 194 | 194 | "outsiders", |
| 195 | 195 | "decisions_by", |
| 196 | 196 | "archive", |
| 197 | "branches", | |
| 197 | 198 | "probe", |
| 198 | 199 | "following", |
| 199 | 200 | "spawn", |
new above
offline
Minor: when
listBranchesfails this answersbranches: []. An agent reads that as "this repository has no branches" when really the read failed. Answerbad("unavailable", …)instead, so a failure never looks like an empty list.