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

Merged opened this

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

Merged

scout/forge:scout/5034-mcp-branches-lists-a-repository-s-tags-w → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/5034-mcp-branches-lists-a-repository-s-tags-w was merged into main as e3bb348.

Review

Sign in to review this change.
3 files changed +130 −2
docs/MCP.md +1 −1
@@ -53,8 +53,8 @@ for a tunnel and a forge that sleeps.
5353 | `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 |
5454 | `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 |
5555 | `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 |

Nit: "as GET /tags does, newest first" reads as if the REST route also sorts newest first. It does not; it returns for-each-ref order. Better to say the answer has the same shape as GET /tags, newest first.

5657 | `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 |
5858 | `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 | |
5959 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
6060 | `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";
149149 import { pillarView } from "../core/pillars.ts";
150150 import { createBranch, deleteBranch } from "../actions/branches.ts";
151151 import { listBranches } from "../git/branches.ts";
152 import { commitLog } from "../git/read.ts";
152 import { commitLog, listRefs } from "../git/read.ts";
153153 import { repoDir } from "../git/repos.ts";
154154 import { runGit } from "../git/env.ts";
155155 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
@@ -2090,14 +2090,37 @@ const TOOLS: readonly Tool[] = [
20902090 name: { type: "string", maxLength: 255 },
20912091 action: { type: "string", enum: ["create", "delete"] },
20922092 from: { type: "string", maxLength: 255 },
2093 kind: { type: "string", enum: ["branches", "tags"], description: "tags: newest first" },
20932094 },
20942095 required: ["repo"],
20952096 },
20962097 writes: true,
20972098 readsWithout: "name",
20982099 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`");
20992104 const repo = repoArg(ctx, user, args);
21002105 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 for-each-ref only to learn the order, then joins it back by name. The ?? 0 fallback means a tag created between the two calls sorts unpredictably. It would be simpler to pass a sort option to listRefs (--sort=-creatordate in the same call), so there is one process and one consistent snapshot. Note too that the list has no limit: a repository with thousands of tags returns all of them in one MCP answer. REST has the same gap, so this can be a follow-up.

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 }
21012124 const base = `/${repo.ownerName}/${repo.name}/branches`;
21022125 if (args["name"] === undefined) {
21032126 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs