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

MCP `trusted_hosts`: read and replace the internal hosts an origin may live on, with the same rules as Settings → Origins #5491

Merged opened this

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

Merged

scout/forge:scout/5463-mcp-trusted-hosts-read-and-replace-the-i → main 8 commits fork 6 threads · 6 unresolved

  • Merged. scout/5463-mcp-trusted-hosts-read-and-replace-the-i was merged into main as e8ced9a.

Review

Sign in to review this change.
4 files changed +138 −6
docs/MCP.md +1 −1
@@ -99,7 +99,7 @@ for a tunnel and a forge that sleeps.
9999 | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes |
100100 | `orgs` | `{ org? }`: without `org`, the organizations you belong to; with it, the organization, the teams you may see and each team's members; the same as GET `/api/v1/user/orgs`, `/api/v1/orgs/:org`, `…/teams` and `…/members` | |
101101 | `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 |
102 | `origins` | `{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes |
102 | `origins` | `{ action, id?, hosts? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), `sync` one now, or `hosts`: with no `hosts`, read the internal hosts an origin may live on (a forge admin, `fromEnv` read-only), with `hosts` (one per line) replace them as Settings → Origins does, a rejected line named in the error; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer | yes |
103103 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
104104 | `parent_unlink` | no input: stop signing in through the parent forge, as Settings does; refused when it is the account's last way in, and "Nothing to unlink." when nothing is linked; an admin token | yes |
105105 | `person` | `{ username, limit?, cursor? }`: a person's public profile and the repositories you may see, as `GET /api/v1/users/:username` and its `/repos`; a private one you cannot read is never listed | |
src/http/mcp/tools/origins.ts +8 −5
@@ -2,28 +2,31 @@ import { listRemoteRepos, listUserOrigins, syncOriginNow } from "../../../action
22 import { originJson, remoteRepoJson } from "../../api.ts";
33 import { ORIGIN_UNREACHABLE } from "../../../actions/origin_app_words.ts";
44 import { scopeAllows } from "../../../core/tokens.ts";
5 import { trustedHosts } from "../trusted_hosts.ts";
56 import { good, bad, type Tool } from "../shared.ts";
67
78 export const tool: Tool = {
89 name: "origins",
9 doc: "`{ action, id? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), or `sync` one now; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer",
10 doc: "`{ action, id?, hosts? }`: `list` your connected origins, `repos` the repositories one holds (`id`; `mirroredAs` says which are already here), `sync` one now, or `hosts`: with no `hosts`, read the internal hosts an origin may live on (a forge admin, `fromEnv` read-only), with `hosts` (one per line) replace them as Settings → Origins does, a rejected line named in the error; the same as `GET /api/v1/user/origins`, `…/:id/repos` and `POST …/:id/sync`, for you only, an admin token as there; never a credential in an answer",
1011 description:
11 "Your connected origins: list, repos of one, or sync it.",
12 "Origins: list, repos, sync, hosts.",
1213 inputSchema: {
1314 type: "object",
1415 properties: {

Nit: "Origins: list, repos, sync, hosts." no longer says these are your origins, while hosts is forge-wide and admin-only. If a later trim frees a few bytes, something like "Your origins: list, repos, sync; hosts (forge admin)." would tell an agent up front that hosts will be refused for most callers.

15 action: { type: "string", enum: ["list", "repos", "sync"] },
16 action: { type: "string", enum: ["list", "repos", "sync", "hosts"] },
1617 id: { type: "string" },
18 hosts: { type: "string" },
1719 },
1820 required: ["action"],
1921 },
2022 writes: true,
21 readsWithout: "id",
23 readsWithout: ["id", "hosts"],
2224 async run(ctx, user, args) {
2325 const action = args["action"];
2426 const id = args["id"];
27 if (action === "hosts") return trustedHosts(ctx, user, args);
2528 if (action !== "list" && action !== "repos" && action !== "sync")
26 return bad("bad_action", "`action` is list, repos or sync");
29 return bad("bad_action", "`action` is list, repos, sync or hosts");
2730 if (action !== "list" && (typeof id !== "string" || id === ""))
2831 return bad("invalid", "send the origin's `id`, from list");
2932 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
src/http/mcp/trusted_hosts.ts added +22
@@ -0,0 +1,22 @@
1 import { scopeAllows } from "../../core/tokens.ts";
2 import type { User } from "../../core/users.ts";
3 import { editableHosts, envHosts, mayEditTrustedHosts, rejectedSentence, updateTrustedHosts } from "../../origins/trusted-hosts.ts";
4 import { good, bad, type Answer, type Args, type Tool } from "./shared.ts";
5
6 type Ctx = Parameters<Tool["run"]>[0];
7
8 /** The `origins` tool's `hosts` action: Settings → Origins' internal-host list, for a forge admin's admin-scope token. */
9 export function trustedHosts(ctx: Ctx, user: User, args: Args): Answer {
10 if (!mayEditTrustedHosts(user)) return bad("forbidden", "Only a forge admin sets this.");
11 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
12 return bad("forbidden", "trusted hosts need an admin-scope token");
13 const hosts = args["hosts"];
14 if (hosts === undefined) return good({ hosts: editableHosts(), fromEnv: envHosts() });
15 if (typeof hosts !== "string") return bad("invalid", "`hosts` is text, one host name per line");
16 const saved = updateTrustedHosts(ctx.app.db, user, hosts);
17 if (!saved.ok)
18 return saved.error === "forbidden"
19 ? bad("forbidden", "Only a forge admin sets this.")
20 : bad("invalid", rejectedSentence(saved.rejected));
21 return good({ hosts: saved.hosts, fromEnv: envHosts() });
22 }
test/mcp-trusted-hosts.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The `origins` tool's `hosts` action is Settings → Origins' internal-host list over
3 * MCP: the same rules, the same refusals, a forge admin only.
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 trusted_hosts", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let admin: string;
19 let other: string;
20 let everyday: string;
21 let agent: string;
22 let id = 1;
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: "origins", arguments: { action: "hosts", ...(args as object) } },
37 }),
38 });
39 const body: any = await res.json();
40 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
41 };
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-trusted-"));
45 app = createApp({ dataDir, port: 0 });
46 servers = await serve(app);
47 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
48 if (!a.ok) throw new Error(a.error);
49 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
50 if (!b.ok) throw new Error(b.error);
51 admin = createToken(app.db, a.value.id, "admin", "admin").token;
52 other = createToken(app.db, b.value.id, "admin", "admin").token;
53 everyday = createToken(app.db, a.value.id, "everyday", "rw").token;
54 const c = await createUser(app.db, { origin: "seed", trusted: true, username: "robot", email: "[email protected]", password: "a-good-passphrase" });
55 if (!c.ok) throw new Error(c.error);
56 app.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE id = ?1").run(c.value.id);
57 agent = createToken(app.db, c.value.id, "robot", "admin").token;
58 }, 30_000);
59
60 afterAll(async () => {
61 servers.stop();
62 app.close();
63 await rm(dataDir, { recursive: true, force: true });
64 });
65
66 test("two hosts are set and read back", async () => {
67 const set = await tool(admin, { hosts: "git.example.test\nLab.Example.Test" });
68 expect(set.isError).toBe(false);
69 expect(JSON.parse(set.text).hosts).toEqual(["git.example.test", "lab.example.test"]);
70 const read = await tool(admin, {});
71 expect(JSON.parse(read.text).hosts).toEqual(["git.example.test", "lab.example.test"]);
72 });
73
74 test("a rejected line is named and nothing is saved", async () => {
75 const res = await tool(admin, { hosts: "ok.example.test\n10.0.0.1\nhttps://x.test" });
76 expect(res.isError).toBe(true);
77 expect(res.text).toContain("Not host names, so not trusted: 10.0.0.1, https://x.test");
78 expect(JSON.parse((await tool(admin, {})).text).hosts).toEqual(["git.example.test", "lab.example.test"]);
79 });
80
81 test("a caller who may not edit is refused, reading and writing", async () => {
82 for (const args of [{}, { hosts: "evil.example.test" }]) {
83 const res = await tool(other, args);
84 expect(res.isError).toBe(true);
85 expect(res.text).toContain("Only a forge admin sets this.");
86 }
87 expect(JSON.parse((await tool(admin, {})).text).hosts).not.toContain("evil.example.test");
88 });
89
90 test("a forge admin's rw token is refused, reading and writing, and nothing is saved", async () => {
91 for (const args of [{}, { hosts: "evil.example.test" }]) {
92 const res = await tool(everyday, args);
93 expect(res.isError).toBe(true);
94 expect(res.text).toContain("admin-scope token");
95 }
96 expect(JSON.parse((await tool(admin, {})).text).hosts).not.toContain("evil.example.test");
97 });
98
99 test("an agent account that is an admin is refused", async () => {
100 for (const args of [{}, { hosts: "evil.example.test" }]) {
101 const res = await tool(agent, args);
102 expect(res.isError).toBe(true);
103 expect(res.text).toContain("Only a forge admin sets this.");
104 }
105 expect(JSON.parse((await tool(admin, {})).text).hosts).not.toContain("evil.example.test");
106 });
107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs