Public: anyone can read this; the crew can change it.
MCP `origin_apps`: list, add and remove the caller's origin OAuth apps, never reading a secret back #4835
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4813-mcp-origin-apps-list-add-and-remove-the → main 2 commits fork 4 threads · 4 unresolved
docs/MCP.md +1
| @@ -61,6 +61,7 @@ for a tunnel and a forge that sleeps. | ||
| 61 | 61 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | |
| 62 | 62 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | | |
| 63 | 63 | | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes | |
| 64 | | `origin_apps` | `{ action, id?, host?, kind?, clientId?, clientSecret? }`: `list` your OAuth apps for connecting origins, `add` one (a `host`, or `kind` github), or `remove` one by `id`; the API's rules and errors, an admin-scope token; a secret is never read back, only `hasSecret` | yes | | |
| 64 | 65 | | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes | |
| 65 | 66 | | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | | |
| 66 | 67 | | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | | |
src/http/api.ts +1 −1
| @@ -6329,7 +6329,7 @@ export function forgeBaseUrl( | ||
| 6329 | 6329 | return vouched ? requestUrl.origin : ""; |
| 6330 | 6330 | } |
| 6331 | 6331 | |
| 6332 | function connectMessage(code: string): string { | |
| 6332 | export function connectMessage(code: string): string { | |
| 6333 | 6333 | if (code === "unknown_host") return "that does not look like a forge host"; |
| 6334 | 6334 | if (code === "private_host") |
| 6335 | 6335 | return "that address is inside a network, not on the internet"; |
src/http/mcp.ts +56 −1
| @@ -217,6 +217,8 @@ import { flowFor, systemFor, welcome } from "../system/telemetry.ts"; | ||
| 217 | 217 | import { running } from "../kernel/version.ts"; |
| 218 | 218 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| 219 | 219 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 220 | import { deleteOriginApp, listOriginApps, registerOriginApp } from "../actions/origins.ts"; | |
| 221 | import { connectMessage } from "./api.ts"; | |
| 220 | 222 | import { resourceMetadataUrl } from "./oauth.ts"; |
| 221 | 223 | import { body, boundedBody, json } from "./respond.ts"; |
| 222 | 224 | import { sizesView } from "../spawn/sizes.ts"; |
| @@ -2503,6 +2505,59 @@ const TOOLS: readonly Tool[] = [ | ||
| 2503 | 2505 | ); |
| 2504 | 2506 | }, |
| 2505 | 2507 | }, |
| 2508 | { | |
| 2509 | name: "origin_apps", | |
| 2510 | description: | |
| 2511 | "Your origin OAuth apps: list, add, remove.", | |
| 2512 | inputSchema: { | |
| 2513 | type: "object", | |
| 2514 | properties: { | |
| 2515 | action: { type: "string" }, | |
| 2516 | id: { type: "string" }, | |
| 2517 | host: { type: "string" }, | |
| 2518 | kind: { type: "string" }, | |
| 2519 | clientId: { type: "string" }, | |
| 2520 | clientSecret: { type: "string" }, | |
| 2521 | }, | |
| 2522 | }, | |
| 2523 | writes: true, | |
| 2524 | async run(ctx, user, args) { | |
| 2525 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 2526 | return bad("forbidden", "origin apps need an admin-scope token"); | |
| 2527 | const action = args["action"]; | |
| 2528 | // Never the secret: an app is shown as `hasSecret`, and errors are codes. | |
| 2529 | const shown = (a: ReturnType<typeof listOriginApps>[number]) => { | |
| 2530 | const { userId: _owner, ...rest } = a; | |
| 2531 | return rest; | |
| 2532 | }; | |
| 2533 | if (action === "list") | |
| 2534 | return good({ items: listOriginApps(ctx.app, user).map(shown), next_cursor: null }); | |
| 2535 | if (action === "add") { | |
| 2536 | const kind = str(args["kind"]); | |
| 2537 | const clientId = str(args["clientId"]); | |
| 2538 | if (kind !== "" && kind !== "github" && kind !== "forgejo" && kind !== "gitea") | |
outdated
No test reaches this refusal. Add one in test/mcp-origin-apps.test.ts, for example |
||
| 2539 | return bad("invalid", "kind is github, forgejo or gitea"); | |
| 2540 | if (clientId === "") return bad("invalid", "`clientId` is required"); | |
| 2541 | const host = str(args["host"]) || kind; | |
| 2542 | if (host === "") return bad("invalid", "`host` or `kind` is required"); | |
| 2543 | const secret = args["clientSecret"]; | |
| 2544 | const saved = await registerOriginApp(ctx.app, user, { | |
| 2545 | host, | |
| 2546 | ...(kind === "" ? {} : { kind }), | |
| 2547 | clientId, | |
| 2548 | ...(typeof secret === "string" ? { clientSecret: secret } : {}), | |
| 2549 | }); | |
outdated
The message here is the code again. The API answers this same failure with |
||
| 2550 | return saved.ok ? good(shown(saved.value)) : bad(saved.error, connectMessage(saved.error)); | |
| 2551 | } | |
| 2552 | if (action === "remove") { | |
| 2553 | const id = str(args["id"]); | |
| 2554 | if (id === "") return bad("invalid", "`id` is required"); | |
| 2555 | const removed = deleteOriginApp(ctx.app, user, id); | |
| 2556 | return removed.ok ? good({}) : bad("not_found", "no such origin app"); | |
| 2557 | } | |
| 2558 | return bad("invalid", "`action` is list, add or remove"); | |
| 2559 | }, | |
| 2560 | }, | |
| 2506 | 2561 | { |
| 2507 | 2562 | name: "front_page", |
| 2508 | 2563 | description: |
| @@ -3360,7 +3415,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3360 | 3415 | { |
| 3361 | 3416 | name: "flow", |
| 3362 | 3417 | description: |
| 3363 | "The forge's pipeline (forge admin): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, merge `queue`, `reviewing` (reviews in flight per reviewer).", | |
| 3418 | "The forge's pipeline (forge admin): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, merge `queue`, `reviewing` per reviewer.", | |
| 3364 | 3419 | inputSchema: { type: "object", properties: {} }, |
| 3365 | 3420 | writes: false, |
| 3366 | 3421 | run(ctx, user) { |
test/mcp-origin-apps.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The `origin_apps` tool is the API's /user/origin-apps routes over MCP: the | |
| 3 | * caller's own apps only, and a client secret never read back. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, 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 { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | describe("MCP origin_apps", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let alice: string; | |
| 19 | let aliceRw: string; | |
| 20 | let bob: string; | |
| 21 | let id = 1; | |
| 22 | const SECRET = "gh-client-secret-xyz"; | |
| 23 | ||
| 24 | const tool = async (token: string, args: unknown) => { | |
| 25 | const res = await fetch(`${servers.url}/mcp`, { | |
| 26 | method: "POST", | |
| 27 | headers: { | |
| 28 | "content-type": "application/json", | |
| 29 | accept: "application/json, text/event-stream", | |
| 30 | authorization: `Bearer ${token}`, | |
| 31 | }, | |
| 32 | body: JSON.stringify({ | |
| 33 | jsonrpc: "2.0", | |
| 34 | id: id++, | |
| 35 | method: "tools/call", | |
| 36 | params: { name: "origin_apps", arguments: args }, | |
| 37 | }), | |
| 38 | }); | |
| 39 | const body: any = await res.json(); | |
| 40 | const text: string = body.result.content[0].text; | |
| 41 | return body.result.isError === true | |
| 42 | ? { error: text } | |
| 43 | : { value: JSON.parse(text), text }; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-oapps-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 51 | username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 52 | if (!a.ok) throw new Error(a.error); | |
| 53 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 54 | username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 55 | if (!b.ok) throw new Error(b.error); | |
| 56 | alice = createToken(app.db, a.value.id, "admin", "admin").token; | |
| 57 | aliceRw = createToken(app.db, a.value.id, "rw", "rw").token; | |
| 58 | bob = createToken(app.db, b.value.id, "admin", "admin").token; | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | servers.stop(); | |
| 63 | app.close(); | |
| 64 | await rm(dataDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a person adds, lists and removes an app, and no answer carries the secret", async () => { | |
| 68 | const added = await tool(alice, { action: "add", kind: "github", clientId: "Iv1.abc", clientSecret: SECRET }); | |
| 69 | expect(added.value.hasSecret).toBe(true); | |
| 70 | expect(added.value.clientId).toBe("Iv1.abc"); | |
| 71 | expect(added.text).not.toContain(SECRET); | |
| 72 | const listed = await tool(alice, { action: "list" }); | |
| 73 | expect(listed.value.items.map((i: any) => i.id)).toEqual([added.value.id]); | |
| 74 | expect(listed.text).not.toContain(SECRET); | |
| 75 | expect((await tool(alice, { action: "remove", id: added.value.id })).value).toEqual({}); | |
| 76 | expect((await tool(alice, { action: "list" })).value.items).toEqual([]); | |
| 77 | }); | |
| 78 | ||
| 79 | test("another person neither sees nor removes it", async () => { | |
| 80 | const added = await tool(alice, { action: "add", kind: "github", clientId: "Iv1.abc", clientSecret: SECRET }); | |
| 81 | expect((await tool(bob, { action: "list" })).value.items).toEqual([]); | |
| 82 | const gone = await tool(bob, { action: "remove", id: added.value.id }); | |
| 83 | expect(gone.error).toContain("not_found"); | |
| 84 | expect(gone.error).not.toContain(SECRET); | |
| 85 | expect((await tool(alice, { action: "list" })).value.items).toHaveLength(1); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a missing field, a bad action or a lesser token is refused, never echoing the secret", async () => { | |
| 89 | const noId = await tool(alice, { action: "add", kind: "github", clientSecret: SECRET }); | |
| 90 | expect(noId.error).toContain("clientId"); | |
| 91 | expect(noId.error).not.toContain(SECRET); | |
| 92 | expect((await tool(alice, { action: "add", clientId: "x", clientSecret: SECRET })).error).toContain("host"); | |
| 93 | expect((await tool(alice, { action: "remove" })).error).toContain("id"); | |
| 94 | expect((await tool(alice, { action: "add", kind: "gitlab", clientId: "x" })).error).toContain("kind"); | |
outdated
Add a line beside this one for an unknown |
||
| 95 | expect((await tool(alice, { action: "burn" })).error).toContain("list, add or remove"); | |
| 96 | const unknown = await tool(alice, { action: "add", host: "not a host", clientId: "x", clientSecret: SECRET }); | |
| 97 | expect(unknown.error).toContain("does not look like a forge host"); | |
| 98 | expect(unknown.error).not.toContain(SECRET); | |
| 99 | expect((await tool(aliceRw, { action: "list" })).error).toContain("admin-scope"); | |
| 100 | }); | |
| 101 | }); |
test/mcp.test.ts +1
| @@ -229,6 +229,7 @@ describe("the forge over MCP", () => { | ||
| 229 | 229 | "feed", |
| 230 | 230 | "feed_chips", |
| 231 | 231 | "make_it_yours", |
| 232 | "origin_apps", | |
| 232 | 233 | "front_page", |
| 233 | 234 | "map", |
| 234 | 235 | "milestones", |
Nit, non-blocking: every action here needs an admin-scope token, so
needsAdminScope: truewould hide the tool fromrwtokens intools/list(one rule for manifest and dispatch, asvisiblesays) and would also make the inline scope check redundant. It's fine as is, because the inline check refuses correctly.