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

Merged opened this

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

Merged

scout/forge:scout/4813-mcp-origin-apps-list-add-and-remove-the → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/4813-mcp-origin-apps-list-add-and-remove-the was merged into main as b687747.

Review

Sign in to review this change.
5 files changed +160 −2
docs/MCP.md +1
@@ -61,6 +61,7 @@ for a tunnel and a forge that sleeps.
6161 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
6262 | `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` | |
6363 | `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 |
6465 | `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 |
6566 | `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 | |
6667 | `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(
63296329 return vouched ? requestUrl.origin : "";
63306330 }
63316331
6332 function connectMessage(code: string): string {
6332 export function connectMessage(code: string): string {
63336333 if (code === "unknown_host") return "that does not look like a forge host";
63346334 if (code === "private_host")
63356335 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";
217217 import { running } from "../kernel/version.ts";
218218 import { parentForgeFor } from "../core/parent_releases.ts";
219219 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";
220222 import { resourceMetadataUrl } from "./oauth.ts";
221223 import { body, boundedBody, json } from "./respond.ts";
222224 import { sizesView } from "../spawn/sizes.ts";
@@ -2503,6 +2505,59 @@ const TOOLS: readonly Tool[] = [
25032505 );
25042506 },
25052507 },
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 },

Nit, non-blocking: every action here needs an admin-scope token, so needsAdminScope: true would hide the tool from rw tokens in tools/list (one rule for manifest and dispatch, as visible says) and would also make the inline scope check redundant. It's fine as is, because the inline check refuses correctly.

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 kind: "gitlab" → an error containing kind, so removing this check fails the suite.

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 connectMessage(saved.error), so use bad(saved.error, connectMessage(saved.error)) to match the API's error, as the issue asks.

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 },
25062561 {
25072562 name: "front_page",
25082563 description:
@@ -3360,7 +3415,7 @@ const TOOLS: readonly Tool[] = [
33603415 {
33613416 name: "flow",
33623417 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.",
33643419 inputSchema: { type: "object", properties: {} },
33653420 writes: false,
33663421 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 kind, e.g. expect((await tool(alice, { action: "add", kind: "gitlab", clientId: "x" })).error).toContain("kind").

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", () => {
229229 "feed",
230230 "feed_chips",
231231 "make_it_yours",
232 "origin_apps",
232233 "front_page",
233234 "map",
234235 "milestones",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs