Public: anyone can read this; the crew can change it.
MCP `origins`: list the caller's connected origins and their repositories, and sync one, with the same rules as the API #4828
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4811-mcp-origins-list-the-caller-s-connected → main 1 commit fork
5 files
changed +181
−2
docs/MCP.md +1
| @@ -44,6 +44,7 @@ for a tunnel and a forge that sleeps. | ||
| 44 | 44 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 45 | 45 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
| 46 | 46 | | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 47 | | `origins` | `{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes | | |
| 47 | 48 | | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes | |
| 48 | 49 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | |
| 49 | 50 | | `trail` | `{ repo, shown?, dryRun? }`: where a migrated repository came from (`provenance`: source, sourceUrl, parents, migratedAt) and whether it is shown; with `shown`, shows or hides the trail — planned first (`dryRun` is true unless `false`), only the repository's admin with an admin token, never an agent. Hiding keeps the record; a hidden one reads `null` to anyone but an admin | yes | |
src/http/api.ts +2 −2
| @@ -648,7 +648,7 @@ function originFacts(repo: Repo) { | ||
| 648 | 648 | } |
| 649 | 649 | |
| 650 | 650 | /** An origin as the API returns it: everything except the credential. */ |
| 651 | function originJson(origin: Origin) { | |
| 651 | export function originJson(origin: Origin) { | |
| 652 | 652 | return { |
| 653 | 653 | id: origin.id, |
| 654 | 654 | kind: origin.kind, |
| @@ -728,7 +728,7 @@ function migrationJson(ctx: Ctx, job: MirrorJob) { | ||
| 728 | 728 | * client can navigate to out here — the picker's whole job is to say |
| 729 | 729 | * "already here" or "not yet". |
| 730 | 730 | */ |
| 731 | function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) { | |
| 731 | export function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) { | |
| 732 | 732 | const mirror = |
| 733 | 733 | row.mirroredAs === null |
| 734 | 734 | ? undefined |
src/http/mcp.ts +40
| @@ -17,6 +17,8 @@ | ||
| 17 | 17 | * no page. `ro` tokens read; `rw` tokens act. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../actions/origins.ts"; | |
| 21 | import { originJson, remoteRepoJson } from "./api.ts"; | |
| 20 | 22 | import { showProvenance } from "../actions/provenance.ts"; |
| 21 | 23 | import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts"; |
| 22 | 24 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| @@ -1624,6 +1626,44 @@ const TOOLS: readonly Tool[] = [ | ||
| 1624 | 1626 | }); |
| 1625 | 1627 | }, |
| 1626 | 1628 | }, |
| 1629 | { | |
| 1630 | name: "origins", | |
| 1631 | description: | |
| 1632 | "Your connected origins: list, repos of one, or sync it.", | |
| 1633 | inputSchema: { | |
| 1634 | type: "object", | |
| 1635 | properties: { | |
| 1636 | action: { type: "string", enum: ["list", "repos", "sync"] }, | |
| 1637 | id: { type: "string" }, | |
| 1638 | }, | |
| 1639 | required: ["action"], | |
| 1640 | }, | |
| 1641 | writes: true, | |
| 1642 | readsWithout: "id", | |
| 1643 | async run(ctx, user, args) { | |
| 1644 | const action = args["action"]; | |
| 1645 | const id = args["id"]; | |
| 1646 | if (action !== "list" && action !== "repos" && action !== "sync") | |
| 1647 | return bad("bad_action", "`action` is list, repos or sync"); | |
| 1648 | if (action !== "list" && (typeof id !== "string" || id === "")) | |
| 1649 | return bad("invalid", "send the origin's `id`, from list"); | |
| 1650 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 1651 | return bad("forbidden", "origins spend your credential elsewhere and need an admin token"); | |
| 1652 | if (action === "list") | |
| 1653 | return good(listUserOrigins(ctx.app, user).map(originJson)); | |
| 1654 | const origin = id as string; | |
| 1655 | if (action === "sync") { | |
| 1656 | const synced = await syncOriginNow(ctx.app, user, origin); | |
| 1657 | return synced.ok ? good(synced.value) : bad("not_found", "no such origin"); | |
| 1658 | } | |
| 1659 | const listed = await listRemoteRepos(ctx.app, user, origin); | |
| 1660 | if (!listed.ok) | |
| 1661 | return listed.error === "unreachable" | |
| 1662 | ? bad("origin_unreachable", "the origin could not be reached") | |
| 1663 | : bad("not_found", "no such origin"); | |
| 1664 | return good(listed.value.map((row) => remoteRepoJson(ctx, row))); | |
| 1665 | }, | |
| 1666 | }, | |
| 1627 | 1667 | { |
| 1628 | 1668 | name: "decisions_by", |
| 1629 | 1669 | description: |
test/mcp-origins.test.ts added +137
| @@ -0,0 +1,137 @@ | ||
| 1 | /** | |
| 2 | * The `origins` tool is the API's origin routes over MCP: the same actions, | |
| 3 | * for the caller only, and never a credential in an answer. | |
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; | |
| 14 | ||
| 15 | const LOGIN = "octo"; | |
| 16 | const TOKEN = "octo-origin-secret-9f3a"; | |
| 17 | ||
| 18 | describe("MCP origins", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let fake: FakeForge; | |
| 23 | let aliceAdmin: string; | |
| 24 | let aliceRw: string; | |
| 25 | let bobAdmin: string; | |
| 26 | let originId = ""; | |
| 27 | let id = 1; | |
| 28 | ||
| 29 | const tool = async (token: string, args: unknown) => { | |
| 30 | const res = await fetch(`${servers.url}/mcp`, { | |
| 31 | method: "POST", | |
| 32 | headers: { | |
| 33 | "content-type": "application/json", | |
| 34 | accept: "application/json, text/event-stream", | |
| 35 | authorization: `Bearer ${token}`, | |
| 36 | }, | |
| 37 | body: JSON.stringify({ | |
| 38 | jsonrpc: "2.0", | |
| 39 | id: id++, | |
| 40 | method: "tools/call", | |
| 41 | params: { name: "origins", arguments: args }, | |
| 42 | }), | |
| 43 | }); | |
| 44 | const body: any = await res.json(); | |
| 45 | const text: string = body.result.content[0].text; | |
| 46 | return body.result.isError === true | |
| 47 | ? { error: text } | |
| 48 | : { value: JSON.parse(text), text }; | |
| 49 | }; | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-origins-")); | |
| 53 | const state = newFakeState("github", LOGIN, TOKEN); | |
| 54 | state.repos.push({ | |
| 55 | id: 5, | |
| 56 | fullName: "octo/sprockets", | |
| 57 | cloneUrl: "http://127.0.0.1:1/octo/sprockets.git", | |
| 58 | defaultBranch: "main", | |
| 59 | }); | |
| 60 | fake = serveFakeForge(state); | |
| 61 | app = createApp({ dataDir, port: 0 }); | |
| 62 | servers = await serve(app); | |
| 63 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 64 | username: "alice", | |
| 65 | email: "[email protected]", | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst: true, | |
| 68 | }); | |
| 69 | if (!a.ok) throw new Error(a.error); | |
| 70 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 71 | username: "bob", | |
| 72 | email: "[email protected]", | |
| 73 | password: "a-good-passphrase", | |
| 74 | }); | |
| 75 | if (!b.ok) throw new Error(b.error); | |
| 76 | aliceAdmin = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 77 | aliceRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 78 | bobAdmin = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 79 | const res = await fetch(`${servers.url}/api/v1/user/origins`, { | |
| 80 | method: "POST", | |
| 81 | headers: { | |
| 82 | "content-type": "application/json", | |
| 83 | authorization: `Bearer ${aliceAdmin}`, | |
| 84 | }, | |
| 85 | body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN }), | |
| 86 | }); | |
| 87 | expect(res.status).toBe(201); | |
| 88 | originId = ((await res.json()) as { id: string }).id; | |
| 89 | }); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | fake.stop(); | |
| 93 | await servers.stop(); | |
| 94 | app.db.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("lists the caller's origin without its token", async () => { | |
| 99 | const r = await tool(aliceAdmin, { action: "list" }); | |
| 100 | expect(r.value.map((o: any) => o.id)).toEqual([originId]); | |
| 101 | expect(r.value[0].login).toBe(LOGIN); | |
| 102 | expect(r.text).not.toContain(TOKEN); | |
| 103 | }); | |
| 104 | ||
| 105 | test("lists its repositories and starts a sync", async () => { | |
| 106 | const repos = await tool(aliceAdmin, { action: "repos", id: originId }); | |
| 107 | expect(repos.value.map((x: any) => x.fullName)).toEqual(["octo/sprockets"]); | |
| 108 | expect(repos.value[0].mirroredAs).toBeNull(); | |
| 109 | expect(repos.text).not.toContain(TOKEN); | |
| 110 | const sync = await tool(aliceAdmin, { action: "sync", id: originId }); | |
| 111 | expect(sync.value).toBeDefined(); | |
| 112 | expect(sync.text).not.toContain(TOKEN); | |
| 113 | }); | |
| 114 | ||
| 115 | test("another person sees nothing of it", async () => { | |
| 116 | expect((await tool(bobAdmin, { action: "list" })).value).toEqual([]); | |
| 117 | for (const action of ["repos", "sync"]) | |
| 118 | expect((await tool(bobAdmin, { action, id: originId })).error).toContain( | |
| 119 | "not_found", | |
| 120 | ); | |
| 121 | }); | |
| 122 | ||
| 123 | test("an unknown id is not found; a missing id is invalid", async () => { | |
| 124 | expect( | |
| 125 | (await tool(aliceAdmin, { action: "repos", id: "nope" })).error, | |
| 126 | ).toContain("not_found"); | |
| 127 | expect((await tool(aliceAdmin, { action: "sync" })).error).toContain("invalid"); | |
| 128 | expect((await tool(aliceAdmin, { action: "x" })).error).toContain("bad_action"); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a token short of admin is refused", async () => { | |
| 132 | expect((await tool(aliceRw, { action: "list" })).error).toContain("forbidden"); | |
| 133 | expect( | |
| 134 | (await tool(aliceRw, { action: "sync", id: originId })).error, | |
| 135 | ).toContain("forbidden"); | |
| 136 | }); | |
| 137 | }); |
test/mcp.test.ts +1
| @@ -203,6 +203,7 @@ describe("the forge over MCP", () => { | ||
| 203 | 203 | "account_role", |
| 204 | 204 | "model_ceiling", |
| 205 | 205 | "outsiders", |
| 206 | "origins", | |
| 206 | 207 | "decisions_by", |
| 207 | 208 | "archive", |
| 208 | 209 | "trail", |
new above
offline