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

MCP `releases`: the forge's kept releases, which one is live and since when, for whoever may read /releases #5388

Merged opened this

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

Merged

scout/forge:scout/5365-mcp-releases-the-forge-s-kept-releases-w → main 14 commits fork 5 threads · 5 unresolved

  • Merged. scout/5365-mcp-releases-the-forge-s-kept-releases-w was merged into main as 890725d.

Review

Sign in to review this change.
11 files changed +138 −7
docs/MCP.md +1
@@ -110,6 +110,7 @@ for a tunnel and a forge that sleeps.
110110 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | |
111111 | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none; enforced when the thing is made or started, never on a forge admin; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes |
112112 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
113 | `releases` | `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
113114 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
114115 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes |
115116 | `repo_settings` | `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }`; a visibility other than public or private is `invalid` | yes |
src/http/mcp/tools/cloud_account.ts +1 −1
@@ -7,7 +7,7 @@ export const tool: Tool = {
77 name: "cloud_account",
88 doc: "`{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says`",
99 description:
10 "Change a cloud account (admin token) by `verb`.",
10 "Change a cloud account by `verb` (admin).",
1111 inputSchema: {
1212 type: "object",
1313 properties: {
src/http/mcp/tools/feed.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "feed",
66 doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out",
77 description:
8 "Your ranked home timeline; `cursor` pages; saved filter (`feed_chips`) applies.",
8 "Your ranked home timeline; `cursor` pages; saved filter applies.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
src/http/mcp/tools/front_page.ts +1 −1
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "front_page",
77 doc: "`{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10)",
88 description:
9 "The front page as a stranger sees it; admin sets `pinned`, `hidden`, `weights`.",
9 "The stranger's front page; admin sets `pinned`/`hidden`/`weights`.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
src/http/mcp/tools/index.ts +1
@@ -90,6 +90,7 @@ export { tool as pull } from "./pull.ts";
9090 export { tool as pulls } from "./pulls.ts";
9191 export { tool as quotas } from "./quotas.ts";
9292 export { tool as read_file } from "./read_file.ts";
93 export { tool as releases } from "./releases.ts";
9394 export { tool as repo } from "./repo.ts";
9495 export { tool as repo_labels } from "./repo_labels.ts";
9596 export { tool as repo_settings } from "./repo_settings.ts";
src/http/mcp/tools/limb.ts +2 −2
@@ -8,7 +8,7 @@ export const tool: Tool = {
88 name: "limb",
99 doc: "`{ 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",
1010 description:
11 "Act on a limb by `action`; `close` takes `reason`.",
11 "Act on a limb by `action`.",
1212 inputSchema: {
1313 type: "object",
1414 properties: {
@@ -20,7 +20,7 @@ export const tool: Tool = {
2020 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] },
2121 reason: {
2222 type: "string",
23 description: "For close: why.",
23 description: "Why.",
2424 },
2525 pillar: {
2626 type: "string",
src/http/mcp/tools/plan.ts +1 −1
@@ -4,7 +4,7 @@ import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"
44 export const tool: Tool = {
55 name: "plan",
66 doc: "`{ app, issues }`: open up to 12 linked issues, all or none (see below)",
7 description: `Open and link issues, all or none (max ${PLAN_MAX}); each has \`pillar\` or \`later: true\` with \`why\`.`,
7 description: `Open linked issues, all or none (max ${PLAN_MAX}); each: \`pillar\` or \`later\`+\`why\`.`,
88 inputSchema: {
99 type: "object",
1010 properties: {
src/http/mcp/tools/releases.ts added +33
@@ -0,0 +1,33 @@
1 import { getRepoById } from "../../../core/repos.ts";
2 import { listReleases, releaseName } from "../../../release/record.ts";
3 import { BOOTED, mayRead } from "../../../release/gate.ts";
4 import { running } from "../../../kernel/version.ts";
5 import { bad, good, type Tool } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "releases",
9 doc: "`{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page",
10 description: "Kept releases, newest first.",
11 inputSchema: { type: "object", properties: {} },
12 writes: false,
13 run(ctx, user) {
14 // The page's own gate: its refusal, in words.

Good: same gate as the page and the terminal, and refusing in words suits a caller who already authenticated with a token.

15 if (!mayRead(ctx.app, user))

Low: the page answers a refused reader with notFoundPage, but this tool says forbidden and explains who may read. A forge's release history is not secret, so this is fine. If you want the doors to match exactly, answer not_found here as the page does.

16 return bad("forbidden", "the release history is for the forge's administrators and its repository's readers");
17 const live = running();
18 return good({
19 items: listReleases(ctx.app.db).map((r) => {
20 const isLive = live.commit !== null && r.sha === live.commit;
21 const repo = getRepoById(ctx.app.db, r.repoId);
22 return {
23 sha: releaseName(r),
outdated

Low: use releaseName(r) from release/record.ts here, so the short sha matches the /releases page (12 characters) and the rule lives in one place.

24 version: isLive ? live.version : null,
25 pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber },
26 landed: r.createdAt,
27 ok: r.ok,
28 ...(isLive ? { live: true, since: BOOTED } : {}),
29 };
30 }),
31 });
32 },
33 };
src/http/mcp/tools/spawn.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "spawn",
66 doc: "`{ 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",
77 description:
8 "Spawn a child forge with named apps and the crew's briefs. Plans first.",
8 "Spawn a child forge. Plans first.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
test/mcp-releases.test.ts added +95
@@ -0,0 +1,95 @@
1 /** `releases` is the /releases page over MCP: the same list, the same gate, no rollback. */
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 { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { recordRelease } from "../src/release/record.ts";
12
13 const LIVE = "a".repeat(40);
14 const OLD = "b".repeat(40);
15
16 describe("MCP releases", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let admin: string;
21 let bob: string;
22 let repoId: string;
23 let n = 1;
24 const saved = process.env["FORGE_COMMIT"];
25
26 const call = async (token: string) => {
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: n++,
37 method: "tools/call",
38 params: { name: "releases", arguments: {} },
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 process.env["FORGE_COMMIT"] = LIVE;
48 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-releases-"));
49 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
50 servers = await serve(app);
51 const mk = async (username: string, first = false) => {
52 const u = await createUser(app.db, {
53 origin: "seed", trusted: true, username,
54 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
55 });
56 if (!u.ok) throw new Error(u.error);
57 return u.value;
58 };
59 const alice = await mk("alice", true);
60 const b = await mk("bob");
61 admin = createToken(app.db, alice.id, "a", "admin").token;
62 bob = createToken(app.db, b.id, "b", "admin").token;
63 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
64 if (!made.ok) throw new Error(String(made.error));
65 repoId = made.value.id;
66 }, 30_000);
67
68 afterAll(async () => {
69 if (saved === undefined) delete process.env["FORGE_COMMIT"];
70 else process.env["FORGE_COMMIT"] = saved;
71 servers.stop();
72 app.close();
73 await rm(dataDir, { recursive: true, force: true });
74 });
75
76 test("a forge with no kept releases answers an empty list", async () => {
77 expect((await call(admin)).value).toEqual({ items: [] });
78 });
79
80 test("a reader the page allows sees them newest first, the live one marked", async () => {
81 recordRelease(app.db, { sha: OLD, repoId, pullNumber: 1, ok: true, line: "ok" });
82 recordRelease(app.db, { sha: LIVE, repoId, pullNumber: 2, ok: true, line: "ok" });
83 const { items } = (await call(admin)).value;
84 expect(items.map((i: any) => i.sha)).toEqual(["a".repeat(12), "b".repeat(12)]);
85 expect(items[0].live).toBe(true);
86 expect(typeof items[0].since).toBe("string");
87 expect(items[0].pull).toEqual({ repo: "alice/forge", number: 2 });
88 expect(items[1].live).toBeUndefined();
89 });
90
91 test("one the page refuses gets its refusal", async () => {
92 const r = await call(bob);
93 expect(r.error).toContain("release history");
94 });
95 });
test/mcp.test.ts +1
@@ -179,6 +179,7 @@ describe("the forge over MCP", () => {
179179 expect(names).toEqual(sortedThenReport([
180180 "drills",
181181 "sizes",
182 "releases",
182183 "apps",
183184 "app_deploy",
184185 "new_app",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs