Public: anyone can read this; the crew can change it.
A forge's admin reads their own forge's plan through MCP, in the same words as the settings row #11424
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11411-a-forge-s-admin-reads-their-own-forge-s → main 3 commits fork
3 files
changed +79
−5
docs/MCP.md +2 −1
| @@ -119,7 +119,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 119 | 119 | | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 | |
| 120 | 120 | | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 | |
| 121 | 121 | | `held` | — | What the Later gate holds, and one proposed pass over it | 270 | |
| 122 | | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 242 | | |
| 122 | | `here` | — | What you may do on a forge page; given an app's link, what its /__forge/ door shows | 237 | | |
| 123 | 123 | | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 | |
| 124 | 124 | | `hooks` | `list`, `add`, `remove`, `deliveries`, `ping`, `pause`, `resume`, `redeliver` | List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins | 494 | |
| 125 | 125 | | `invites` | `list`, `revoke` | Open invites; `id` revokes | 206 | |
| @@ -370,6 +370,7 @@ longer sentences: | ||
| 370 | 370 | - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder. |
| 371 | 371 | - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed. |
| 372 | 372 | - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`. |
| 373 | - `here.url`: none means the forge's own page. On `/` or `/settings` this forge's admin also gets `plan: { text, manage? }`, the Plan row's words and its manage-billing address; no plan notice or not an admin, no `plan` key. | |
| 373 | 374 | - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `since` is another name for `updated`. `query` is the same argument; `q` wins when both are given. Each of `kind`, `sort`, `updated`, `owner`, `archived` is also its own argument (and a query parameter on `/explore` and `/api/v1/explore`); it wins over the same key in `q`. |
| 374 | 375 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
| 375 | 376 | - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches. |
src/http/mcp/tools/here.ts +9 −4
| @@ -1,21 +1,23 @@ | ||
| 1 | 1 | import { appsRuntime } from "../../../apps/runtime.ts"; |
| 2 | 2 | import { hereOf } from "../../../apps/here.ts"; |
| 3 | 3 | import { manifestFor } from "../../../actions/here.ts"; |
| 4 | import { noticeOf } from "../../../billing/notice.ts"; | |
| 5 | import { planLine } from "../../../web/plan_row.ts"; | |
| 4 | 6 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 5 | 7 | |
| 6 | 8 | export const tool: Tool = { |
| 7 | 9 | name: "here", |
| 8 | 10 | description: |
| 9 | 11 | "What you may do on a forge page; given an app's link, what its /__forge/ door shows.", |
| 10 | doc: "`{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, audience: { level, who, sentence } (only when you may read the repository), environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page", | |
| 12 | doc: "`{ url }` (none: the forge's own page; its admin also gets `plan: { text, manage? }` on `/` or `/settings`, the Plan row's words and billing link, absent without a plan): on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, audience: { level, who, sentence } (only when you may read the repository), environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page", | |
| 11 | 13 | inputSchema: { |
| 12 | 14 | type: "object", |
| 13 | 15 | properties: { url: { type: "string", maxLength: 2000 } }, |
| 14 | required: ["url"], | |
| 16 | required: [], | |
| 15 | 17 | }, |
| 16 | 18 | writes: false, |
| 17 | 19 | run(ctx, user, args) { |
| 18 | const raw = str(args["url"]); | |
| 20 | const raw = str(args["url"]) || "/"; | |
| 19 | 21 | const runtime = appsRuntime(ctx.app); |
| 20 | 22 | const door = runtime === undefined || raw.length > 2000 ? undefined : hereOf(runtime, raw, user); |
| 21 | 23 | if (door !== undefined) return good(door); |
| @@ -29,8 +31,11 @@ export const tool: Tool = { | ||
| 29 | 31 | ctx.url.origin, |
| 30 | 32 | parsed.pathname, |
| 31 | 33 | ); |
| 34 | const forgePage = parsed.pathname === "/" || parsed.pathname === "/settings"; | |
| 35 | const text = forgePage && user.isAdmin === true ? planLine(ctx.app.config.dataDir) : undefined; | |
| 36 | const manage = text === undefined ? undefined : noticeOf(ctx.app.config.dataDir)?.portalUrl; | |
| 32 | 37 | return found.ok |
| 33 | ? good(found.value) | |
| 38 | ? good(text === undefined ? found.value : { ...found.value, plan: { text, ...(manage === undefined ? {} : { manage }) } }) | |
| 34 | 39 | : bad("not_found", "no such page here, or it is not visible to you"); |
| 35 | 40 | }, |
| 36 | 41 | }; |
test/mcp-here-plan.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** `here` carries the Plan row's words and billing link for this forge's admin, on `/` and `/settings`, and for no one else. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { planLine } from "../src/web/plan_row.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | import { promote } from "./helpers/members.ts"; | |
| 12 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 13 | ||
| 14 | let dataDir = ""; | |
| 15 | let app: App, servers: Servers; | |
| 16 | let admin = "", member = ""; | |
| 17 | let id = 1; | |
| 18 | const PORTAL = "https://billing.example.com/p/login/abc"; | |
| 19 | ||
| 20 | const here = async (token: string, args: unknown): Promise<any> => { | |
| 21 | const res = await fetch(`${servers.url}/mcp`, { | |
| 22 | method: "POST", | |
| 23 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 24 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "here", arguments: args } }), | |
| 25 | }); | |
| 26 | const body: any = await res.json(); | |
| 27 | return JSON.parse(body.result.content[0].text); | |
| 28 | }; | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-here-plan-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 35 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 36 | if (!b.ok) throw new Error(b.error); | |
| 37 | promote(app.db, b.value); | |
| 38 | admin = tokenFor(app, "alice", "rw"); | |
| 39 | member = tokenFor(app, "bob", "rw"); | |
| 40 | }, 30_000); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | servers.stop(); | |
| 44 | app.close(); | |
| 45 | await rm(dataDir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a forge with no plan notice has no plan key, even for its admin", async () => { | |
| 49 | expect((await here(admin, { url: "/settings" })).plan).toBeUndefined(); | |
| 50 | }); | |
| 51 | ||
| 52 | test("the admin reads the plan on /settings and with no page; a non-admin gets none", async () => { | |
| 53 | await writeFile(join(dataDir, "plan.json"), JSON.stringify({ name: "Medium", size: "medium", priceUsd: 49, creditUsd: 10, status: "active", portalUrl: PORTAL })); | |
| 54 | const text = planLine(dataDir)!; | |
| 55 | expect(text).toContain("Medium"); | |
| 56 | for (const args of [{ url: "/settings" }, {}, { url: "/" }]) { | |
| 57 | expect((await here(admin, args)).plan).toEqual({ text, manage: PORTAL }); | |
| 58 | expect((await here(member, args)).plan).toBeUndefined(); | |
| 59 | } | |
| 60 | expect((await here(admin, { url: "/explore" })).plan).toBeUndefined(); | |
| 61 | }); | |
| 62 | ||
| 63 | test("no portal address, no manage key", async () => { | |
| 64 | await writeFile(join(dataDir, "plan.json"), JSON.stringify({ name: "Small", size: "small", priceUsd: 9, creditUsd: 0, status: "active" })); | |
| 65 | const plan = (await here(admin, {})).plan; | |
| 66 | expect(plan.text).toContain("Small"); | |
| 67 | expect("manage" in plan).toBe(false); | |
| 68 | }); |
new above
offline