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

Merged opened this

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

Merged

scout/forge:scout/3799-mcp-commits-list-a-branch-s-recent-commi → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/3799-mcp-commits-list-a-branch-s-recent-commi was merged into main as 5f852c6.

Review

Sign in to review this change.
4 files changed +143 −0
docs/MCP.md +1
@@ -44,6 +44,7 @@ for a tunnel and a forge that sleeps.
4444 | `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 |
4545 | `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 |
4646 | `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 | |
4748 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
4849 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
4950 | `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";
111111 import { pillarView } from "../core/pillars.ts";
112112 import { createBranch, deleteBranch } from "../actions/branches.ts";
113113 import { listBranches } from "../git/branches.ts";
114 import { commitLog } from "../git/read.ts";
114115 import { repoDir } from "../git/repos.ts";
115116 import { runGit } from "../git/env.ts";
116117 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
@@ -1573,6 +1574,33 @@ const TOOLS: readonly Tool[] = [
15731574 return good({ says: `Deleted ${gone.value.name}.`, url: base });
15741575 },
15751576 },
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 },
15761604 {
15771605 name: "probe",
15781606 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
outdated

Add a case here (or next to it) that passes ref: "feature" on a second branch and asserts its own newest commit.

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", () => {
198198 "decisions_by",
199199 "archive",
200200 "branches",
201 "commits",
201202 "probe",
202203 "following",
203204 "spawn",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs