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

The MCP apps and flow tools draw ui:// cards, so a client in Claude or ChatGPT sees apps and the pipeline, not JSON #10884

Merged opened this

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

Merged

torque/forge:torque/10859-the-mcp-apps-and-flow-tools-draw-ui-card → main 3 commits fork 4 threads · 4 unresolved

  • Merged. torque/10859-the-mcp-apps-and-flow-tools-draw-ui-card was merged into main as aeb194b.

Review

Sign in to review this change.
4 files changed +113 −6
src/http/mcp/tools/apps.ts +16 −4
@@ -2,12 +2,14 @@ import { appsRuntime, envsFor } from "../../../apps/runtime.ts";
22 import { listActiveRuns } from "../../../core/runs.ts";
33 import { appsOf } from "../../../web/studio/asks.ts";
44 import { PULL_RUNS_DEFAULT, PULL_RUNS_MAX, pullRunLines } from "../../../apps/pull.ts";
5 import { envPull } from "../../../apps/data.ts";
6 import { getUserById } from "../../../core/users.ts";
57 import { getAppByRepo } from "../../../core/apps.ts";
68 import { bad, good, isAnswer, repoArg, type Tool } from "../shared.ts";
79
810 export const tool: Tool = {
911 name: "apps",
10 doc: "`{ archived? }`: the apps you can see: live or not, where served, how each runs itself, what is working. `{ repo, pulls: true, limit? }`: an adapter's recorded pull runs, newest first, as `GET …/app/pulls` (`limit` 1-100); a reader's",
12 doc: "`{ archived? }`: your apps (`ui://forge/app` card): live or not, what is working, running `previews`, `crew` with a run now. `{ repo, pulls: true, limit? }`: an adapter's pull runs, as `GET …/app/pulls` (`limit`); a reader's",

Non-blocking: this doc line is indented four spaces; the rest of the object uses two.

1113 description:
1214 "Apps you can see: live or not, where served, how each runs, what works.",
1315 inputSchema: {
@@ -48,19 +50,29 @@ export const tool: Tool = {
4850 ctx.app.db,
4951 repos.map((r) => r.id),
5052 );
53 // One read per actor, not per run per repo.
54 const names = new Map<string, string>();
55 for (const r of active)
56 if (r.actorId !== null && !names.has(r.actorId)) names.set(r.actorId, getUserById(ctx.app.db, r.actorId)?.username ?? "");
5157 return good(
5258 repos.map((repo) => {
outdated

No test covers previews or crew as the tool returns them. Add one that calls the tool. Also note getUserById runs once per run per repo; resolve the usernames once.

5359 const record = records.get(repo.id)!;
54 const production = envsFor(ctx.app, user, record).find(
55 (e) => e.env === "production",
60 const envs = envsFor(ctx.app, user, record);
61 const production = envs.find((e) => e.env === "production");
62 const previews = envs.flatMap((e) =>
63 e.state === "running" && envPull(e.env) !== undefined ? [{ pull: envPull(e.env)!, url: e.url }] : [],
5664 );
65 const mine = active.filter((r) => r.repoId === repo.id);
66 const crew = [...new Set(mine.flatMap((r) => (r.actorId === null ? [] : [names.get(r.actorId) ?? ""])))].filter((n) => n !== "");
5767 return {
5868 app: `${repo.ownerName}/${repo.name}`,
5969 description: repo.description,
6070 live: production?.state ?? "stopped",
6171 url: runtime?.urlFor(record, "production") ?? null,
6272 autonomy: repo.autonomy,
63 working: active.filter((r) => r.repoId === repo.id).length,
73 working: mine.length,
74 previews,
75 crew,
6476 ...(archived ? { archivedAt: repo.archivedAt } : {}),
6577 };
6678 }),
src/http/mcp/tools/flow.ts +1 −1
@@ -27,7 +27,7 @@ const said = (subject: string, why: Why) =>
2727
2828 export const tool: Tool = {
2929 name: "flow",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ q, sort? }`, the rows a fleet query line selects, as `/fleet` shows them: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
30 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing` (the `ui://forge/flow` card); with `{ q, sort? }`, the rows a fleet query line selects: `{ kind, columns, items }`, only rows you may read, `sort` a column key led by `-` to reverse, a bad line answers its reason; with `{ q, action: { verb, value }, apply? }`, a fleet operation over those rows as `POST /api/v1/fleet/ops` answers it: the dry-run plan `{ dryRun: true, query, kind, verb, value, reversible, rows, counts }` and nothing changed, or with `apply: true` (an admin token, a person) the receipt `{ dryRun: false, id, rows, … }` whose id `limb` undoes; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a repository writer), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
3131 description:
3232 "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.",
3333 inputSchema: {
src/web/studio/cards.ts +11 −1
@@ -105,6 +105,16 @@ const web = (u: string): boolean => /^https?:\/\//.test(u);
105105 /** A repository named `owner/name`. */
106106 const slugOf = (s: string): boolean => /^[^/]+\/[^/]+$/.test(s);
107107
108 /** " · smith, juniper": who has a run going on the app, or nothing. */
109 const crewWords = (crew: unknown): string => {
110 const names = list(crew).map(text).filter((n) => n !== "").slice(0, 3);
111 return names.length === 0 ? "" : ` · ${names.join(", ")}`;
112 };
113 /** " · pull #7 https://…": the app's running previews, in its one line; only http(s) addresses. */
114 const previewWords = (previews: unknown): string =>
115 list(previews).map(obj).filter((p) => web(text(p["url"]))).slice(0, 3)
116 .map((p) => ` · pull #${text(p["pull"])} ${text(p["url"])}`).join("");
117
108118 /** The card for a tool's answer, or nothing when the answer has no card. */
109119 export function cardFor(
110120 tool: string,
@@ -289,7 +299,7 @@ export function cardFor(
289299 label: text(a["app"]),
290300 value: `${text(a["live"])}${url && !web(url) ? ` · ${url}` : ""}${
291301 Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : ""
outdated

Box 1 wants one line per app. These preview rows add up to 3 more per app, on top of the production row. Fold them into the app row's value or drop the extra rows. Also extract a helper rather than this long inline loop.

292 }`,
302 }${crewWords(a["crew"])}${previewWords(a["previews"])}`,
293303 ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(a["app"]))
294304 ? { link: `${origin}/${text(a["app"])}` }
295305 : {}),
test/mcp-apps-flow-cards.test.ts added +85
@@ -0,0 +1,85 @@
1 /** MCP `apps` answers `previews` and `crew`, and its card draws each app in one line. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } 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 { newApp } from "../src/actions/apps.ts";
8 import { AppsRuntime } from "../src/apps/runtime.ts";
9 import { createRun } from "../src/core/runs.ts";
10 import { getRepo } from "../src/core/repos.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { cardFor } from "../src/web/studio/cards.ts";
14 import { seedAdmin } from "./helpers/admin.ts";
15 import { tokenFor } from "./helpers/tokens.ts";
16
17 describe("MCP apps previews, crew and card", () => {
outdated

This flow test duplicates test/mcp-flow-card.test.ts, which is already on main. Remove it.

18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let token: string;
22
23 const call = async (args: unknown): Promise<any> => {
24 const res = await fetch(`${servers.url}/mcp`, {
25 method: "POST",
26 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
27 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "apps", arguments: args } }),
28 });
29 return (await res.json()) as any;
30 };
31
32 beforeAll(async () => {
33 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-apps-card-"));
34 app = createApp({ dataDir, port: 0 });
35 servers = await serve(app);
36 const runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url, concurrency: 1 });
37 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
38 const alice = getUserByName(app.db, "alice")!;
39 const bot = await createUser(app.db, { origin: "seed", trusted: true, username: "smith", email: "[email protected]", password: "a-good-passphrase" });
40 if (!bot.ok) throw new Error(bot.error);
41 for (const name of ["game", "quiet"]) {
42 const made = await newApp(app, alice, { name, visibility: "public" } as never);
43 if (!made.ok) throw new Error(String(made.error));
44 }
45 const game = getRepo(app.db, "alice", "game")!;
46 const quiet = getRepo(app.db, "alice", "quiet")!;
47 createRun(app.db, { repoId: game.id, kind: "agent", actorId: bot.value.id, reason: "work" });
48 // The runtime's own view of environments, fixed: a running preview of `game`, a stopped one of `quiet`.
49 runtime.status = (record) =>
50 record.repoId === game.id
51 ? [{ env: "pull-7", state: "running", commitOid: "a", url: "http://game--7.apps.test" }]
52 : record.repoId === quiet.id
53 ? [{ env: "pull-3", state: "stopped", commitOid: "b", url: "http://quiet--3.apps.test" }]
54 : [];
55 token = tokenFor(app, "alice", "rw");
56 }, 30_000);
57
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("the tool answers a running preview and the agent with a run going, each on its own app only", async () => {
65 const items: any[] = JSON.parse((await call({})).result.content[0].text);
66 const by = (n: string) => items.find((a) => a.app === `alice/${n}`);
67 expect(by("game").previews).toEqual([{ pull: 7, url: "http://game--7.apps.test" }]);
68 expect(by("game").crew).toEqual(["smith"]);
69 expect(by("game").working).toBe(1);
70 expect(by("quiet").previews).toEqual([]);
71 expect(by("quiet").crew).toEqual([]);
72 });
73
74 test("the card keeps one line per app, with previews and crew in it; hostile preview urls are left out", () => {
75 const card = cardFor("apps", [
76 { app: "alice/game", live: "running", working: 1, crew: ["smith", "juniper"],
77 previews: [{ pull: 7, url: "https://game--7.example" }, { pull: 8, url: "javascript:alert(1)" }] },
78 { app: "alice/quiet", live: "stopped", crew: [], previews: [] },
79 ], "https://forge.example")!;
80 expect(card.rows).toEqual([
81 { label: "alice/game", value: "running · 1 working · smith, juniper · pull #7 https://game--7.example", link: "https://forge.example/alice/game" },
82 { label: "alice/quiet", value: "stopped", link: "https://forge.example/alice/quiet" },
83 ]);
84 });
85 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs