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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5365-mcp-releases-the-forge-s-kept-releases-w → main 14 commits fork 5 threads · 5 unresolved
11 files
changed +138
−7
docs/MCP.md +1
| @@ -110,6 +110,7 @@ for a tunnel and a forge that sleeps. | ||
| 110 | 110 | | `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 | | |
| 111 | 111 | | `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 | |
| 112 | 112 | | `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 | | | |
| 113 | 114 | | `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` | | |
| 114 | 115 | | `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 | |
| 115 | 116 | | `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 = { | ||
| 7 | 7 | name: "cloud_account", |
| 8 | 8 | 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`", |
| 9 | 9 | description: |
| 10 | "Change a cloud account (admin token) by `verb`.", | |
| 10 | "Change a cloud account by `verb` (admin).", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/feed.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "feed", |
| 6 | 6 | 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", |
| 7 | 7 | description: |
| 8 | "Your ranked home timeline; `cursor` pages; saved filter (`feed_chips`) applies.", | |
| 8 | "Your ranked home timeline; `cursor` pages; saved filter applies.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/front_page.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "front_page", |
| 7 | 7 | 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)", |
| 8 | 8 | 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`.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/index.ts +1
| @@ -90,6 +90,7 @@ export { tool as pull } from "./pull.ts"; | ||
| 90 | 90 | export { tool as pulls } from "./pulls.ts"; |
| 91 | 91 | export { tool as quotas } from "./quotas.ts"; |
| 92 | 92 | export { tool as read_file } from "./read_file.ts"; |
| 93 | export { tool as releases } from "./releases.ts"; | |
| 93 | 94 | export { tool as repo } from "./repo.ts"; |
| 94 | 95 | export { tool as repo_labels } from "./repo_labels.ts"; |
| 95 | 96 | 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 = { | ||
| 8 | 8 | name: "limb", |
| 9 | 9 | 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", |
| 10 | 10 | description: |
| 11 | "Act on a limb by `action`; `close` takes `reason`.", | |
| 11 | "Act on a limb by `action`.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
| @@ -20,7 +20,7 @@ export const tool: Tool = { | ||
| 20 | 20 | action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] }, |
| 21 | 21 | reason: { |
| 22 | 22 | type: "string", |
| 23 | description: "For close: why.", | |
| 23 | description: "Why.", | |
| 24 | 24 | }, |
| 25 | 25 | pillar: { |
| 26 | 26 | 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" | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "plan", |
| 6 | 6 | 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\`.`, | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | 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. | |
| 15 | if (!mayRead(ctx.app, user)) | |
Low: the page answers a refused reader with |
||
| 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 |
||
| 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 = { | ||
| 5 | 5 | name: "spawn", |
| 6 | 6 | 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", |
| 7 | 7 | description: |
| 8 | "Spawn a child forge with named apps and the crew's briefs. Plans first.", | |
| 8 | "Spawn a child forge. Plans first.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | 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", () => { | ||
| 179 | 179 | expect(names).toEqual(sortedThenReport([ |
| 180 | 180 | "drills", |
| 181 | 181 | "sizes", |
| 182 | "releases", | |
| 182 | 183 | "apps", |
| 183 | 184 | "app_deploy", |
| 184 | 185 | "new_app", |
new above
offline
Good: same gate as the page and the terminal, and refusing in words suits a caller who already authenticated with a token.