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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4902-mcp-upstream-read-and-set-the-repository → main 4 commits fork 5 threads · 5 unresolved
docs/MCP.md +1
| @@ -60,6 +60,7 @@ for a tunnel and a forge that sleeps. | ||
| 60 | 60 | | `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 | |
| 61 | 61 | | `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 | |
| 62 | 62 | | `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 | | |
| 63 | 64 | | `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 | |
| 64 | 65 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 65 | 66 | | `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"; | ||
| 50 | 50 | import { SIGNUP_MODES } from "../settings/signup.ts"; |
| 51 | 51 | import { QUOTA_KEYS } from "../settings/quotas.ts"; |
| 52 | 52 | 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"; | |
| 53 | 55 | import { convergeWords, FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts"; |
| 54 | 56 | import { relationOf } from "../actions/forks.ts"; |
| 55 | 57 | import { chainOfRepo, FOLLOW_MODES } from "../spawn/chain.ts"; |
| @@ -757,7 +759,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 757 | 759 | { |
| 758 | 760 | name: "issue", |
| 759 | 761 | 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.", | |
| 761 | 763 | inputSchema: { |
| 762 | 764 | type: "object", |
| 763 | 765 | properties: { |
| @@ -1298,7 +1300,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1298 | 1300 | { |
| 1299 | 1301 | name: "issue_teams", |
| 1300 | 1302 | 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.", | |
| 1302 | 1304 | inputSchema: { |
| 1303 | 1305 | type: "object", |
| 1304 | 1306 | properties: { |
| @@ -1827,7 +1829,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1827 | 1829 | { |
| 1828 | 1830 | name: "decisions_by", |
| 1829 | 1831 | 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.", | |
| 1831 | 1833 | inputSchema: { |
| 1832 | 1834 | type: "object", |
| 1833 | 1835 | properties: { |
| @@ -2355,7 +2357,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2355 | 2357 | { |
| 2356 | 2358 | name: "claim_link", |
| 2357 | 2359 | 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.", | |
| 2359 | 2361 | inputSchema: { |
| 2360 | 2362 | type: "object", |
| 2361 | 2363 | properties: { |
| @@ -2468,6 +2470,49 @@ const TOOLS: readonly Tool[] = [ | ||
| 2468 | 2470 | return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message); |
| 2469 | 2471 | }, |
| 2470 | 2472 | }, |
| 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"); | |
| 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 | }, | |
| 2471 | 2516 | { |
| 2472 | 2517 | name: "propose_to_parent_forge", |
| 2473 | 2518 | description: |
| @@ -2922,7 +2967,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2922 | 2967 | { |
| 2923 | 2968 | name: "front_page", |
| 2924 | 2969 | 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`.", | |
| 2926 | 2971 | inputSchema: { |
| 2927 | 2972 | type: "object", |
| 2928 | 2973 | properties: { |
| @@ -3171,20 +3216,9 @@ const TOOLS: readonly Tool[] = [ | ||
| 3171 | 3216 | minimum: 1, |
| 3172 | 3217 | }, |
| 3173 | 3218 | 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" }, | |
| 3188 | 3222 | operation: { |
| 3189 | 3223 | type: "string", |
| 3190 | 3224 | }, |
| @@ -3809,7 +3843,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3809 | 3843 | { |
| 3810 | 3844 | name: "access", |
| 3811 | 3845 | 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.", | |
| 3813 | 3847 | inputSchema: { |
| 3814 | 3848 | type: "object", |
| 3815 | 3849 | properties: { |
| @@ -4126,7 +4160,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4126 | 4160 | { |
| 4127 | 4161 | name: "quotas", |
| 4128 | 4162 | 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.", | |
| 4130 | 4164 | inputSchema: { |
| 4131 | 4165 | type: "object", |
| 4132 | 4166 | properties: { |
test/mcp-upstream.test.ts +90
| @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { CHECK_REFUSALS } from "../src/actions/upstream_follow.ts"; | |
| 11 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 12 | 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| 13 | 14 | import { createApp, type App } from "../src/app.ts"; |
| @@ -248,3 +249,92 @@ describe("MCP proposes to the parent forge and lists proposals", () => { | ||
| 248 | 249 | expect(listed[0].says).toContain("parent unreachable"); |
| 249 | 250 | }); |
| 250 | 251 | }); |
| 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
outdated
This passes whatever the tool answers. A fall-through to the read ( |
||
| 310 | const refused = Object.keys(CHECK_REFUSALS).some((k) => checked.text.startsWith(`${k}:`) || checked.text.startsWith(k)); | |
Nit: |
||
| 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", () => { | ||
| 222 | 222 | "take_updates", |
| 223 | 223 | "sever", |
| 224 | 224 | "follow_mode", |
| 225 | "upstream", | |
| 225 | 226 | "propose_to_parent_forge", |
| 226 | 227 | "forge_proposals", |
| 227 | 228 | "converge", |
Minor: when
check: trueandenabledcome 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 thatcheckwins.