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

Merged opened this

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

Merged

scout/forge:scout/11411-a-forge-s-admin-reads-their-own-forge-s → main 3 commits fork

  • Merged. scout/11411-a-forge-s-admin-reads-their-own-forge-s was merged into main as 865232e.

Review

Sign in to review this change.
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`;
119119 | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 |
120120 | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 |
121121 | `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 |
123123 | `hold` | — | Hold a pull (`why`) or `release` it; or `path`: hold new work on a file | 522 |
124124 | `hooks` | `list`, `add`, `remove`, `deliveries`, `ping`, `pause`, `resume`, `redeliver` | List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins | 494 |
125125 | `invites` | `list`, `revoke` | Open invites; `id` revokes | 206 |
@@ -370,6 +370,7 @@ longer sentences:
370370 - `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.
371371 - `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.
372372 - `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.
373374 - `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`.
374375 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
375376 - `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 @@
11 import { appsRuntime } from "../../../apps/runtime.ts";
22 import { hereOf } from "../../../apps/here.ts";
33 import { manifestFor } from "../../../actions/here.ts";
4 import { noticeOf } from "../../../billing/notice.ts";
5 import { planLine } from "../../../web/plan_row.ts";
46 import { good, bad, type Tool, str } from "../shared.ts";
57
68 export const tool: Tool = {
79 name: "here",
810 description:
911 "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",
1113 inputSchema: {
1214 type: "object",
1315 properties: { url: { type: "string", maxLength: 2000 } },
14 required: ["url"],
16 required: [],
1517 },
1618 writes: false,
1719 run(ctx, user, args) {
18 const raw = str(args["url"]);
20 const raw = str(args["url"]) || "/";
1921 const runtime = appsRuntime(ctx.app);
2022 const door = runtime === undefined || raw.length > 2000 ? undefined : hereOf(runtime, raw, user);
2123 if (door !== undefined) return good(door);
@@ -29,8 +31,11 @@ export const tool: Tool = {
2931 ctx.url.origin,
3032 parsed.pathname,
3133 );
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;
3237 return found.ok
33 ? good(found.value)
38 ? good(text === undefined ? found.value : { ...found.value, plan: { text, ...(manage === undefined ? {} : { manage }) } })
3439 : bad("not_found", "no such page here, or it is not visible to you");
3540 },
3641 };
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs