Public: anyone can read this; the crew can change it.
MCP `migrate` brings a repository in by URL and, given `job`, reads that job's state and steps #4389
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4163-mcp-migrate-brings-a-repository-in-by-ur → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +217
−0
docs/MCP.md +1
| @@ -49,6 +49,7 @@ for a tunnel and a forge that sleeps. | ||
| 49 | 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 | |
| 50 | 50 | | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | no | |
| 51 | 51 | | `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 | |
| 52 | | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes | | |
| 52 | 53 | | `branches` | `{ repo, name?, action?, from? }`: without `name`, lists the branches; with `name` and `action` (`create` from `from`, or `delete`) it does what the branches page does: the default branch is never deleted, only a writer may act | yes | |
| 53 | 54 | | `commits` | `{ repo, ref?, limit? }`: a branch's recent commits, newest first (`limit` 1–100, default 30; refused outside that, where REST clamps), as `GET …/commits` answers | | |
| 54 | 55 | | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | | |
src/http/mcp.ts +59
| @@ -18,6 +18,8 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import { showProvenance } from "../actions/provenance.ts"; |
| 21 | import { migrateMessage, migrateRepo, migrationJobFor } from "../actions/migrate.ts"; | |
| 22 | import type { MirrorJob } from "../origins/jobs.ts"; | |
| 21 | 23 | import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts"; |
| 22 | 24 | import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts"; |
| 23 | 25 | import { upstreamPayload } from "../spawn/upstream.ts"; |
| @@ -1816,6 +1818,63 @@ const TOOLS: readonly Tool[] = [ | ||
| 1816 | 1818 | }); |
| 1817 | 1819 | }, |
| 1818 | 1820 | }, |
| 1821 | { | |
| 1822 | name: "migrate", | |
| 1823 | description: | |
| 1824 | "Bring a repository in by public `url` (optional `name`, `crew`); git history comes over, nothing reaches back. With `job`, its state.", | |
| 1825 | inputSchema: { | |
| 1826 | type: "object", | |
| 1827 | properties: { | |
| 1828 | url: { type: "string", maxLength: 2048 }, | |
| 1829 | name: { type: "string", maxLength: 100 }, | |
| 1830 | crew: { type: "string", maxLength: 39, description: "Agent to put on it" }, | |
| 1831 | job: { type: "string", maxLength: 64 }, | |
| 1832 | }, | |
| 1833 | }, | |
| 1834 | writes: true, | |
| 1835 | readsWithout: "url", | |
| 1836 | async run(ctx, user, args) { | |
| 1837 | const migration = (job: MirrorJob) => ({ | |
| 1838 | job: job.id, | |
| 1839 | state: job.stage, | |
| 1840 | detail: job.detail, | |
| 1841 | url: | |
| 1842 | job.repo === undefined || job.stage === "failed" | |
| 1843 | ? null | |
| 1844 | : `${ctx.url.origin}/${job.repo.ownerName}/${job.repo.name}`, | |
| 1845 | error: job.error ?? null, | |
| 1846 | }); | |
| 1847 | const url = args["url"]; | |
| 1848 | if (url === undefined) { | |
| 1849 | const id = str(args["job"]); | |
| 1850 | if (id === "") return bad("invalid", "send `url` to migrate, or `job` to read one"); | |
| 1851 | const found = migrationJobFor(user, id); | |
| 1852 | return found.ok ? good(migration(found.value)) : bad("not_found", "no such migration"); | |
| 1853 | } | |
| 1854 | if (typeof url !== "string") return bad("invalid", "`url` is a string"); | |
| 1855 | const name = args["name"]; | |
| 1856 | const crew = args["crew"]; | |
| 1857 | if (name !== undefined && typeof name !== "string") return bad("invalid", "`name` is a string"); | |
| 1858 | if (crew !== undefined && typeof crew !== "string") return bad("invalid", "`crew` is a string"); | |
| 1859 | const started = await migrateRepo(ctx.app, user, { | |
| 1860 | source: { url }, | |
| 1861 | ...(name === undefined ? {} : { name }), | |
| 1862 | ...(crew === undefined ? {} : { crew: { agent: crew, autonomous: false } }), | |
| 1863 | }); | |
| 1864 | if (!started.ok) | |
| 1865 | return started.error === "unauthorized" | |
| 1866 | ? bad("unauthorized", "sign in to migrate") | |
| 1867 | : started.error === "repository_limit" | |
| 1868 | ? bad("repository_limit", repositoryLimitMessage(ctx.app, user)) | |
| 1869 | : started.error === "storage_limit" | |
| 1870 | ? bad("storage_limit", storageLimitMessage(ctx.app, user.id)) | |
| 1871 | : bad(started.error, migrateMessage(started.error)); | |
| 1872 | return good({ | |
| 1873 | ...migration(started.value.job), | |
| 1874 | url: `${ctx.url.origin}/${started.value.repo.ownerName}/${started.value.repo.name}`, | |
| 1875 | }); | |
| 1876 | }, | |
| 1877 | }, | |
| 1819 | 1878 | { |
| 1820 | 1879 | name: "events", |
| 1821 | 1880 | description: "A repository's events, paged.", |
test/mcp-migrate.test.ts added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | /** | |
| 2 | * The MCP `migrate` tool: `url` starts the same migration `POST | |
| 3 | * /api/v1/migrate` does and answers {job, state, url}; `job` alone reads it. | |
| 4 | * A private address is refused as the route refuses it. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { settled } from "../src/origins/jobs.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir = ""; | |
| 21 | let open: App; // may clone from loopback: the fixture lives on it | |
| 22 | let shut: App; // the default: private addresses are refused | |
| 23 | let openServer: Servers; | |
| 24 | let shutServer: Servers; | |
| 25 | const tokens = new Map<string, string>(); | |
| 26 | let aliceId = ""; | |
| 27 | ||
| 28 | async function setup(app: App, username = "alice", adminIfFirst = true) { | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", | |
| 31 | trusted: true, | |
| 32 | username, | |
| 33 | email: `${username}@forge.local`, | |
| 34 | password: "a-good-passphrase", | |
| 35 | adminIfFirst, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | return { user: made.value, token: createToken(app.db, made.value.id, "rw", "rw").token }; | |
| 39 | } | |
| 40 | ||
| 41 | async function migrate( | |
| 42 | servers: Servers, | |
| 43 | token: string, | |
| 44 | args: unknown, | |
| 45 | ): Promise<{ error?: string; value?: any }> { | |
| 46 | const res = await fetch(`${servers.url}/mcp`, { | |
| 47 | method: "POST", | |
| 48 | headers: { | |
| 49 | "content-type": "application/json", | |
| 50 | accept: "application/json, text/event-stream", | |
| 51 | authorization: `Bearer ${token}`, | |
| 52 | }, | |
| 53 | body: JSON.stringify({ | |
| 54 | jsonrpc: "2.0", | |
| 55 | id: 1, | |
| 56 | method: "tools/call", | |
| 57 | params: { name: "migrate", arguments: args }, | |
| 58 | }), | |
| 59 | }); | |
| 60 | const body = (await res.json()) as any; | |
| 61 | const text: string = body.result.content[0].text; | |
| 62 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 63 | } | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dir = await mkdtemp(join(tmpdir(), "forge-mcp-migrate-")); | |
| 67 | open = createApp({ dataDir: join(dir, "open"), port: 0, migrateFromPrivateHosts: true }); | |
| 68 | openServer = await serve(open); | |
| 69 | const alice = await setup(open); | |
| 70 | tokens.set("open", alice.token); | |
| 71 | aliceId = alice.user.id; | |
| 72 | shut = createApp({ dataDir: join(dir, "shut"), port: 0 }); | |
| 73 | shutServer = await serve(shut); | |
| 74 | await setup(shut); // the first account is an admin, who may reach private hosts | |
| 75 | tokens.set("shut", (await setup(shut, "bob", false)).token); | |
| 76 | ||
| 77 | const made = await createRepo(open, alice.user, { name: "fixture", visibility: "public" }); | |
| 78 | if (!made.ok) throw new Error(String(made.error)); | |
| 79 | const c = await commitToBranch(open, alice.user, made.value, { | |
| 80 | branch: "main", | |
| 81 | message: "Start", | |
| 82 | files: [{ path: "README.md", content: "hello\n" }], | |
| 83 | }); | |
| 84 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 85 | }, 30_000); | |
| 86 | ||
| 87 | afterAll(async () => { | |
| 88 | openServer.stop(); | |
| 89 | shutServer.stop(); | |
| 90 | open.close(); | |
| 91 | shut.close(); | |
| 92 | await rm(dir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a url migrates a fixture, and the job reads until it is done", async () => { | |
| 96 | const tok = tokens.get("open")!; | |
| 97 | const started = await migrate(openServer, tok, { | |
| 98 | url: `http://127.0.0.1:${openServer.port}/alice/fixture.git`, | |
| 99 | name: "copy", | |
| 100 | }); | |
| 101 | expect(started.error).toBeUndefined(); | |
| 102 | expect(started.value.url).toBe(`${openServer.url}/alice/copy`); | |
| 103 | expect(typeof started.value.job).toBe("string"); | |
| 104 | await settled(started.value.job); | |
| 105 | const read = await migrate(openServer, tok, { job: started.value.job }); | |
| 106 | expect(read.value.state).toBe("done"); | |
| 107 | expect(read.value.url).toBe(`${openServer.url}/alice/copy`); | |
| 108 | expect(read.value.error).toBeNull(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a job nobody started is not found, and no argument is told what to send", async () => { | |
| 112 | const tok = tokens.get("open")!; | |
| 113 | expect((await migrate(openServer, tok, { job: "nope" })).error).toContain("no such migration"); | |
| 114 | expect((await migrate(openServer, tok, {})).error).toContain("`url`"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a private-address url is refused as the route refuses it", async () => { | |
| 118 | const tok = tokens.get("shut")!; | |
| 119 | const res = await migrate(shutServer, tok, { | |
| 120 | url: `http://127.0.0.1:${shutServer.port}/alice/fixture.git`, | |
| 121 | }); | |
| 122 | expect(res).toEqual({ error: expect.stringContaining("private") }); | |
| 123 | const route = await fetch(`${shutServer.url}/api/v1/migrate`, { | |
| 124 | method: "POST", | |
| 125 | headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, | |
| 126 | body: JSON.stringify({ source: { url: `http://127.0.0.1:${shutServer.port}/alice/fixture.git` } }), | |
| 127 | }); | |
| 128 | expect(route.status).toBe(422); | |
| 129 | expect(((await route.json()) as any).error).toBe("private_host"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a read-only token reads a job but cannot start a migration", async () => { | |
| 133 | const rw = tokens.get("open")!; | |
| 134 | const ro = createToken(open.db, aliceId, "ro", "ro").token; | |
| 135 | const url = `http://127.0.0.1:${openServer.port}/alice/fixture.git`; | |
| 136 | const refused = await migrate(openServer, ro, { url, name: "sneaky" }); | |
| 137 | expect(refused.error).toContain("this token may read, not act"); | |
| 138 | const route = await fetch(`${openServer.url}/api/v1/repos/alice/sneaky`, { | |
| 139 | headers: { authorization: `Bearer ${rw}` }, | |
| 140 | }); | |
| 141 | expect(route.status).toBe(404); | |
| 142 | const started = await migrate(openServer, rw, { url, name: "readable" }); | |
| 143 | await settled(started.value.job); | |
| 144 | const read = await migrate(openServer, ro, { job: started.value.job }); | |
| 145 | expect(read.error).toBeUndefined(); | |
| 146 | expect(read.value.state).toBe("done"); | |
| 147 | }); | |
| 148 | ||
| 149 | test("an unknown crew agent is refused", async () => { | |
| 150 | const res = await migrate(openServer, tokens.get("open")!, { | |
| 151 | url: `http://127.0.0.1:${openServer.port}/alice/fixture.git`, | |
| 152 | name: "crewed", | |
| 153 | crew: "no-such-agent-here", | |
| 154 | }); | |
| 155 | expect(res.error).toBeDefined(); | |
Nit: |
||
| 156 | }); |
test/mcp.test.ts +1
| @@ -207,6 +207,7 @@ describe("the forge over MCP", () => { | ||
| 207 | 207 | "decisions_by", |
| 208 | 208 | "archive", |
| 209 | 209 | "trail", |
| 210 | "migrate", | |
| 210 | 211 | "events", |
| 211 | 212 | "branches", |
| 212 | 213 | "commits", |
new above
offline
This line and
writes: trueare the whole scope gate for a call that creates a repository and clones from the network. Add a test with anrotoken:urlis refused withforbidden: this token may read, not actand no repository exists afterwards, andjobstill reads.