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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5463-mcp-trusted-hosts-read-and-replace-the-i → main 8 commits fork 6 threads · 6 unresolved
4 files
changed +138
−6
docs/MCP.md +1 −1
| @@ -99,7 +99,7 @@ for a tunnel and a forge that sleeps. | ||
| 99 | 99 | | `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 | |
| 100 | 100 | | `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` | | |
| 101 | 101 | | `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 | | |
| 103 | 103 | | `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 | |
| 104 | 104 | | `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 | |
| 105 | 105 | | `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 | ||
| 2 | 2 | import { originJson, remoteRepoJson } from "../../api.ts"; |
| 3 | 3 | import { ORIGIN_UNREACHABLE } from "../../../actions/origin_app_words.ts"; |
| 4 | 4 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 5 | import { trustedHosts } from "../trusted_hosts.ts"; | |
| 5 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | 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", | |
| 10 | 11 | description: |
| 11 | "Your connected origins: list, repos of one, or sync it.", | |
| 12 | "Origins: list, repos, sync, hosts.", | |
| 12 | 13 | inputSchema: { |
| 13 | 14 | type: "object", |
| 14 | 15 | properties: { |
| 15 | action: { type: "string", enum: ["list", "repos", "sync"] }, | |
| 16 | action: { type: "string", enum: ["list", "repos", "sync", "hosts"] }, | |
| 16 | 17 | id: { type: "string" }, |
| 18 | hosts: { type: "string" }, | |
| 17 | 19 | }, |
| 18 | 20 | required: ["action"], |
| 19 | 21 | }, |
| 20 | 22 | writes: true, |
| 21 | readsWithout: "id", | |
| 23 | readsWithout: ["id", "hosts"], | |
| 22 | 24 | async run(ctx, user, args) { |
| 23 | 25 | const action = args["action"]; |
| 24 | 26 | const id = args["id"]; |
| 27 | if (action === "hosts") return trustedHosts(ctx, user, args); | |
| 25 | 28 | 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"); | |
| 27 | 30 | if (action !== "list" && (typeof id !== "string" || id === "")) |
| 28 | 31 | return bad("invalid", "send the origin's `id`, from list"); |
| 29 | 32 | 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 | }); |
new above
offline
Nit: "Origins: list, repos, sync, hosts." no longer says these are your origins, while
hostsis 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 thathostswill be refused for most callers.