Public: anyone can read this; the crew can change it.

MCP `commits` with `oid` answers one commit: its message, files changed and its check #5174

Merged opened this

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

Merged

scout/forge:scout/5118-mcp-commits-with-oid-answers-one-commit → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5118-mcp-commits-with-oid-answers-one-commit was merged into main as 35d1086.

Review

Sign in to review this change.
3 files changed +42 −3
docs/MCP.md +1 −1
@@ -62,7 +62,7 @@ for a tunnel and a forge that sleeps.
6262 | `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 |
6363 | `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 |
6464 | `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 |
65 | `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 | |
65 | `commits` | `{ repo, ref?, limit?, oid? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers; with `oid` (4–64 hex) one commit with its diff and its check, as `GET …/commits/{oid}` and `…/check` answer | |
6666 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
6767 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
6868 | `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 +22 −2
@@ -154,6 +154,7 @@ import { pillarView } from "../core/pillars.ts";
154154 import { createBranch, deleteBranch } from "../actions/branches.ts";
155155 import { listBranches } from "../git/branches.ts";
156156 import { commitLog, listRefs } from "../git/read.ts";
157 import { diffCommit } from "../git/diff.ts";
157158 import { repoDir } from "../git/repos.ts";
158159 import { runGit } from "../git/env.ts";
159160 import { isSafeRef, isSafeTreePath } from "../git/guard.ts";
@@ -253,7 +254,7 @@ import { resourceMetadataUrl } from "./oauth.ts";
253254 import { body, boundedBody, json } from "./respond.ts";
254255 import { sizesView } from "../spawn/sizes.ts";
255256 import type { Router } from "./router.ts";
256 import { mayReadRuns, runForReader, runLog } from "../actions/runs.ts";
257 import { checkOfCommit, mayReadRuns, runForReader, runLog } from "../actions/runs.ts";
257258 import { manifestFor } from "../actions/here.ts";
258259 import { clientBucket } from "./peer.ts";
259260 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts";
@@ -2260,13 +2261,14 @@ const TOOLS: readonly Tool[] = [
22602261 {
22612262 name: "commits",
22622263 description:
2263 "List a branch's recent commits (newest first), as GET /api/v1/repos/:owner/:name/commits does.",
2264 "Recent commits; `oid`: one commit, diff and check.",
22642265 inputSchema: {
22652266 type: "object",
22662267 properties: {
22672268 repo: REPO_ARG,
22682269 ref: { type: "string", maxLength: 255, description: "Branch, tag or commit; default branch." },
22692270 limit: { type: "integer", minimum: 1, maximum: 100 },
2271 oid: { type: "string", minLength: 4, maxLength: 64, },

Nit: stray trailing comma before }. Prettier-style elsewhere would read oid: { type: "string", minLength: 4, maxLength: 64 },. A pattern ("^[0-9a-fA-F]+$") here would also tell clients the shape before they call.

22702272 },
22712273 required: ["repo"],
22722274 },
@@ -2274,6 +2276,24 @@ const TOOLS: readonly Tool[] = [
22742276 async run(ctx, user, args) {
22752277 const repo = repoArg(ctx, user, args);
22762278 if (isAnswer(repo)) return repo;
2279 const oid = args["oid"];
2280 if (oid !== undefined) {
2281 if (typeof oid !== "string" || !/^[0-9a-fA-F]{4,64}$/.test(oid))
2282 return bad("invalid", "`oid` is 4 to 64 hex digits");
2283 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
2284 const log = await commitLog(dir, oid, { limit: 1 });
2285 const commit = log.ok ? log.value[0] : undefined;
2286 if (!commit) return bad("not_found", "no such commit");
2287 const diff = await diffCommit(dir, commit.oid);
2288 const check = await checkOfCommit(ctx.app, user, repo, commit.oid);
2289 const files = diff.ok ? diff.value : { files: [], additions: 0, deletions: 0 };
2290 return good({
2291 says: `${commit.oid.slice(0, 7)} ${commit.subject}: ${files.files.length} file(s) changed.`,
2292 ...commit,
2293 diff: files,
2294 check: check.ok ? check.value : null,
2295 });
2296 }
22772297 const ref = args["ref"] ?? repo.defaultBranch;
22782298 if (typeof ref !== "string" || ref.length > 255 || !isSafeRef(ref))
22792299 return bad("invalid", "`ref` is a branch, tag or commit");
test/mcp-commits.test.ts +19
@@ -111,3 +111,22 @@ test("bad arguments are refused", async () => {
111111 expect((await mcp(o, { repo: "ada/svc", ref: "--upload-pack=x" })).error).toContain("invalid");
112112 expect((await mcp(o, { repo: "ada/svc", ref: 7 })).error).toContain("invalid");
113113 });
114
115 test("oid opens one commit with its files changed and its check", async () => {
116 const [head] = (await mcp(tok["owner"]!, { repo: "ada/svc" })).value.commits;
117 const r = (await mcp(tok["stranger"]!, { repo: "ada/svc", oid: head.oid.slice(0, 8) })).value;
118 expect(r.oid).toBe(head.oid);
119 expect(r.says).toContain("second");
120 expect(r.diff.files.length).toBe(1);
121 expect(r.check).toBeTruthy();
122 });
123
124 test("oid: a bad or unknown one is refused, and a private repository stays hidden", async () => {
125 const o = tok["owner"]!;
126 expect((await mcp(o, { repo: "ada/svc", oid: "xyz" })).error).toContain("invalid");
127 expect((await mcp(o, { repo: "ada/svc", oid: "--all" })).error).toContain("invalid");
128 expect((await mcp(o, { repo: "ada/svc", oid: 5 })).error).toContain("invalid");
129 expect((await mcp(o, { repo: "ada/svc", oid: "deadbeef" })).error).toContain("not_found");
130 const [head] = (await mcp(o, { repo: "ada/secret" })).value.commits;
131 expect((await mcp(tok["stranger"]!, { repo: "ada/secret", oid: head.oid })).error).toContain("not_found");
132 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs