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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5118-mcp-commits-with-oid-answers-one-commit → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +42
−3
docs/MCP.md +1 −1
| @@ -62,7 +62,7 @@ for a tunnel and a forge that sleeps. | ||
| 62 | 62 | | `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 | |
| 63 | 63 | | `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 | |
| 64 | 64 | | `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 | | | |
| 66 | 66 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
| 67 | 67 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 68 | 68 | | `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"; | ||
| 154 | 154 | import { createBranch, deleteBranch } from "../actions/branches.ts"; |
| 155 | 155 | import { listBranches } from "../git/branches.ts"; |
| 156 | 156 | import { commitLog, listRefs } from "../git/read.ts"; |
| 157 | import { diffCommit } from "../git/diff.ts"; | |
| 157 | 158 | import { repoDir } from "../git/repos.ts"; |
| 158 | 159 | import { runGit } from "../git/env.ts"; |
| 159 | 160 | import { isSafeRef, isSafeTreePath } from "../git/guard.ts"; |
| @@ -253,7 +254,7 @@ import { resourceMetadataUrl } from "./oauth.ts"; | ||
| 253 | 254 | import { body, boundedBody, json } from "./respond.ts"; |
| 254 | 255 | import { sizesView } from "../spawn/sizes.ts"; |
| 255 | 256 | 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"; | |
| 257 | 258 | import { manifestFor } from "../actions/here.ts"; |
| 258 | 259 | import { clientBucket } from "./peer.ts"; |
| 259 | 260 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts"; |
| @@ -2260,13 +2261,14 @@ const TOOLS: readonly Tool[] = [ | ||
| 2260 | 2261 | { |
| 2261 | 2262 | name: "commits", |
| 2262 | 2263 | 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.", | |
| 2264 | 2265 | inputSchema: { |
| 2265 | 2266 | type: "object", |
| 2266 | 2267 | properties: { |
| 2267 | 2268 | repo: REPO_ARG, |
| 2268 | 2269 | ref: { type: "string", maxLength: 255, description: "Branch, tag or commit; default branch." }, |
| 2269 | 2270 | limit: { type: "integer", minimum: 1, maximum: 100 }, |
| 2271 | oid: { type: "string", minLength: 4, maxLength: 64, }, | |
| 2270 | 2272 | }, |
| 2271 | 2273 | required: ["repo"], |
| 2272 | 2274 | }, |
| @@ -2274,6 +2276,24 @@ const TOOLS: readonly Tool[] = [ | ||
| 2274 | 2276 | async run(ctx, user, args) { |
| 2275 | 2277 | const repo = repoArg(ctx, user, args); |
| 2276 | 2278 | 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 | } | |
| 2277 | 2297 | const ref = args["ref"] ?? repo.defaultBranch; |
| 2278 | 2298 | if (typeof ref !== "string" || ref.length > 255 || !isSafeRef(ref)) |
| 2279 | 2299 | 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 () => { | ||
| 111 | 111 | expect((await mcp(o, { repo: "ada/svc", ref: "--upload-pack=x" })).error).toContain("invalid"); |
| 112 | 112 | expect((await mcp(o, { repo: "ada/svc", ref: 7 })).error).toContain("invalid"); |
| 113 | 113 | }); |
| 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 | }); |
new above
offline
Nit: stray trailing comma before
}. Prettier-style elsewhere would readoid: { type: "string", minLength: 4, maxLength: 64 },. A pattern ("^[0-9a-fA-F]+$") here would also tell clients the shape before they call.