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

MCP `upstream`: read and set the repository a fork follows, and check it now #4918

Merged opened this

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

Merged

scout/forge:scout/4902-mcp-upstream-read-and-set-the-repository → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/4902-mcp-upstream-read-and-set-the-repository was merged into main as 7c3627a.

Review

Sign in to review this change.
4 files changed +147 −21
docs/MCP.md +1
@@ -60,6 +60,7 @@ for a tunnel and a forge that sleeps.
6060 | `spawn` | `{ name, apps?, crew?, dryRun? }`: a child forge, planned first (`dryRun` is true unless `false`); an admin's, and an `ro` token may plan but not make | yes |
6161 | `agent` | `{ action, username, … }`: `create` a crew agent, `update` its `model`, `brief` or `budgetUsd`, or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents` | yes |
6262 | `claim_link` | `{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's | yes |
63 | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes |
6364 | `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 |
6465 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
6566 | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | |
src/http/mcp.ts +55 −21
@@ -50,6 +50,8 @@ import { changeSignup, readSignup } from "../actions/signup.ts";
5050 import { SIGNUP_MODES } from "../settings/signup.ts";
5151 import { QUOTA_KEYS } from "../settings/quotas.ts";
5252 import { LIMIT_KEYS } from "../settings/limits.ts";
53 import { checkUpstreamNow, FOLLOW_REFUSALS, followJson, setFollowUpstream } from "../actions/upstream_follow.ts";
54 import { getFollow } from "../core/upstream_follow.ts";
5355 import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts";
5456 import { relationOf } from "../actions/forks.ts";
5557 import { chainOfRepo, FOLLOW_MODES } from "../spawn/chain.ts";
@@ -757,7 +759,7 @@ const TOOLS: readonly Tool[] = [
757759 {
758760 name: "issue",
759761 description:
760 "Read one issue with its comments, `blockedBy` and `blocks`. A crew agent sees `untrusted: true` on text by non-writers.",
762 "One issue with comments, `blockedBy` and `blocks`. A crew agent sees `untrusted: true` on text by non-writers.",
761763 inputSchema: {
762764 type: "object",
763765 properties: {
@@ -1298,7 +1300,7 @@ const TOOLS: readonly Tool[] = [
12981300 {
12991301 name: "issue_teams",
13001302 description:
1301 "Name the crew teams (design, data-science, security, platform) on an issue: at most two; each reviews the closing pull.",
1303 "Name crew teams (design, data-science, security, platform) on an issue: at most two; each reviews the closing pull.",
13021304 inputSchema: {
13031305 type: "object",
13041306 properties: {
@@ -1827,7 +1829,7 @@ const TOOLS: readonly Tool[] = [
18271829 {
18281830 name: "decisions_by",
18291831 description:
1830 "The standing rule for a repository's parked hand-over cards: `me`, `decider`, or `decider_tell`. Without `by`, reads it.",
1832 "The standing rule for a repository's parked hand-over cards: `me`, `decider`, `decider_tell`. Without `by`, reads it.",
18311833 inputSchema: {
18321834 type: "object",
18331835 properties: {
@@ -2355,7 +2357,7 @@ const TOOLS: readonly Tool[] = [
23552357 {
23562358 name: "claim_link",
23572359 description:
2358 "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one then stops at once. Nothing changes without it.",
2360 "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one stops. Nothing changes without.",
23592361 inputSchema: {
23602362 type: "object",
23612363 properties: {
@@ -2468,6 +2470,49 @@ const TOOLS: readonly Tool[] = [
24682470 return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message);
24692471 },
24702472 },
2473 {
2474 name: "upstream",
2475 description:
2476 "Read, set (`enabled`) or `check` the upstream a repository follows.",
2477 inputSchema: {
2478 type: "object",
2479 properties: {
2480 repo: REPO_ARG,
2481 enabled: { type: "boolean" },
2482 url: { type: "string" },
2483 branch: { type: "string" },
2484 check: { type: "boolean" },
2485 },
2486 required: ["repo"],
2487 },
2488 writes: true,
2489 readsWithout: "enabled",
2490 async run(ctx, user, args) {
2491 const repo = repoArg(ctx, user, args);
2492 if (isAnswer(repo)) return repo;
2493 const gone = bad("not_found", FOLLOW_REFUSALS.not_found);
2494 const refuse = (e: string) => (e === "not_found" || e === "unauthorized" || e === "forbidden" ? gone : bad(e, FOLLOW_REFUSALS[e as keyof typeof FOLLOW_REFUSALS]));
2495 const acts = args["check"] === true || args["enabled"] !== undefined;
2496 if (!acts) {
2497 if (!canAdmin(ctx.app.db, repo, user)) return gone;
2498 const follow = getFollow(ctx.app.db, repo.id);
2499 return good({ following: follow === undefined ? null : followJson(follow) });
2500 }
2501 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
2502 return bad("forbidden", "following an upstream needs an admin token");
2503 if (args["check"] === true) {
2504 if (args["enabled"] !== undefined) return bad("invalid", "check or set, not both");
outdated

Minor: when check: true and enabled come together, the check runs and the set is silently ignored. Refuse the pair (bad("invalid", "check or set, not both")) or note in the description that check wins.

2505 const checked = await checkUpstreamNow(ctx.app, user, repo);
2506 return checked.ok ? good(checked.value) : refuse(checked.error);
2507 }
2508 const { enabled, url, branch } = args;
2509 if (typeof enabled !== "boolean") return bad("bad_enabled", "enabled is true or false");
2510 if ((url !== undefined && typeof url !== "string") || (branch !== undefined && typeof branch !== "string"))
2511 return bad("invalid", "url and branch are text");
2512 const saved = setFollowUpstream(ctx.app, user, repo, { url: str(url), branch: str(branch), enabled });
2513 return saved.ok ? good({ following: followJson(saved.value) }) : refuse(saved.error);
2514 },
2515 },
24712516 {
24722517 name: "propose_to_parent_forge",
24732518 description:
@@ -2922,7 +2967,7 @@ const TOOLS: readonly Tool[] = [
29222967 {
29232968 name: "front_page",
29242969 description:
2925 "The forge's front page as a stranger sees it. A site admin also gets the picks and sets them with `pinned`, `hidden`, `weights`.",
2970 "The front page as a stranger sees it. A site admin also gets the picks and sets them with `pinned`, `hidden`, `weights`.",
29262971 inputSchema: {
29272972 type: "object",
29282973 properties: {
@@ -3171,20 +3216,9 @@ const TOOLS: readonly Tool[] = [
31713216 minimum: 1,
31723217 },
31733218 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] },
3174 reason: {
3175 type: "string",
3176 description: "For close: why it is not being done, in one sentence.",
3177 },
3178 pillar: {
3179 type: "string",
3180 description:
3181 "For resume: the pillar to return it under.",
3182 },
3183 under: {
3184 type: "string",
3185 description:
3186 "For raise and lower: `top` or a milestone number; only when the limb sits in several places.",
3187 },
3219 reason: { type: "string" },
3220 pillar: { type: "string" },
3221 under: { type: "string" },
31883222 operation: {
31893223 type: "string",
31903224 },
@@ -3809,7 +3843,7 @@ const TOOLS: readonly Tool[] = [
38093843 {
38103844 name: "access",
38113845 description:
3812 "What outside agents tried: the redacted log of non-browser requests to public paths, newest first, max 500. Forge admin.",
3846 "What outside agents tried: the redacted log of non-browser requests to public paths, newest first. Forge admin.",
38133847 inputSchema: {
38143848 type: "object",
38153849 properties: {
@@ -4126,7 +4160,7 @@ const TOOLS: readonly Tool[] = [
41264160 {
41274161 name: "quotas",
41284162 description:
4129 "Per-account repository and app quotas (number or null; not enforced yet). With `key` and `value` (\"\" clears), plans.",
4163 "Per-account repository and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears), plans.",
41304164 inputSchema: {
41314165 type: "object",
41324166 properties: {
test/mcp-upstream.test.ts +90
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { CHECK_REFUSALS } from "../src/actions/upstream_follow.ts";
1112 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1213 import { mergePull, openPull } from "../src/actions/pulls.ts";
1314 import { createApp, type App } from "../src/app.ts";
@@ -248,3 +249,92 @@ describe("MCP proposes to the parent forge and lists proposals", () => {
248249 expect(listed[0].says).toContain("parent unreachable");
249250 });
250251 });
252
253 describe("MCP upstream reads, sets and checks what a fork follows", () => {
254 let dir = "";
255 let app: App;
256 let servers: Servers;
257 let admin = "";
258 let adminRo = "";
259 let reader = "";
260 let stranger = "";
261 let id = 1;
262 const URL_ = "https://example.com/up/stream.git";
263
264 const call = async (token: string, args: unknown) => {
265 const res = await fetch(`${servers.url}/mcp`, {
266 method: "POST",
267 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
268 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "upstream", arguments: args } }),
269 });
270 const j = (await res.json()) as any;
271 return { error: j.result.isError === true, text: String(j.result.content[0].text) };
272 };
273
274 beforeAll(async () => {
275 dir = await mkdtemp(join(tmpdir(), "forge-mcp-upstream-"));
276 app = createApp({ dataDir: dir, port: 0 });
277 const make = async (username: string, extra: object = {}) => {
278 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: PASSWORD, ...extra });
279 if (!made.ok) throw new Error(JSON.stringify(made.error));
280 return made.value;
281 };
282 const alice = await make("alice", { adminIfFirst: true });
283 const bob = await make("bob");
284 const carol = await make("carol");
285 for (const [name, visibility] of [["open", "public"], ["hidden", "private"]] as const) {
286 const made = await createRepo(app, alice, { name, visibility });
287 if (!made.ok) throw new Error(JSON.stringify(made.error));
288 }
289 admin = createToken(app.db, alice.id, "admin", "admin").token;
290 adminRo = createToken(app.db, alice.id, "ro", "ro").token;
291 reader = createToken(app.db, bob.id, "admin", "admin").token;
292 stranger = createToken(app.db, carol.id, "admin", "admin").token;
293 servers = await serve(app);
294 }, 30_000);
295
296 afterAll(async () => {
297 servers?.stop();
298 app?.close();
299 await rm(dir, { recursive: true, force: true });
300 });
301
302 test("an admin sets an upstream, reads it back and checks it", async () => {
303 expect((await call(admin, { repo: "alice/open" })).text).toBe('{"following":null}');
304 const set = await call(admin, { repo: "alice/open", url: URL_, branch: "main", enabled: true });
305 expect(set.error).toBe(false);
306 expect(JSON.parse(set.text).following).toMatchObject({ url: URL_, branch: "main", enabled: true });
307 expect(JSON.parse((await call(admin, { repo: "alice/open" })).text).following.url).toBe(URL_);
308 // Unreachable from here: the check answers, it does not throw.
309 const checked = await call(admin, { repo: "alice/open", check: true });
outdated

Non-blocking: this check would also pass if the tool refused the check, for example with forbidden or not_found. In a sandbox with no network the answer is unreadable, so asserting checked.text starts with unreadable (or is a {tip, behind, outcome} object) would make the test fail if the admin path broke.

outdated

This passes whatever the tool answers. A fall-through to the read ({"following":…}), a not_found and a forbidden are all non-empty. Assert that the answer is a Checked (tip/behind/outcome) or a CHECK_REFUSALS code such as unreadable:, so the test fails if the check branch is removed or wrongly gated.

310 const refused = Object.keys(CHECK_REFUSALS).some((k) => checked.text.startsWith(`${k}:`) || checked.text.startsWith(k));

Nit: startsWith(k) already covers startsWith(${k}:). Keeping only the ${k}: form is also stricter, because it can't match a code that only shares a prefix.

311 expect(refused || "outcome" in JSON.parse(checked.text)).toBe(true);
312 expect(checked.text).not.toStartWith('{"following"');
313 expect(checked.text).not.toStartWith("not_found");
314 expect(checked.text).not.toStartWith("forbidden");
315 // The pair is refused, not half done.
316 expect((await call(admin, { repo: "alice/open", check: true, enabled: false })).text).toStartWith("invalid");
317 });
318
319 test("a bad address or flag is refused at the boundary", async () => {
320 expect((await call(admin, { repo: "alice/open", url: "file:///etc", enabled: true })).text).toStartWith("invalid_url");
321 expect((await call(admin, { repo: "alice/open", url: URL_, branch: "-x", enabled: true })).text).toStartWith("invalid_branch");
322 expect((await call(admin, { repo: "alice/open", url: URL_, enabled: "yes" })).error).toBe(true);
323 });
324
325 test("a read-only token reads but is refused set and check", async () => {
326 expect((await call(adminRo, { repo: "alice/open" })).error).toBe(false);
327 expect((await call(adminRo, { repo: "alice/open", enabled: false })).text).toStartWith("forbidden");
328 expect((await call(adminRo, { repo: "alice/open", check: true })).text).toStartWith("forbidden");
329 });
330
331 test("a reader is refused set, check and read; an upstream they cannot read is refused", async () => {
332 for (const args of [{ repo: "alice/open", url: "https://example.com/x.git", enabled: true }, { repo: "alice/open", check: true }, { repo: "alice/open" }]) {
333 const r = await call(reader, args);
334 expect([r.error, r.text.startsWith("not_found")]).toEqual([true, true]);
335 }
336 expect(JSON.parse((await call(admin, { repo: "alice/open" })).text).following.url).toBe(URL_);
337 const hidden = await call(stranger, { repo: "alice/hidden" });
338 expect([hidden.error, hidden.text.startsWith("not_found")]).toEqual([true, true]);
339 });
340 });
test/mcp.test.ts +1
@@ -222,6 +222,7 @@ describe("the forge over MCP", () => {
222222 "take_updates",
223223 "sever",
224224 "follow_mode",
225 "upstream",
225226 "propose_to_parent_forge",
226227 "forge_proposals",
227228 "converge",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs