Public: anyone can read this; the crew can change it.
MCP `run_log`: read a run's log, or its last N lines, with the same rules as GET …/runs/{id}/log #3806
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3796-mcp-run-log-read-a-run-s-log-or-its-last → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +165
−1
docs/MCP.md +1
| @@ -59,6 +59,7 @@ for a tunnel and a forge that sleeps. | ||
| 59 | 59 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | |
| 60 | 60 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
| 61 | 61 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 62 | | `run_log` | `{ repo, run, tail? }`: a run's log, or its last `tail` lines (1–2000), with the REST route's access: a hidden log or unreadable repository is not found | | | |
| 62 | 63 | | `review` | `{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull | yes | |
| 63 | 64 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
| 64 | 65 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | |
src/http/mcp.ts +48 −1
| @@ -195,7 +195,7 @@ import { resourceMetadataUrl } from "./oauth.ts"; | ||
| 195 | 195 | import { body, boundedBody, json } from "./respond.ts"; |
| 196 | 196 | import { sizesView } from "../spawn/sizes.ts"; |
| 197 | 197 | import type { Router } from "./router.ts"; |
| 198 | import { mayReadRuns, runForReader } from "../actions/runs.ts"; | |
| 198 | import { mayReadRuns, runForReader, runLog } from "../actions/runs.ts"; | |
| 199 | 199 | import { manifestFor } from "../actions/here.ts"; |
| 200 | 200 | import { clientBucket } from "./peer.ts"; |
| 201 | 201 | import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts"; |
| @@ -2288,6 +2288,53 @@ const TOOLS: readonly Tool[] = [ | ||
| 2288 | 2288 | : bad("forbidden", "only a person watches a milestone"); |
| 2289 | 2289 | }, |
| 2290 | 2290 | }, |
| 2291 | { | |
| 2292 | name: "run_log", | |
| 2293 | description: | |
| 2294 | "A run's log, or its last `tail` lines: the same text and the same access as GET /api/v1/repos/:owner/:name/runs/:id/log.", | |
| 2295 | inputSchema: { | |
| 2296 | type: "object", | |
| 2297 | properties: { | |
| 2298 | repo: { ...REPO_ARG, maxLength: 140 }, | |
| 2299 | run: { type: "string", maxLength: 80, description: "The run's id." }, | |
| 2300 | tail: { | |
| 2301 | type: "integer", | |
| 2302 | minimum: 1, | |
| 2303 | maximum: 2000, | |
| 2304 | description: "Only the last this many lines.", | |
| 2305 | }, | |
| 2306 | }, | |
| 2307 | required: ["repo", "run"], | |
| 2308 | }, | |
| 2309 | writes: false, | |
| 2310 | run(ctx, user, args) { | |
| 2311 | const tail = args["tail"]; | |
| 2312 | if ( | |
| 2313 | typeof args["run"] !== "string" || | |
| 2314 | args["run"].length === 0 || | |
| 2315 | args["run"].length > 80 || | |
| 2316 | (tail !== undefined && | |
| 2317 | (typeof tail !== "number" || | |
| 2318 | !Number.isInteger(tail) || | |
| 2319 | tail < 1 || | |
| 2320 | tail > 2000)) | |
| 2321 | ) | |
| 2322 | return bad("invalid", "`run` is a run id; `tail` is 1 to 2000 lines"); | |
| 2323 | const repo = repoArg(ctx, user, args); | |
| 2324 | if (isAnswer(repo)) return repo; | |
| 2325 | const found = runLog(ctx.app, user, repo, args["run"]); | |
| 2326 | // A hidden log is not found, as on the REST route. | |
| 2327 | if (!found.ok || found.value.logHidden) | |
| 2328 | return bad("not_found", "no such run here"); | |
| 2329 | const all = found.value.lines; | |
| 2330 | const lines = tail === undefined ? all : all.slice(-tail); | |
| 2331 | return good({ | |
| 2332 | run: found.value.run.id, | |
| 2333 | lines, | |
| 2334 | truncated: found.value.truncated || lines.length < all.length, | |
| 2335 | }); | |
| 2336 | }, | |
| 2337 | }, | |
| 2291 | 2338 | { |
| 2292 | 2339 | name: "limb", |
| 2293 | 2340 | description: |
test/mcp-run-log.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** The `run_log` tool answers what GET …/runs/:id/log would, to the same callers. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createRun } from "../src/core/runs.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | describe("MCP run_log", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let aliceRo: string; | |
| 19 | let bobRo: string; | |
| 20 | let runId: string; | |
| 21 | let openRunId: string; | |
| 22 | let id = 1; | |
| 23 | ||
| 24 | const tool = async (token: string, args: unknown) => { | |
| 25 | const res = await fetch(`${servers.url}/mcp`, { | |
| 26 | method: "POST", | |
| 27 | headers: { | |
| 28 | "content-type": "application/json", | |
| 29 | accept: "application/json, text/event-stream", | |
| 30 | authorization: `Bearer ${token}`, | |
| 31 | }, | |
| 32 | body: JSON.stringify({ | |
| 33 | jsonrpc: "2.0", | |
| 34 | id: id++, | |
| 35 | method: "tools/call", | |
| 36 | params: { name: "run_log", arguments: args }, | |
| 37 | }), | |
| 38 | }); | |
| 39 | const body: any = await res.json(); | |
| 40 | const text: string = body.result.content[0].text; | |
| 41 | return body.result.isError === true | |
| 42 | ? { error: text } | |
| 43 | : { value: JSON.parse(text) }; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-run-log-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const mk = async (username: string, first = false) => { | |
| 51 | const u = await createUser(app.db, { | |
| 52 | origin: "seed", | |
| 53 | trusted: true, | |
| 54 | username, | |
| 55 | email: `${username}@forge.local`, | |
| 56 | password: "a-good-passphrase", | |
| 57 | adminIfFirst: first, | |
| 58 | }); | |
| 59 | if (!u.ok) throw new Error(u.error); | |
| 60 | return u.value; | |
| 61 | }; | |
| 62 | const alice = await mk("alice", true); | |
| 63 | const bob = await mk("bob"); | |
| 64 | aliceRo = createToken(app.db, alice.id, "ro", "ro").token; | |
| 65 | bobRo = createToken(app.db, bob.id, "ro", "ro").token; | |
| 66 | const made = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 67 | if (!made.ok) throw new Error(String(made.error)); | |
| 68 | runId = createRun(app.db, { repoId: made.value.id, kind: "agent", actorId: alice.id, reason: "assigned" }).id; | |
| 69 | for (const line of ["one", "two", "three", "four"]) appendRunLog(dataDir, runId, line); | |
| 70 | const open = await createRepo(app, alice, { name: "square", visibility: "public" }); | |
| 71 | if (!open.ok) throw new Error(String(open.error)); | |
| 72 | openRunId = createRun(app.db, { repoId: open.value.id, kind: "agent", actorId: alice.id, reason: "assigned" }).id; | |
| 73 | appendRunLog(dataDir, openRunId, "secret-line"); | |
| 74 | }, 30_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the owner reads the log, or its last lines", async () => { | |
| 83 | const all = await tool(aliceRo, { repo: "alice/vault", run: runId }); | |
| 84 | expect(all.value.lines).toEqual(["one", "two", "three", "four"]); | |
| 85 | const tail = await tool(aliceRo, { repo: "alice/vault", run: runId, tail: 2 }); | |
outdated
A private repository is refused by |
||
| 86 | expect(tail.value.lines).toEqual(["three", "four"]); | |
| 87 | expect(tail.value.truncated).toBe(true); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a caller who cannot read a private repository is told it is not found", async () => { | |
| 91 | const r = await tool(bobRo, { repo: "alice/vault", run: runId }); | |
| 92 | expect(r.error).toContain("no such app or repository"); | |
| 93 | expect(JSON.stringify(r)).not.toContain("three"); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a reader of a public repository who is not crew cannot read the log", async () => { | |
| 97 | const r = await tool(bobRo, { repo: "alice/square", run: openRunId }); | |
| 98 | expect(r.error).toContain("no such run here"); | |
| 99 | expect(JSON.stringify(r)).not.toContain("secret-line"); | |
| 100 | const owner = await tool(aliceRo, { repo: "alice/square", run: openRunId }); | |
| 101 | expect(owner.value.lines).toEqual(["secret-line"]); | |
| 102 | }); | |
| 103 | ||
| 104 | test("an unknown run is not found", async () => { | |
| 105 | const r = await tool(aliceRo, { repo: "alice/vault", run: "nope" }); | |
| 106 | expect(r.error).toContain("no such run here"); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a bad argument is refused", async () => { | |
| 110 | for (const bad of [{ tail: 0 }, { tail: 2001 }, { tail: 1.5 }, { tail: "2" }, { run: 5 }, { run: "" }]) { | |
| 111 | const r = await tool(aliceRo, { repo: "alice/vault", run: runId, ...bad }); | |
| 112 | expect(r.error).toBeDefined(); | |
| 113 | } | |
| 114 | }); | |
| 115 | }); |
test/mcp.test.ts +1
| @@ -221,6 +221,7 @@ describe("the forge over MCP", () => { | ||
| 221 | 221 | "map", |
| 222 | 222 | "milestones", |
| 223 | 223 | "watch", |
| 224 | "run_log", | |
| 224 | 225 | "limb", |
| 225 | 226 | "returns", |
| 226 | 227 | "review", |
new above
offline
This branch is the tool's only access decision beyond
repoArg, and no test exercises it. A public-repo reader who is not crew reaches it withlogHidden: true. Add a test that would fail if|| found.value.logHiddenwere removed.