Public: anyone can read this; the crew can change it.
MCP `branches` lists a repository's tags with `kind: "tags"`, newest first, with no new tool #5173
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5034-mcp-branches-lists-a-repository-s-tags-w → main 3 commits fork 2 threads · 2 unresolved
docs/MCP.md +1 −1
| @@ -53,8 +53,8 @@ for a tunnel and a forge that sleeps. | ||
| 53 | 53 | | `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 | |
| 54 | 54 | | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | no | |
| 55 | 55 | | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes | |
| 56 | | `branches` | `{ repo, name?, action?, from?, kind? }`: without `name`, lists the branches; `kind: "tags"` lists the tags instead (as GET `/api/v1/repos/:owner/:name/tags` does, newest first; no `name` or `action`); 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 | | |
| 56 | 57 | | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes | |
| 57 | | `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 | | |
| 58 | 58 | | `commits` | `{ repo, ref?, limit? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers | | |
| 59 | 59 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 60 | 60 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
src/http/mcp.ts +24 −1
| @@ -149,7 +149,7 @@ import { repoGraph } from "../core/dependencies.ts"; | ||
| 149 | 149 | import { pillarView } from "../core/pillars.ts"; |
| 150 | 150 | import { createBranch, deleteBranch } from "../actions/branches.ts"; |
| 151 | 151 | import { listBranches } from "../git/branches.ts"; |
| 152 | import { commitLog } from "../git/read.ts"; | |
| 152 | import { commitLog, listRefs } from "../git/read.ts"; | |
| 153 | 153 | import { repoDir } from "../git/repos.ts"; |
| 154 | 154 | import { runGit } from "../git/env.ts"; |
| 155 | 155 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| @@ -2090,14 +2090,37 @@ const TOOLS: readonly Tool[] = [ | ||
| 2090 | 2090 | name: { type: "string", maxLength: 255 }, |
| 2091 | 2091 | action: { type: "string", enum: ["create", "delete"] }, |
| 2092 | 2092 | from: { type: "string", maxLength: 255 }, |
| 2093 | kind: { type: "string", enum: ["branches", "tags"], description: "tags: newest first" }, | |
| 2093 | 2094 | }, |
| 2094 | 2095 | required: ["repo"], |
| 2095 | 2096 | }, |
| 2096 | 2097 | writes: true, |
| 2097 | 2098 | readsWithout: "name", |
| 2098 | 2099 | async run(ctx, user, args) { |
| 2100 | const kind = args["kind"] ?? "branches"; | |
| 2101 | if (kind !== "branches" && kind !== "tags") return bad("invalid", "`kind` is branches or tags"); | |
| 2102 | if (kind === "tags" && (args["name"] !== undefined || args["action"] !== undefined)) | |
| 2103 | return bad("invalid", "`kind: tags` only lists; leave out `name` and `action`"); | |
| 2099 | 2104 | const repo = repoArg(ctx, user, args); |
| 2100 | 2105 | if (isAnswer(repo)) return repo; |
| 2106 | if (kind === "tags") { | |
| 2107 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 2108 | const refs = await listRefs(dir, "refs/tags/"); | |
| 2109 | if (!refs.ok) return good({ tags: [] }); | |
| 2110 | const order = await runGit( | |
Low: this starts a second |
||
| 2111 | ["for-each-ref", "--sort=-creatordate", "--format=%(refname)", "--", "refs/tags/"], | |
| 2112 | { cwd: dir }, | |
| 2113 | ); | |
| 2114 | const rank = new Map((order.ok ? order.value.split("\n") : []).map((n, i) => [n, i])); | |
| 2115 | refs.value.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0)); | |
| 2116 | return good({ | |
| 2117 | tags: refs.value.map((r) => ({ | |
| 2118 | name: r.name.replace("refs/tags/", ""), | |
| 2119 | oid: r.oid, | |
| 2120 | commit: r.peeled ?? r.oid, | |
| 2121 | })), | |
| 2122 | }); | |
| 2123 | } | |
| 2101 | 2124 | const base = `/${repo.ownerName}/${repo.name}/branches`; |
| 2102 | 2125 | if (args["name"] === undefined) { |
| 2103 | 2126 | const listed = await listBranches( |
test/mcp-tags.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** MCP `branches` with `kind: "tags"`: newest first, a private repository is not found to a stranger, and misuse is refused. */ | |
| 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 { createRepo } from "../src/core/repos.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { runGit } from "../src/git/env.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | const tok: Record<string, string> = {}; | |
| 22 | ||
| 23 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 24 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 25 | return r.value; | |
| 26 | }; | |
| 27 | ||
| 28 | async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 29 | const res = await fetch(`${servers.url}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { | |
| 32 | "content-type": "application/json", | |
| 33 | accept: "application/json, text/event-stream", | |
| 34 | authorization: `Bearer ${token}`, | |
| 35 | }, | |
| 36 | body: JSON.stringify({ | |
| 37 | jsonrpc: "2.0", | |
| 38 | id: 1, | |
| 39 | method: "tools/call", | |
| 40 | params: { name: "branches", arguments: args }, | |
| 41 | }), | |
| 42 | }); | |
| 43 | const body = (await res.json()) as any; | |
| 44 | const text: string = body.result.content[0].text; | |
| 45 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 46 | } | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-tags-mcp-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | const make = (username: string) => | |
| 53 | createUser(app.db, { | |
| 54 | origin: "seed", | |
| 55 | trusted: true, | |
| 56 | username, | |
| 57 | email: `${username}@forge.local`, | |
| 58 | password: "a-good-passphrase", | |
| 59 | }); | |
| 60 | const owner = ok(await make("ada")); | |
| 61 | const stranger = ok(await make("sam")); | |
| 62 | const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" })); | |
| 63 | const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "vault", visibility: "private" })); | |
| 64 | for (const repo of [pub, priv]) { | |
| 65 | ok(await commitToBranch(app, owner, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 66 | const dir = repoDir(app.reposRoot, owner.username, repo.name); | |
| 67 | const env = { | |
| 68 | GIT_COMMITTER_NAME: "t", | |
| 69 | GIT_COMMITTER_EMAIL: "t@t", | |
| 70 | GIT_AUTHOR_NAME: "t", | |
| 71 | GIT_AUTHOR_EMAIL: "t@t", | |
| 72 | }; | |
| 73 | // Alphabetical order is the reverse of age, so sorting by name cannot pass. | |
| 74 | for (const [name, date] of [["v3", "2020-01-01T00:00:00Z"], ["v2", "2021-01-01T00:00:00Z"], ["v1", "2022-01-01T00:00:00Z"]] as const) | |
| 75 | ok(await runGit(["tag", "-a", name, "-m", name, "main"], { cwd: dir, env: { ...env, GIT_COMMITTER_DATE: date } })); | |
| 76 | } | |
| 77 | tok["owner"] = createToken(app.db, owner.id, "rw", "rw").token; | |
| 78 | tok["stranger"] = createToken(app.db, stranger.id, "rw", "rw").token; | |
| 79 | }, 30_000); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | servers?.stop(); | |
| 83 | app?.close(); | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("tags are listed newest first", async () => { | |
| 88 | const got = (await mcp(tok["stranger"]!, { repo: "ada/svc", kind: "tags" })).value; | |
| 89 | expect(got.tags.map((t: any) => t.name)).toEqual(["v1", "v2", "v3"]); | |
| 90 | expect(got.tags[0].commit).toMatch(/^[0-9a-f]{40}$/); | |
| 91 | expect(got.tags[0].oid).not.toBe(got.tags[0].commit); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a private repository's tags are not found for a stranger", async () => { | |
| 95 | expect((await mcp(tok["stranger"]!, { repo: "ada/vault", kind: "tags" })).error).toContain("not_found"); | |
| 96 | expect((await mcp(tok["owner"]!, { repo: "ada/vault", kind: "tags" })).value.tags).toHaveLength(3); | |
| 97 | }); | |
| 98 | ||
| 99 | test("kind tags with name or action, and an unknown kind, are refused", async () => { | |
| 100 | const w = tok["owner"]!; | |
| 101 | expect((await mcp(w, { repo: "ada/svc", kind: "tags", name: "v1" })).error).toContain("only lists"); | |
| 102 | expect((await mcp(w, { repo: "ada/svc", kind: "tags", action: "delete" })).error).toContain("only lists"); | |
| 103 | expect((await mcp(w, { repo: "ada/svc", kind: "refs" })).error).toContain("branches or tags"); | |
| 104 | expect((await mcp(w, { repo: "ada/svc", kind: "branches" })).value.branches[0].name).toBe("main"); | |
| 105 | }); |
Nit: "as GET
/tagsdoes, newest first" reads as if the REST route also sorts newest first. It does not; it returnsfor-each-reforder. Better to say the answer has the same shape as GET/tags, newest first.