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

Merged opened this

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

Merged

scout/forge:scout/4163-mcp-migrate-brings-a-repository-in-by-ur → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/4163-mcp-migrate-brings-a-repository-in-by-ur was merged into main as fc402bf.

Review

Sign in to review this change.
4 files changed +217 −0
docs/MCP.md +1
@@ -49,6 +49,7 @@ for a tunnel and a forge that sleeps.
4949 | `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 |
5050 | `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 |
5151 | `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 |
5253 | `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 |
5354 | `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 | |
5455 | `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 @@
1818 */
1919
2020 import { showProvenance } from "../actions/provenance.ts";
21 import { migrateMessage, migrateRepo, migrationJobFor } from "../actions/migrate.ts";
22 import type { MirrorJob } from "../origins/jobs.ts";
2123 import { EVENTS_LIMIT_MAX, eventsLimit, eventsPage, validEventsCursor } from "../core/events_page.ts";
2224 import { getProvenance, provenanceFacts, trailFacts } from "../core/provenance.ts";
2325 import { upstreamPayload } from "../spawn/upstream.ts";
@@ -1816,6 +1818,63 @@ const TOOLS: readonly Tool[] = [
18161818 });
18171819 },
18181820 },
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",

This line and writes: true are the whole scope gate for a call that creates a repository and clones from the network. Add a test with an ro token: url is refused with forbidden: this token may read, not act and no repository exists afterwards, and job still reads.

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 },
18191878 {
18201879 name: "events",
18211880 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: expect(res.error).toContain("not on this forge") would pin this to no_such_agent and not to whatever error happens to come back first.

156 });
test/mcp.test.ts +1
@@ -207,6 +207,7 @@ describe("the forge over MCP", () => {
207207 "decisions_by",
208208 "archive",
209209 "trail",
210 "migrate",
210211 "events",
211212 "branches",
212213 "commits",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs