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

MCP `runs`: list a repository's recent runs, with the same rules as GET …/runs #3844

Merged opened this

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

Merged

scout/forge:scout/3831-mcp-runs-list-a-repository-s-recent-runs → main 2 commits fork

  • Merged. scout/3831-mcp-runs-list-a-repository-s-recent-runs was merged into main as b1c7265.

Review

Sign in to review this change.
4 files changed +144 −1
docs/MCP.md +1
@@ -60,6 +60,7 @@ for a tunnel and a forge that sleeps.
6060 | `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) | |
6161 | `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 |
6262 | `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 |
63 | `runs` | `{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them | |
6364 | `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 | |
6465 | `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 |
6566 | `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 | |
src/http/mcp.ts +40 −1
@@ -57,7 +57,7 @@ import { submitReview } from "../actions/pulls.ts";
5757 import { originError } from "../web/pages/thread.ts";
5858 import { holdPull, releasePull } from "../actions/holds.ts";
5959 import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts";
60 import { cancelRepoRun } from "../actions/runs.ts";
60 import { cancelRepoRun, listRepoRuns } from "../actions/runs.ts";
6161 import { familyView } from "../spawn/family.ts";
6262 import { clientRows } from "../spawn/clients.ts";
6363 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
@@ -2345,6 +2345,45 @@ const TOOLS: readonly Tool[] = [
23452345 : bad("forbidden", "only a person watches a milestone");
23462346 },
23472347 },
2348 {
2349 name: "runs",
2350 description:
2351 "A repository's recent runs, newest first: id, kind, state, the issue or pull number, started and ended; the same rules as the runs page.",
2352 inputSchema: {
2353 type: "object",
2354 properties: {
2355 repo: REPO_ARG,
2356 limit: {
2357 type: "integer",
2358 minimum: 1,
2359 maximum: 50,
2360 description: "How many, 1 to 50; 20 when absent.",
2361 },
2362 },
2363 required: ["repo"],
2364 },
2365 writes: false,
2366 run(ctx, user, args) {
2367 const limit = args["limit"] ?? 20;
2368 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
2369 return bad("bad_limit", "limit is a whole number from 1 to 50");
2370 const repo = repoArg(ctx, user, args);
2371 if (isAnswer(repo)) return repo;
2372 const runs = listRepoRuns(ctx.app, user, repo, { limit });
2373 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
2374 return good({
2375 runs: runs.value.map((r) => ({
2376 id: r.id,
2377 kind: r.kind,
2378 state: r.state,
2379 number:
2380 r.issueId === null ? null : (getIssueById(ctx.app.db, r.issueId)?.number ?? null),
2381 startedAt: r.startedAt,
2382 endedAt: r.finishedAt,
2383 })),
2384 });
2385 },
2386 },
23482387 {
23492388 name: "run_log",
23502389 description:
test/mcp-runs.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The `runs` tool lists a repository's recent runs through the same gate as
3 * GET …/runs: a list, a private repository as not found, a bad limit refused.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createIssue } from "../src/core/issues.ts";
12 import { createRun } from "../src/core/runs.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16
17 describe("MCP runs", () => {
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let aliceRo: string;
22 let bobRo: string;
23 let number: number;
24 let id = 1;
25
26 const tool = async (token: string, args: unknown) => {
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: id++,
37 method: "tools/call",
38 params: { name: "runs", arguments: args },
39 }),
40 });
41 const body: any = await res.json();
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-mcp-runs-"));
48 app = createApp({ dataDir, port: 0 });
49 servers = await serve(app);
50 const mk = async (username: string) => {
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: true,
58 });
59 if (!u.ok) throw new Error(u.error);
60 return u.value;
61 };
62 const alice = await mk("alice");
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 const repoId = made.value.id;
69 const issue = createIssue(app.db, { repoId, authorId: alice.id, title: "Build it" });
70 if (!issue.ok) throw new Error(issue.error);
71 number = issue.value.number;
72 createRun(app.db, { repoId, kind: "agent", issueId: issue.value.id, actorId: alice.id, reason: "assigned" });
73 createRun(app.db, { repoId, kind: "deploy", env: "production", reason: "push" });
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("lists runs newest first with the issue number", async () => {
83 const r = await tool(aliceRo, { repo: "alice/vault" });
84 expect(r.value.runs.map((x: any) => x.kind)).toEqual(["deploy", "agent"]);
85 expect(r.value.runs[1].number).toBe(number);
86 expect(r.value.runs[0].number).toBeNull();
87 expect(Object.keys(r.value.runs[0]).sort()).toEqual(
88 ["endedAt", "id", "kind", "number", "startedAt", "state"],
89 );
90 expect((await tool(aliceRo, { repo: "alice/vault", limit: 1 })).value.runs).toHaveLength(1);
91 });
92
93 test("a private repository without access is not found", async () => {
94 const r = await tool(bobRo, { repo: "alice/vault" });
95 expect(r.error).toContain("no such app or repository");
96 });
97
98 test("a bad limit is refused", async () => {
99 for (const limit of [0, 51, 1.5, "9"])
100 expect((await tool(aliceRo, { repo: "alice/vault", limit })).error).toContain("limit");
101 });
102 });
test/mcp.test.ts +1
@@ -222,6 +222,7 @@ describe("the forge over MCP", () => {
222222 "map",
223223 "milestones",
224224 "watch",
225 "runs",
225226 "run_log",
226227 "limb",
227228 "returns",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs