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

Merged opened this

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

Merged

scout/forge:scout/4811-mcp-origins-list-the-caller-s-connected → main 1 commit fork

  • Merged. scout/4811-mcp-origins-list-the-caller-s-connected was merged into main as 0b7da5c.

Review

Sign in to review this change.
5 files changed +181 −2
docs/MCP.md +1
@@ -44,6 +44,7 @@ for a tunnel and a forge that sleeps.
4444 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
4545 | `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`) | |
4646 | `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 |
4748 | `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 |
4849 | `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 |
4950 | `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) {
648648 }
649649
650650 /** An origin as the API returns it: everything except the credential. */
651 function originJson(origin: Origin) {
651 export function originJson(origin: Origin) {
652652 return {
653653 id: origin.id,
654654 kind: origin.kind,
@@ -728,7 +728,7 @@ function migrationJson(ctx: Ctx, job: MirrorJob) {
728728 * client can navigate to out here — the picker's whole job is to say
729729 * "already here" or "not yet".
730730 */
731 function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) {
731 export function remoteRepoJson(ctx: Ctx, row: OriginRepoRow) {
732732 const mirror =
733733 row.mirroredAs === null
734734 ? undefined
src/http/mcp.ts +40
@@ -17,6 +17,8 @@
1717 * no page. `ro` tokens read; `rw` tokens act.
1818 */
1919
20 import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../actions/origins.ts";
21 import { originJson, remoteRepoJson } from "./api.ts";
2022 import { showProvenance } from "../actions/provenance.ts";
2123 import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts";
2224 import { upstreamPayload } from "../spawn/upstream.ts";
@@ -1624,6 +1626,44 @@ const TOOLS: readonly Tool[] = [
16241626 });
16251627 },
16261628 },
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 },
16271667 {
16281668 name: "decisions_by",
16291669 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", () => {
203203 "account_role",
204204 "model_ceiling",
205205 "outsiders",
206 "origins",
206207 "decisions_by",
207208 "archive",
208209 "trail",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs