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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10859-the-mcp-apps-and-flow-tools-draw-ui-card → main 3 commits fork 4 threads · 4 unresolved
src/http/mcp/tools/apps.ts +16 −4
| @@ -2,12 +2,14 @@ import { appsRuntime, envsFor } from "../../../apps/runtime.ts"; | ||
| 2 | 2 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 3 | 3 | import { appsOf } from "../../../web/studio/asks.ts"; |
| 4 | 4 | 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"; | |
| 5 | 7 | import { getAppByRepo } from "../../../core/apps.ts"; |
| 6 | 8 | import { bad, good, isAnswer, repoArg, type Tool } from "../shared.ts"; |
| 7 | 9 | |
| 8 | 10 | export const tool: Tool = { |
| 9 | 11 | 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", | |
| 11 | 13 | description: |
| 12 | 14 | "Apps you can see: live or not, where served, how each runs, what works.", |
| 13 | 15 | inputSchema: { |
| @@ -48,19 +50,29 @@ export const tool: Tool = { | ||
| 48 | 50 | ctx.app.db, |
| 49 | 51 | repos.map((r) => r.id), |
| 50 | 52 | ); |
| 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 ?? ""); | |
| 51 | 57 | return good( |
| 52 | 58 | repos.map((repo) => { |
outdated
No test covers |
||
| 53 | 59 | 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 }] : [], | |
| 56 | 64 | ); |
| 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 !== ""); | |
| 57 | 67 | return { |
| 58 | 68 | app: `${repo.ownerName}/${repo.name}`, |
| 59 | 69 | description: repo.description, |
| 60 | 70 | live: production?.state ?? "stopped", |
| 61 | 71 | url: runtime?.urlFor(record, "production") ?? null, |
| 62 | 72 | autonomy: repo.autonomy, |
| 63 | working: active.filter((r) => r.repoId === repo.id).length, | |
| 73 | working: mine.length, | |
| 74 | previews, | |
| 75 | crew, | |
| 64 | 76 | ...(archived ? { archivedAt: repo.archivedAt } : {}), |
| 65 | 77 | }; |
| 66 | 78 | }), |
src/http/mcp/tools/flow.ts +1 −1
| @@ -27,7 +27,7 @@ const said = (subject: string, why: Why) => | ||
| 27 | 27 | |
| 28 | 28 | export const tool: Tool = { |
| 29 | 29 | 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", | |
| 31 | 31 | description: |
| 32 | 32 | "Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck.", |
| 33 | 33 | inputSchema: { |
src/web/studio/cards.ts +11 −1
| @@ -105,6 +105,16 @@ const web = (u: string): boolean => /^https?:\/\//.test(u); | ||
| 105 | 105 | /** A repository named `owner/name`. */ |
| 106 | 106 | const slugOf = (s: string): boolean => /^[^/]+\/[^/]+$/.test(s); |
| 107 | 107 | |
| 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 | ||
| 108 | 118 | /** The card for a tool's answer, or nothing when the answer has no card. */ |
| 109 | 119 | export function cardFor( |
| 110 | 120 | tool: string, |
| @@ -289,7 +299,7 @@ export function cardFor( | ||
| 289 | 299 | label: text(a["app"]), |
| 290 | 300 | value: `${text(a["live"])}${url && !web(url) ? ` · ${url}` : ""}${ |
| 291 | 301 | 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"])}`, | |
| 293 | 303 | ...(web(origin) && /^[^/]+\/[^/]+$/.test(text(a["app"])) |
| 294 | 304 | ? { link: `${origin}/${text(a["app"])}` } |
| 295 | 305 | : {}), |
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 | }); |
Non-blocking: this doc line is indented four spaces; the rest of the object uses two.