Public: anyone can read this; the crew can change it.
MCP `commits`: list a branch's recent commits, with the same rules as GET …/commits #3811
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3799-mcp-commits-list-a-branch-s-recent-commi → main 4 commits fork 4 threads · 4 unresolved
4 files
changed +143
−0
docs/MCP.md +1
| @@ -44,6 +44,7 @@ for a tunnel and a forge that sleeps. | ||
| 44 | 44 | | `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 | |
| 45 | 45 | | `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 | |
| 46 | 46 | | `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 | |
| 47 | | `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 | | | |
| 47 | 48 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 48 | 49 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 49 | 50 | | `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 +28
| @@ -111,6 +111,7 @@ import { repoGraph } from "../core/dependencies.ts"; | ||
| 111 | 111 | import { pillarView } from "../core/pillars.ts"; |
| 112 | 112 | import { createBranch, deleteBranch } from "../actions/branches.ts"; |
| 113 | 113 | import { listBranches } from "../git/branches.ts"; |
| 114 | import { commitLog } from "../git/read.ts"; | |
| 114 | 115 | import { repoDir } from "../git/repos.ts"; |
| 115 | 116 | import { runGit } from "../git/env.ts"; |
| 116 | 117 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| @@ -1573,6 +1574,33 @@ const TOOLS: readonly Tool[] = [ | ||
| 1573 | 1574 | return good({ says: `Deleted ${gone.value.name}.`, url: base }); |
| 1574 | 1575 | }, |
| 1575 | 1576 | }, |
| 1577 | { | |
| 1578 | name: "commits", | |
| 1579 | description: | |
| 1580 | "List a branch's recent commits (newest first), as GET /api/v1/repos/:owner/:name/commits does.", | |
| 1581 | inputSchema: { | |
| 1582 | type: "object", | |
| 1583 | properties: { | |
| 1584 | repo: REPO_ARG, | |
| 1585 | ref: { type: "string", maxLength: 255, description: "A branch, tag or commit; default the default branch." }, | |
| 1586 | limit: { type: "integer", minimum: 1, maximum: 100, description: "How many, default 30." }, | |
| 1587 | }, | |
| 1588 | required: ["repo"], | |
| 1589 | }, | |
| 1590 | writes: false, | |
| 1591 | async run(ctx, user, args) { | |
| 1592 | const repo = repoArg(ctx, user, args); | |
| 1593 | if (isAnswer(repo)) return repo; | |
| 1594 | const ref = args["ref"] ?? repo.defaultBranch; | |
| 1595 | if (typeof ref !== "string" || ref.length > 255 || !isSafeRef(ref)) | |
| 1596 | return bad("invalid", "`ref` is a branch, tag or commit"); | |
| 1597 | const limit = args["limit"] ?? 30; | |
| 1598 | if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 100) | |
| 1599 | return bad("invalid", "`limit` is a whole number from 1 to 100"); | |
| 1600 | const log = await commitLog(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), ref, { limit }); | |
| 1601 | return good({ commits: log.ok ? log.value : [] }); | |
| 1602 | }, | |
| 1603 | }, | |
| 1576 | 1604 | { |
| 1577 | 1605 | name: "probe", |
| 1578 | 1606 | description: |
test/mcp-commits.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** MCP `commits`: a good answer, a private repository is not found to a stranger, and bad arguments are 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | const tok: Record<string, string> = {}; | |
| 20 | ||
| 21 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 22 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 23 | return r.value; | |
| 24 | }; | |
| 25 | ||
| 26 | async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { | |
| 30 | "content-type": "application/json", | |
| 31 | accept: "application/json, text/event-stream", | |
| 32 | authorization: `Bearer ${token}`, | |
| 33 | }, | |
| 34 | body: JSON.stringify({ | |
| 35 | jsonrpc: "2.0", | |
| 36 | id: 1, | |
| 37 | method: "tools/call", | |
| 38 | params: { name: "commits", arguments: args }, | |
| 39 | }), | |
| 40 | }); | |
| 41 | const body = (await res.json()) as any; | |
| 42 | const text: string = body.result.content[0].text; | |
| 43 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 44 | } | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-commits-mcp-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const make = (username: string) => | |
| 51 | createUser(app.db, { | |
| 52 | origin: "seed", | |
| 53 | trusted: true, | |
| 54 | username, | |
| 55 | email: `${username}@forge.local`, | |
| 56 | password: "a-good-passphrase", | |
| 57 | }); | |
| 58 | const owner = ok(await make("ada")); | |
| 59 | const stranger = ok(await make("rita")); | |
| 60 | const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" })); | |
| 61 | const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "secret", visibility: "private" })); | |
| 62 | for (const repo of [pub, priv]) { | |
| 63 | ok(await commitToBranch(app, owner, repo, { message: "first", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 64 | ok(await commitToBranch(app, owner, repo, { message: "second", files: [{ path: "b.txt", content: "b\n" }] })); | |
| 65 | } | |
| 66 | ok( | |
| 67 | await commitToBranch(app, owner, pub, { | |
| 68 | branch: "feature", | |
| 69 | message: "on feature", | |
| 70 | files: [{ path: "c.txt", content: "c\n" }], | |
| 71 | }), | |
| 72 | ); | |
| 73 | tok["owner"] = createToken(app.db, owner.id, "ro", "ro").token; | |
| 74 | tok["stranger"] = createToken(app.db, stranger.id, "ro", "ro").token; | |
| 75 | }, 30_000); | |
| 76 | ||
| 77 | afterAll(async () => { | |
| 78 | servers?.stop(); | |
| 79 | app?.close(); | |
| 80 | await rm(dataDir, { recursive: true, force: true }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("lists the newest commits first, and honours limit", async () => { | |
| 84 | const all = (await mcp(tok["owner"]!, { repo: "ada/svc" })).value.commits; | |
| 85 | expect(all.map((c: any) => c.subject)).toEqual(["second", "first"]); | |
| 86 | const one = (await mcp(tok["stranger"]!, { repo: "ada/svc", limit: 1 })).value.commits; | |
| 87 | expect(one.length).toBe(1); | |
| 88 | }); | |
| 89 | ||
| 90 | test("ref picks the branch, and a ref that does not exist answers no commits", async () => { | |
| 91 | const o = tok["owner"]!; | |
| 92 | const feature = (await mcp(o, { repo: "ada/svc", ref: "feature" })).value.commits; | |
| 93 | expect(feature.map((c: any) => c.subject)).toEqual(["on feature", "second", "first"]); | |
| 94 | const main = (await mcp(o, { repo: "ada/svc" })).value.commits; | |
| 95 | expect(main.map((c: any) => c.subject)).not.toContain("on feature"); | |
| 96 | expect((await mcp(o, { repo: "ada/svc", ref: "nope" })).value).toEqual({ commits: [] }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a private repository is not found to someone who cannot read it", async () => { | |
| 100 | expect((await mcp(tok["owner"]!, { repo: "ada/secret" })).value.commits.length).toBe(2); | |
| 101 | const r = await mcp(tok["stranger"]!, { repo: "ada/secret" }); | |
| 102 | expect(r.error).toContain("not_found"); | |
| 103 | expect(r.value).toBeUndefined(); | |
| 104 | }); | |
| 105 | ||
| 106 | test("bad arguments are refused", async () => { | |
| 107 | const o = tok["owner"]!; | |
| 108 | expect((await mcp(o, { repo: "ada/svc", limit: 0 })).error).toContain("invalid"); | |
| 109 | expect((await mcp(o, { repo: "ada/svc", limit: 101 })).error).toContain("invalid"); | |
| 110 | expect((await mcp(o, { repo: "ada/svc", limit: "5" })).error).toContain("invalid"); | |
| 111 | expect((await mcp(o, { repo: "ada/svc", ref: "--upload-pack=x" })).error).toContain("invalid"); | |
| 112 | expect((await mcp(o, { repo: "ada/svc", ref: 7 })).error).toContain("invalid"); | |
| 113 | }); |
test/mcp.test.ts +1
| @@ -198,6 +198,7 @@ describe("the forge over MCP", () => { | ||
| 198 | 198 | "decisions_by", |
| 199 | 199 | "archive", |
| 200 | 200 | "branches", |
| 201 | "commits", | |
| 201 | 202 | "probe", |
| 202 | 203 | "following", |
| 203 | 204 | "spawn", |
new above
offline
Add a case here (or next to it) that passes
ref: "feature"on a second branch and asserts its own newest commit.