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

MCP `sync`: sync a mirror now, or reconnect its origin, with the same rules as the API #6994

Merged opened this

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

Merged

scout/forge:scout/4903-mcp-sync-sync-a-mirror-now-or-reconnect → main 8 commits fork

  • Merged. scout/4903-mcp-sync-sync-a-mirror-now-or-reconnect was merged into main as 383293e.

Review

Sign in to review this change.
5 files changed +275 −0
docs/MCP.md +1
@@ -151,6 +151,7 @@ for a tunnel and a forge that sleeps.
151151 | `subscription_budget` | `{ id, usd }`: set an account's whole-life budget in dollars (up to the `subscription.budget_max_usd` limit), or clear it with `usd: null`, as the Subscriptions page does; a locked budget can only be lowered; a forge admin with an admin token | yes |
152152 | `subscription_delete` | `{ id }`: remove a subscription and its grants, as the Subscriptions page does; runs it paid for keep it in their history; a forge admin's, with an admin token | yes |
153153 | `subscription_wake` | `{ id }`: use a resting Claude subscription again now, as the Subscriptions page's Wake button does; answers `{ id, says }`; a forge admin, an admin token | yes |
154 | `sync` | `{ repo, originId? }`: sync a mirror now, or with `originId` (admin token) reconnect its origin; refused as `POST …/sync` and `…/origin/reconnect` are, a fork is pointed to `take_updates` | yes |
154155 | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | |
155156 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
156157 | `teams` | `{ slug?, charter?, dial?, members?, dryRun? }`: the crew teams (person admins): without `slug` lists them; with it, changes charter, dial (0–100) or members, planned unless `dryRun` is false | yes |
src/http/mcp/tools/index.ts +1
@@ -126,6 +126,7 @@ export { tool as subscription_add } from "./subscription_add.ts";
126126 export { tool as subscription_budget } from "./subscription_budget.ts";
127127 export { tool as subscription_delete } from "./subscription_delete.ts";
128128 export { tool as subscription_wake } from "./subscription_wake.ts";
129 export { tool as sync } from "./sync.ts";
129130 export { tool as system } from "./system.ts";
130131 export { tool as take_updates } from "./take_updates.ts";
131132 export { tool as teams } from "./teams.ts";
src/http/mcp/tools/sync.ts added +57
@@ -0,0 +1,57 @@
1 import { ORIGIN_UNREACHABLE } from "../../../actions/origin_app_words.ts";
2 import { reattachMirror, syncRepoNow } from "../../../actions/origins.ts";
3 import { scopeAllows } from "../../../core/tokens.ts";
4 import { good, bad, type Answer, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts";
5
6 /** What an agent does next, for each way a sync or a reconnect is refused. */
7 const REFUSALS: Record<string, string> = {
8 forbidden: "you need write access to sync here",
9 unauthorized: "you need write access to sync here",
10 not_detached: "this mirror is not frozen; sync it without originId",
11 wrong_host: "that origin is on another site than the mirror came from",
12 remote_unknown: "this mirror's source is not known; it cannot be reconnected",
13 origin_cannot_see: "that origin's account cannot see the source repository",
14 not_the_same_repo: "the origin's repository is not the one this mirror came from",
15 already_mirrored: "that repository is already mirrored here",
16 needs_reauth: "that origin's token no longer works; connect it again",
17 unreachable: `${ORIGIN_UNREACHABLE}; try again later`,
18 };
19
20 export const tool: Tool = {
21 name: "sync",
22 doc: "`{ repo, originId? }`: sync a mirror now, or with `originId` (admin token) reconnect its origin; refused as `POST …/sync` and `…/origin/reconnect` are, a fork is pointed to `take_updates`",
23 description: "Sync a mirror now; `originId` reconnects it.",
24 inputSchema: {
25 type: "object",
26 properties: { repo: REPO_ARG, originId: REPO_ARG },
27 required: ["repo"],
28 },
29 writes: true,
30 async run(ctx, user, args) {
31 const repo = repoArg(ctx, user, args);
32 if (isAnswer(repo)) return repo;
33 const reconnecting = args["originId"] !== undefined;
34 const refused = (e: string): Answer =>
35 e === "not_found"
36 ? bad("not_found", "no such app or repository (owner/name)")
37 : reconnecting && (e === "forbidden" || e === "unauthorized")
38 ? bad(e, "you need to be an admin of this repository to reconnect its origin")
39 : bad(e, REFUSALS[e] ?? e);
40 if (reconnecting) {
41 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
42 return bad("forbidden", "reconnecting a mirror needs an admin-scope token");
43 const done = await reattachMirror(ctx.app, user, repo, str(args["originId"]));
44 return done.ok
45 ? good({ says: `Reconnected ${done.value.fullName}; its refs are syncing.`, fullName: done.value.fullName })
46 : refused(done.error);
47 }
48 if (repo.origin === null && repo.forkParentId !== null)
49 return bad("not_a_mirror", "this is a fork; use take_updates");
50 const synced = await syncRepoNow(ctx.app, user, repo);
51 return synced.ok
52 ? synced.value.error === ""
53 ? good({ says: "Synced.", ...synced.value })
54 : bad("sync_failed", synced.value.error)
55 : refused(synced.error);
56 },
57 };
test/mcp-sync.test.ts added +215
@@ -0,0 +1,215 @@
1 /**
2 * The `sync` tool is POST …/sync and …/origin/reconnect over MCP: the same
3 * actions, the same refusals, a faked origin and no network.
4 */
5 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, 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, forkRepo } from "../src/actions/repos.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createOrigin } from "../src/core/origins.ts";
13 import { getRepo } from "../src/core/repos.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { syncsSettled } from "../src/origins/sync.ts";
17 import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
18 import { proveAll } from "./helpers/verified.ts";
19 import { seedAdmin } from "./helpers/admin.ts";
20 import { promote } from "./helpers/members.ts";
21
22 setDefaultTimeout(60_000);
23
24 const LOGIN = "octo";
25 const TOKEN = "octo-origin-secret-9d2f";
26 const PASSWORD = "alice-login-secret";
27 const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`;
28
29 describe("MCP sync", () => {
30 let dirs: string[] = [];
31 let app: App;
32 let servers: Servers;
33 let originApp: App;
34 let originServers: Servers;
35 let fake: FakeForge;
36 let adminTok: string;
37 let rwTok: string;
38 let readerTok: string;
39 let id = 1;
40 let firstOrigin = "";
41
42 const rpc = async (token: string, args: unknown) => {
43 const res = await fetch(`${servers.url}/mcp`, {
44 method: "POST",
45 headers: {
46 "content-type": "application/json",
47 accept: "application/json, text/event-stream",
48 authorization: `Bearer ${token}`,
49 },
50 body: JSON.stringify({
51 jsonrpc: "2.0",
52 id: id++,
53 method: "tools/call",
54 params: { name: "sync", arguments: args },
55 }),
56 });
57 const body = (await res.json()) as any;
58 return { text: String(body.result?.content?.[0]?.text ?? ""), isError: body.result?.isError === true };
59 };
60
61 beforeAll(async () => {
62 for (const p of ["forge-mcpsync-", "forge-mcpsync-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p)));
63 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
64 originServers = await serve(originApp);
65 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
66 const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
67 await fetch(`${originServers.url}/api/v1/repos`, {
68 method: "POST",
69 headers: oh,
70 body: JSON.stringify({ name: "sprockets", visibility: "public" }),
71 });
72 await fetch(`${originServers.url}/api/v1/repos/octo/sprockets/contents`, {
73 method: "POST",
74 headers: oh,
75 body: JSON.stringify({ branch: "main", message: "first", files: [{ path: "a.txt", content: "one\n" }] }),
76 });
77 const state = newFakeState("github", LOGIN, TOKEN);
78 state.repos.push({
79 id: 5,
80 fullName: "octo/sprockets",
81 cloneUrl: `${originServers.url}/octo/sprockets.git`,
82 defaultBranch: "main",
83 htmlUrl: `${originServers.url}/octo/sprockets`,
84 });
85 fake = serveFakeForge(state);
86 // The repository's page lives on the origin's own site; a reconnect compares sites.
87 state.repos[0]!.htmlUrl = `${fake.url}/octo/sprockets`;
88
89 app = createApp({ dataDir: dirs[0]!, port: 0 });
90 servers = await serve(app);
91 const alice = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
92 if (!alice.ok) throw new Error(alice.error);
93 const aliceUser = alice.value;
94 const api = (path: string, init?: RequestInit) => {
95 const headers = new Headers(init?.headers);
96 headers.set("content-type", "application/json");
97 headers.set("authorization", basic("alice", PASSWORD));
98 return fetch(`${servers.url}${path}`, { ...init, headers });
99 };
100 const connected = (await (
101 await api("/api/v1/user/origins", {
102 method: "POST",
103 body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN }),
104 })
105 ).json()) as { id: string };
106 firstOrigin = connected.id;
107 await mirrorAndWait(
108 await api(`/api/v1/user/origins/${connected.id}/mirror`, {
109 method: "POST",
110 body: JSON.stringify({ fullName: "octo/sprockets" }),
111 }),
112 );
113 proveAll(app);
114 const made = await createRepo(app, aliceUser, { name: "plain", visibility: "public" });
115 if (!made.ok) throw new Error(String(made.error));
116 const a = getUserByName(app.db, "alice")!;
117 adminTok = createToken(app.db, a.id, "admin", "admin").token;
118 rwTok = createToken(app.db, a.id, "rw", "rw").token;
119 const b = await createUser(app.db, {
120 origin: "seed",
121 trusted: true,
122 username: "bob",
123 email: "[email protected]",
124 password: "a-good-passphrase",
125 });
126 if (!b.ok) throw new Error(b.error);
127 promote(app.db, b.value);
128 readerTok = createToken(app.db, b.value.id, "rw", "rw").token;
129 });
130
131 afterAll(async () => {
132 await syncsSettled();
133 fake.stop();
134 servers.stop();
135 originServers.stop();
136 app.close();
137 originApp.close();
138 for (const d of dirs) await rm(d, { recursive: true, force: true });
139 });
140
141 test("an admin starts a sync of a mirror", async () => {
142 const r = await rpc(adminTok, { repo: "alice/sprockets" });
143 expect(r.isError).toBe(false);
144 expect(r.text).toContain("Synced");
145 });
146
147 test("a non-mirror is refused as the API refuses", async () => {
148 const r = await rpc(adminTok, { repo: "alice/plain" });
149 expect(r.isError).toBe(true);
150 expect(r.text).toContain("not_found");
151 });
152
153 test("a reader is refused", async () => {
154 const r = await rpc(readerTok, { repo: "alice/sprockets" });
155 expect(r.isError).toBe(true);
156 expect(r.text).toContain("write access");
157 });
158
159 test("an attached mirror is not frozen, and reconnecting says so", async () => {
160 const r = await rpc(adminTok, { repo: "alice/sprockets", originId: "x" });
161 expect(r.isError).toBe(true);
162 expect(r.text).toContain("not_detached");
163 expect(r.text).toContain("sync it without originId");
164 });
165
166 test("a native fork is not a mirror: take_updates", async () => {
167 const parent = getRepo(app.db, "alice", "plain")!;
168 const forked = await forkRepo(app, getUserByName(app.db, "alice"), parent, "plain-fork");
169 if (!forked.ok) throw new Error(String(forked.error));
170 const r = await rpc(adminTok, { repo: "alice/plain-fork" });
171 expect(r.isError).toBe(true);
172 expect(r.text).toContain("not_a_mirror");
173 expect(r.text).toContain("take_updates");
174 });
175
176 test("reconnecting needs an admin-scope token", async () => {
177 const r = await rpc(rwTok, { repo: "alice/sprockets", originId: "x" });
178 expect(r.isError).toBe(true);
179 expect(r.text).toContain("admin-scope");
180 });
181
182 test("a frozen mirror is reconnected to a new origin of the same account", async () => {
183 const api = (path: string, init?: RequestInit) =>
184 fetch(`${servers.url}${path}`, {
185 ...init,
186 headers: { "content-type": "application/json", authorization: basic("alice", PASSWORD) },
187 });
188 const gone = await api(`/api/v1/user/origins/${firstOrigin}`, { method: "DELETE" });
189 expect(gone.status).toBe(200);
190 const before = (await (await api("/api/v1/repos/alice/sprockets")).json()) as any;
191 expect(before.origin.detached).toBe(true);
192 // A row made directly, not through a connect, which would reattach by itself.
193 const made = createOrigin(app.db, app.config.dataDir, {
194 userId: getUserByName(app.db, "alice")!.id,
195 kind: "github",
196 apiUrl: fake.url,
197 webUrl: fake.url,
198 login: LOGIN,
199 token: TOKEN,
200 });
201 if (!made.ok) throw new Error(made.error);
202 const again = { id: made.value.id };
203
204 const unknown = await rpc(adminTok, { repo: "alice/sprockets", originId: "nope" });
205 expect(unknown.isError).toBe(true);
206 expect(unknown.text).toContain("not_found");
207
208 const r = await rpc(adminTok, { repo: "alice/sprockets", originId: again.id });
209 expect(r.isError).toBe(false);
210 expect(r.text).toContain("Reconnected octo/sprockets");
211 await syncsSettled();
212 const after = (await (await api("/api/v1/repos/alice/sprockets")).json()) as any;
213 expect(after.origin.detached).toBe(false);
214 });
215 });
test/mcp.test.ts +1
@@ -245,6 +245,7 @@ describe("the forge over MCP", () => {
245245 "forges",
246246 "claim_link",
247247 "take_updates",
248 "sync",
248249 "sever",
249250 "flag_set",
250251 "follow_mode",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs