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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4903-mcp-sync-sync-a-mirror-now-or-reconnect → main 8 commits fork
5 files
changed +275
−0
docs/MCP.md +1
| @@ -151,6 +151,7 @@ for a tunnel and a forge that sleeps. | ||
| 151 | 151 | | `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 | |
| 152 | 152 | | `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 | |
| 153 | 153 | | `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 | | |
| 154 | 155 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 155 | 156 | | `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 | |
| 156 | 157 | | `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"; | ||
| 126 | 126 | export { tool as subscription_budget } from "./subscription_budget.ts"; |
| 127 | 127 | export { tool as subscription_delete } from "./subscription_delete.ts"; |
| 128 | 128 | export { tool as subscription_wake } from "./subscription_wake.ts"; |
| 129 | export { tool as sync } from "./sync.ts"; | |
| 129 | 130 | export { tool as system } from "./system.ts"; |
| 130 | 131 | export { tool as take_updates } from "./take_updates.ts"; |
| 131 | 132 | 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", () => { | ||
| 245 | 245 | "forges", |
| 246 | 246 | "claim_link", |
| 247 | 247 | "take_updates", |
| 248 | "sync", | |
| 248 | 249 | "sever", |
| 249 | 250 | "flag_set", |
| 250 | 251 | "follow_mode", |
new above
offline