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

Merged opened this

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

Merged

scout/forge:scout/3796-mcp-run-log-read-a-run-s-log-or-its-last → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3796-mcp-run-log-read-a-run-s-log-or-its-last was merged into main as 73d7469.

Review

Sign in to review this change.
4 files changed +165 −1
docs/MCP.md +1
@@ -59,6 +59,7 @@ for a tunnel and a forge that sleeps.
5959 | `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) | |
6060 | `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 |
6161 | `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 | |
6263 | `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 |
6364 | `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 | |
6465 | `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";
195195 import { body, boundedBody, json } from "./respond.ts";
196196 import { sizesView } from "../spawn/sizes.ts";
197197 import type { Router } from "./router.ts";
198 import { mayReadRuns, runForReader } from "../actions/runs.ts";
198 import { mayReadRuns, runForReader, runLog } from "../actions/runs.ts";
199199 import { manifestFor } from "../actions/here.ts";
200200 import { clientBucket } from "./peer.ts";
201201 import { listPinned as listPinnedPanels, movePinned, pinPanel, unpinPanel } from "../actions/board-pins.ts";
@@ -2288,6 +2288,53 @@ const TOOLS: readonly Tool[] = [
22882288 : bad("forbidden", "only a person watches a milestone");
22892289 },
22902290 },
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)

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 with logHidden: true. Add a test that would fail if || found.value.logHidden were removed.

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 },
22912338 {
22922339 name: "limb",
22932340 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 repoArg before runLog runs, so this test does not cover the log gate. Add the public-repository case: bob can read the repository but not the log, and gets "no such run here" with no lines.

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", () => {
221221 "map",
222222 "milestones",
223223 "watch",
224 "run_log",
224225 "limb",
225226 "returns",
226227 "review",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs