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

One MCP server beside every harness: tools with MCP Apps ui:// cards, an OpenAPI description, thin plugins for OpenCode and Claude Code #1407

Merged opened this in answer to

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

Merged

scout/forge:scout/1116-one-mcp-server-beside-every-harness-tool → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/1116-one-mcp-server-beside-every-harness-tool was merged into main as 4635db3.

Review

Sign in to review this change.
15 files changed +638 −3
docs/MCP.md +19
@@ -131,3 +131,22 @@ answers
131131 "opened": [{ "key": "board", "number": 7 }, { "key": "moves", "number": 8 }, { "key": "play", "number": 9 }],
132132 "ready": [7, 8], "later": [] }
133133 ```
134
135 ## Cards, the contract and the plugins (#1116)
136
137 - **Cards.** `apps`, `now`, `asks` and `milestones` name a `ui://` resource in
138 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
139 `decision`, `wave`. `resources/list` and `resources/read` serve one small
140 sandboxed page, which draws the `structuredContent.card` model that the
141 tool result carries, with `textContent` only. The model is built in
142 `src/web/studio/cards.ts`. A card's
outdated

Once cardHtml is gone, drop "which also renders it as escaped HTML".

143 buttons ask the host to call a tool (`asks_act`, `say`, `stop`, `tell`); the
144 host decides, and every call is checked as any other.
145 - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes
146 the manifest (`here`) names, plus `POST /mcp`. `test/openapi.test.ts` fails
147 when the manifest names a route it lacks.
148 - **Where a tell came from.** `tell` takes `context`: `page` (http/https),
149 `run`, `commit` (hex) and `said` (the person's words). Malformed context is
150 refused; the rest is appended to the issue body.
151 - **Plugins.** `plugins/opencode` and `plugins/claude-code` register this
152 server and add a `forge` command (tell, status, decide). They hold no logic.
plugins/claude-code/.claude-plugin/plugin.json added +12
@@ -0,0 +1,12 @@
1 {
2 "name": "forge",
3 "version": "0.1.0",
4 "description": "Your forge inside Claude Code: its MCP server and a /forge command (tell, status, decide).",
5 "mcpServers": {
6 "forge": {
7 "type": "http",
8 "url": "${FORGE_URL}/mcp",
9 "headers": { "Authorization": "Bearer ${FORGE_TOKEN}" }
10 }
11 }
12 }
plugins/claude-code/README.md added +6
@@ -0,0 +1,6 @@
1 # forge for Claude Code
2
3 Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN`
4 (an `rw` token; `ro` reads only), then add this directory as a plugin
5 (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP
6 server and a `/forge` command: `tell`, `status`, `decide`.
plugins/claude-code/commands/forge.md added +12
@@ -0,0 +1,12 @@
1 ---
2 description: Talk to your forge: tell, status, decide
3 argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words]
4 ---
5
6 Use the `forge` MCP server's tools for `$ARGUMENTS`.
7
8 - `tell <app> <words>`: call `tell` with `repo` = the app (owner/name) and `text` = the words. Pass `context` with `page` (if known), `run`, `commit` (the current `git rev-parse HEAD`) and `said` (the person's own words, verbatim).
9 - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person.
10 - `decide approve|redirect|decline`: call `asks`, take the first row; approve → `asks_act` with action `ship`, decline → `asks_act` with action `close`, redirect → `say` with the person's words. Plan first (`dryRun` defaults to true), show what would change, and apply (`dryRun: false`) only after the person says yes.
11
12 Keep what you relay short. Never merge: a person lands changes.
plugins/opencode/README.md added +6
@@ -0,0 +1,6 @@
1 # forge for OpenCode
2
3 Copy `forge.ts` into `.opencode/plugin/` (or list this package under `plugin`
4 in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN`. It registers the
5 forge's MCP server and the `forge` command: `tell`, `status`, `decide`.
6 Code Mode can also read `GET /contract/openapi.json`.
plugins/opencode/forge.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * forge for OpenCode: registers the forge's MCP server and a `forge`
3 * command (tell, status, decide). Set FORGE_URL and FORGE_TOKEN.
4 * No logic lives here: the tools are the forge's own (`docs/MCP.md`).
5 */
6 import type { Plugin } from "@opencode-ai/plugin";
7
8 const COMMAND = `Use the forge MCP tools for: $ARGUMENTS
9 - tell <app> <words>: call \`tell\` with repo and text; pass context { page, run, commit, said }.
10 - status: call \`now\` and \`asks\`; say in three sentences what runs and what waits on the person.
11 - decide approve|redirect|decline: call \`asks\`; approve -> \`asks_act\` ship, decline -> \`asks_act\` close, redirect -> \`say\`. Plan first, apply (dryRun false) only after the person says yes.
12 Never merge: a person lands changes.`;
13
14 export const Forge: Plugin = async () => ({
15 config: async (config: Record<string, any>) => {
16 const url = process.env["FORGE_URL"];
17 const token = process.env["FORGE_TOKEN"];
18 if (!url || !token) return;
19 config["mcp"] = {
20 ...config["mcp"],
21 forge: {
22 type: "remote",
23 url: `${url.replace(/\/$/, "")}/mcp`,
24 headers: { Authorization: `Bearer ${token}` },
25 },
26 };
27 config["command"] = {
28 ...config["command"],
29 forge: { description: "Tell, status or decide on your forge", template: COMMAND },
30 };
31 },
32 });
33
34 export default Forge;
plugins/opencode/package.json added +7
@@ -0,0 +1,7 @@
1 {
2 "name": "opencode-forge",
3 "version": "0.1.0",
4 "type": "module",
5 "main": "forge.ts",
6 "description": "The forge's MCP server and a forge command for OpenCode"
7 }
scripts/gate.ts +1
@@ -212,6 +212,7 @@ export const GATE: readonly string[] = [
212212 "test/app-env.test.ts",
213213 "test/apps-ssh.test.ts",
214214 "test/pacing.test.ts",
215 "test/openapi.test.ts",
215216 "test/palette.test.ts",
216217 "test/qr.test.ts",
217218 "test/branches.test.ts",
src/actions/tell_context.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * Where a tell came from. A tell said inside an app or a harness carries
3 * the page it was said on, the run and commit that were current, and the
4 * person's own words, so the agent that picks it up sees its context. Each
5 * field is checked at the boundary and bounded; what passes is appended to
6 * the tell as a plain block the issue shows and the agent reads.
7 */
8
9 export interface TellContext {
10 readonly page?: string;
11 readonly run?: string;
12 readonly commit?: string;
13 readonly said?: string;
14 }
15
16 const SAID_MAX = 2000;
17 const RUN = /^[A-Za-z0-9_-]{1,64}$/;
18 const COMMIT = /^[0-9a-f]{7,64}$/;
19
20 /** The checked context, nothing for none, or null when a field is malformed. */
21 export function parseContext(v: unknown): TellContext | undefined | null {
22 if (v === undefined) return undefined;
23 if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
24 const o = v as Record<string, unknown>;
25 const out: { page?: string; run?: string; commit?: string; said?: string } = {};
26 if (o["page"] !== undefined) {
27 const url =
28 typeof o["page"] === "string" && o["page"].length <= 2000
29 ? URL.parse(o["page"])
30 : null;
31 if (url === null || (url.protocol !== "http:" && url.protocol !== "https:"))
32 return null;
33 out.page = url.href;
34 }
35 if (o["run"] !== undefined) {
36 if (typeof o["run"] !== "string" || !RUN.test(o["run"])) return null;
37 out.run = o["run"];
38 }
39 if (o["commit"] !== undefined) {
40 if (typeof o["commit"] !== "string" || !COMMIT.test(o["commit"])) return null;
41 out.commit = o["commit"];
42 }
43 if (o["said"] !== undefined) {
44 if (typeof o["said"] !== "string" || o["said"].length > SAID_MAX) return null;
45 out.said = o["said"].trim();
46 }
47 return out;
48 }
49
50 /** The block appended to a tell; empty when there is nothing to say. */
51 export function contextNote(c: TellContext | undefined): string {
52 if (c === undefined) return "";
53 const lines = [
54 c.page === undefined ? "" : `- page: ${c.page}`,
55 c.run === undefined ? "" : `- run: ${c.run}`,
56 c.commit === undefined ? "" : `- commit: ${c.commit}`,
57 c.said === undefined || c.said === ""
58 ? ""
59 : `- their words: ${c.said.replace(/\s*\n\s*/g, " ")}`,
60 ].filter((l) => l !== "");
61 return lines.length === 0 ? "" : `\n\n---\nWhere this was told from:\n${lines.join("\n")}`;
62 }
src/contract/openapi.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The forge's public API as an OpenAPI 3.1 document: the routes the
3 * manifest (`actions/here.ts`) names to an agent, one row each, so a client
4 * that reads OpenAPI (OpenCode's Code Mode, any agent harness) can call the
5 * forge without a hand-written wrapper. Plain data in, plain data out;
6 * `GET /contract/openapi.json` serves it. A route added to the manifest is
7 * added here, and `test/openapi.test.ts` fails until it is.
8 */
9
10 interface Route {
11 readonly method: "get" | "post" | "patch";
12 readonly path: string;
13 readonly summary: string;
14 /** Whether it needs a token; reads of public things do not. */
15 readonly auth: boolean;
16 /** A JSON body's fields, when it takes one. */
17 readonly body?: Readonly<Record<string, string>>;
18 }
19
20 const REPO = "/api/v1/repos/{owner}/{name}";
21
22 export const ROUTES: readonly Route[] = [
23 { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false },
24 { method: "get", path: REPO, summary: "Read one repository", auth: false },
25 {
26 method: "post",
27 path: `${REPO}/reports`,
28 summary: "Report a problem in a public repository, without an account",
29 auth: false,
30 body: { title: "string", body: "string", page: "string" },
31 },
32 {
33 method: "post",
34 path: `${REPO}/issues`,
35 summary: "Open an issue",
36 auth: true,
37 body: { title: "string", body: "string" },
38 },
39 {
40 method: "post",
41 path: `${REPO}/issues/{number}/comments`,
42 summary: "Comment on an issue or pull",
43 auth: true,
44 body: { body: "string" },
45 },
46 { method: "post", path: `${REPO}/forks`, summary: "Fork a repository", auth: true },
47 {
48 method: "post",
49 path: `${REPO}/pulls`,
50 summary: "Offer a change as a pull",
51 auth: true,
52 body: { title: "string", head: "string", headRepo: "string" },
53 },
54 {
55 method: "post",
56 path: `${REPO}/pulls/{number}/reviews`,
57 summary: "Give a verdict on a pull",
58 auth: true,
59 body: { verdict: "string", body: "string" },
60 },
61 { method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true },
62 { method: "patch", path: REPO, summary: "Change a repository's settings", auth: true },
63 { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false },
64 {
65 method: "post",
66 path: "/mcp",
67 summary: "The forge as an MCP server (JSON-RPC 2.0); see docs/MCP.md",
68 auth: true,
69 body: { jsonrpc: "string", id: "integer", method: "string", params: "object" },
70 },
71 ];
72
73 const schema = (t: string) => (t === "object" ? { type: "object" } : { type: t });
74
75 /** The document, for the forge served at `origin`. */
76 export function openapi(origin: string, version: string): Record<string, unknown> {
77 const paths: Record<string, Record<string, unknown>> = {};
78 for (const r of ROUTES) {
79 const params = [...r.path.matchAll(/\{(\w+)\}/g)].map((m) => ({
80 name: m[1],
81 in: "path",
82 required: true,
83 schema: { type: m[1] === "number" ? "integer" : "string" },
84 }));
85 (paths[r.path] ??= {})[r.method] = {
86 operationId: `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`,
87 summary: r.summary,
88 parameters: params,
89 ...(r.body === undefined
90 ? {}
91 : {
92 requestBody: {
93 required: true,
94 content: {
95 "application/json": {
96 schema: {
97 type: "object",
98 properties: Object.fromEntries(
99 Object.entries(r.body).map(([k, t]) => [k, schema(t)]),
100 ),
101 },
102 },
103 },
104 },
105 }),
106 ...(r.auth ? { security: [{ token: [] }] } : {}),
107 responses: {
108 "200": { description: "Done" },
109 default: {
110 description: "An error: { error, message }; `error` is a stable code",
111 },
112 },
113 };
114 }
115 return {
116 openapi: "3.1.0",
117 info: {
118 title: "forge",
119 version,
120 description:
121 "The routes the forge names to an agent. `not_found` means missing or not yours to see.",
122 },
123 servers: [{ url: origin }],
124 paths,
125 components: {
126 securitySchemes: { token: { type: "http", scheme: "bearer" } },
127 },
128 };
129 }
src/core/reserved.ts +1
@@ -75,6 +75,7 @@ export const SYSTEM_NAMES: readonly string[] = [
7575 "manifest.webmanifest",
7676 "map",
7777 "mcp",
78 "contract",
7879 "migrate",
7980 "mode",
8081 "new",
src/http/mcp.ts +76 −3
@@ -37,6 +37,15 @@ import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts";
3737 import { cancelRepoRun } from "../actions/runs.ts";
3838 import { clientRows } from "../spawn/clients.ts";
3939 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
40 import { contextNote, parseContext } from "../actions/tell_context.ts";
41 import {
42 CARD_KINDS,
43 CARD_MIME,
44 CARD_OF_TOOL,
45 CARD_SHELL,
46 CARD_URI,
47 cardFor,
48 } from "../web/studio/cards.ts";
4049 import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts";
4150 import { readReturns, reportSpend } from "../actions/returns.ts";
4251 import {
@@ -73,6 +82,7 @@ import { plannerDeclines } from "../crew/autonomy.ts";
7382 import { listActiveRuns } from "../core/runs.ts";
7483 import { scopeAllows } from "../core/tokens.ts";
7584 import { getUsersByIds, type User } from "../core/users.ts";
85 import { openapi } from "../contract/openapi.ts";
7686 import { audit } from "../contract/audit.ts";
7787 import { probeApp } from "../contract/probes.ts";
7888 import { convergeFork, convergeState } from "../crew/converge.ts";
@@ -272,14 +282,41 @@ const TOOLS: readonly Tool[] = [
272282 "Tell an app's crew what should change. One sentence or several; the first line becomes the title. A builder is picked and starts on the crew's next tick.",
273283 inputSchema: {
274284 type: "object",
275 properties: { repo: REPO_ARG, text: { type: "string", maxLength: 8000 } },
285 properties: {
286 repo: REPO_ARG,
287 text: { type: "string", maxLength: 8000 },
288 context: {
289 type: "object",
290 description:
291 "Where this was told from, so the builder sees it: page (http/https address), run (id), commit (hex), said (the person's own words).",
292 properties: {
293 page: { type: "string" },
294 run: { type: "string" },
295 commit: { type: "string" },
296 said: { type: "string", maxLength: 2000 },
297 },
298 },
299 },
276300 required: ["repo", "text"],
277301 },
278302 writes: true,
279303 async run(ctx, user, args) {
280304 const repo = repoArg(ctx, user, args);
281305 if (isAnswer(repo)) return repo;
282 const told = await tell(ctx.app, user, repo, str(args["text"]));
306 const where = parseContext(args["context"]);
307 if (where === null)
308 return bad(
309 "invalid",
310 "context is { page?: http(s) address, run?: id, commit?: hex, said?: words }",
311 );
312 const told = await tell(
313 ctx.app,
314 user,
315 repo,
316 str(args["text"]) === ""
317 ? ""
318 : `${str(args["text"])}${contextNote(where)}`,
319 );
283320 if (!told.ok)
284321 return bad(
285322 told.error,
@@ -1651,6 +1688,12 @@ function adminNote(ctx: Ctx, user: User): string {
16511688 }`;
16521689 }
16531690
1691 /** A card's model beside the text, for a host that draws `ui://` resources. */
1692 function cardResult(name: string, value: unknown): Record<string, unknown> {
1693 const card = cardFor(name, value);
1694 return card === undefined ? {} : { structuredContent: { card } };
1695 }
1696
16541697 function rpcOne(
16551698 ctx: Ctx,
16561699 user: User,
@@ -1682,7 +1725,7 @@ function rpcOne(
16821725 protocolVersion: (PROTOCOLS as readonly string[]).includes(asked)
16831726 ? asked
16841727 : PROTOCOLS[0],
1685 capabilities: { tools: {} },
1728 capabilities: { tools: {}, resources: {} },
16861729 serverInfo: server(),
16871730 instructions: `This is a person's forge: their apps, and the crew that builds them. \`tell\` and \`say\` are how they direct it. Keep what you relay short — three sentences and the real options.${adminNote(ctx, user)}`,
16881731 });
@@ -1697,10 +1740,33 @@ function rpcOne(
16971740 description: t.description,
16981741 inputSchema: t.inputSchema,
16991742 annotations: { readOnlyHint: !t.writes },
1743 ...(CARD_OF_TOOL[t.name] === undefined
1744 ? {}
1745 : {
1746 _meta: {
1747 ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) },
1748 },
1749 }),
17001750 })),
17011751 REPORT_TOOL,
17021752 ],
17031753 });
1754 case "resources/list":
1755 return reply({
1756 resources: CARD_KINDS.map((k) => ({
1757 uri: CARD_URI(k),
1758 name: `forge-${k}-card`,
1759 mimeType: CARD_MIME,
1760 })),
1761 });
1762 case "resources/read": {
1763 const uri = str(params["uri"]);
1764 const kind = CARD_KINDS.find((k) => CARD_URI(k) === uri);
1765 if (kind === undefined) return refuse(-32002, `no resource ${uri}`);
1766 return reply({
1767 contents: [{ uri, mimeType: CARD_MIME, text: CARD_SHELL(kind) }],
1768 });
1769 }
17041770 case "tools/call": {
17051771 const tool = TOOLS.find((t) => t.name === params["name"]);
17061772 if (tool === undefined)
@@ -1722,6 +1788,7 @@ function rpcOne(
17221788 answer.ok
17231789 ? {
17241790 content: [{ type: "text", text: JSON.stringify(answer.value) }],
1791 ...cardResult(String(params["name"]), answer.value),
17251792 }
17261793 : {
17271794 content: [
@@ -1881,4 +1948,10 @@ export function mcpRoutes(router: Router<Handler>): void {
18811948 // for a proxy to cut.
18821949 router.add("GET", "/mcp", () => NOT_HERE("POST"));
18831950 router.add("DELETE", "/mcp", () => NOT_HERE("POST"));
1951 // The public API as OpenAPI, for any client that reads it.
1952 router.add("GET", "/contract/openapi.json", (ctx) =>
1953 json(openapi(ctx.url.origin, running().version), 200, {
1954 "cache-control": "public, max-age=300",
1955 }),
1956 );
18841957 }
src/web/studio/cards.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * The cards: what a tool's answer looks like inside an MCP Apps host
3 * (`ui://` resources, SEP-1865). One model per card — a title, a status,
4 * rows, and the actions beside them — built from the data the tool
5 * already returns. The model is plain data; `CARD_SHELL` is the one
6 * sandboxed page every `ui://` resource serves, which draws it with
7 * `textContent` and never `innerHTML`.
8 */
outdated

No web page draws a card, so "a host and a web page show the same card" is not true. Describe only the ui:// shell.

9
10 import { html, raw } from "../html.ts";
11
12 export type CardKind = "app" | "run" | "decision" | "wave";
13
14 export interface CardRow {
15 readonly label: string;
16 readonly value: string;
17 }
18 export interface CardAction {
19 readonly label: string;
20 /** The tool the host calls when it is pressed, and with what. */
21 readonly tool: string;
22 readonly args: Record<string, unknown>;
23 }
24 export interface Card {
25 readonly kind: CardKind;
26 readonly title: string;
27 readonly status: string;
28 readonly rows: readonly CardRow[];
29 readonly actions: readonly CardAction[];
30 }
31
32 export const CARD_MIME = "text/html;profile=mcp-app";
33 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
34 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave"];
35
36 /** The tool whose answer each card draws. */
37 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
38 apps: "app",
39 now: "run",
40 asks: "decision",
41 milestones: "wave",
42 };
43
44 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
45 app: "Apps",
46 run: "Working now",
47 decision: "Waiting on you",
48 wave: "Wave",
49 };
50
51 type Obj = Record<string, unknown>;
52 const obj = (v: unknown): Obj =>
53 typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
54 const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
55 const text = (v: unknown): string =>
56 typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
57 const LIMIT = 12;
58
59 /** The card for a tool's answer, or nothing when the answer has no card. */
60 export function cardFor(tool: string, value: unknown): Card | undefined {
61 const kind = CARD_OF_TOOL[tool];
62 if (kind === undefined) return undefined;
63 const rows: CardRow[] = [];
64 const actions: CardAction[] = [];
65 let status = "";
66 if (kind === "app") {
67 const apps = list(value).map(obj);
68 status = `${apps.length} app${apps.length === 1 ? "" : "s"}`;
69 for (const a of apps.slice(0, LIMIT))
70 rows.push({
71 label: text(a["app"]),
72 value: `${text(a["live"])}${a["url"] ? ` · ${text(a["url"])}` : ""}${
73 Number(a["working"]) > 0 ? ` · ${text(a["working"])} working` : ""
74 }`,
75 });
76 } else if (kind === "run") {
77 const runs = list(value).map(obj);
78 status = runs.length === 0 ? "nothing running" : `${runs.length} going`;
79 for (const r of runs.slice(0, LIMIT)) {
80 const cost = Number(r["costUsd"]) > 0 ? ` · $${Number(r["costUsd"]).toFixed(2)}` : "";
81 rows.push({
outdated

Medium: tell refuses empty text, so this button can never succeed as written. It needs a way for the person to supply the words, or it should go.

82 label: `${text(r["who"])} on ${text(r["app"])}`,
83 value: `${text(r["on"])} — ${text(r["state"])}${cost}`,
84 });
85 if (r["run"] && actions.length < 3)
86 actions.push({ label: `Stop ${text(r["who"])}`, tool: "stop", args: { repo: text(r["app"]), run: text(r["run"]) } });
87 }
88 } else if (kind === "decision") {
89 const asks = list(obj(value)["rows"]).map(obj);
90 status = asks.length === 0 ? "nothing waits" : `${asks.length} waiting`;
91 for (const a of asks.slice(0, LIMIT)) {
92 const words = obj(a["words"]);
93 rows.push({
94 label: text(a["line"]),
95 value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(),
96 });
97 }
98 const first = asks[0];
99 if (first !== undefined) {
100 const items = list(first["items"]).map(text);
101 const kindOf = text(first["kind"]);
102 // No `dryRun`: the press is the dry run `asks_act` defaults to, and the
103 // host shows what would change before anything ships or closes. Tell and
104 // Redirect are absent: the card has no field for the person's words.
105 if (kindOf === "look" || kindOf === "review")
106 actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } });
107 actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } });
108 }
109 } else {
110 const limbs = list(obj(value)["limbs"]).map(obj);
outdated

High: dryRun: false makes a single press ship every item in the row, and ship is never undone. Drop dryRun so the press is the dry run asks_act defaults to, and apply as a second step. The same applies to Decline on line 112.

111 const repo = obj(obj(value)["repo"]);
outdated

Medium: say requires repo, number and text, and this passes only text: "", so it is always refused. Pass the row's repo and number, or remove the button.

112 status = `${text(repo["owner"])}/${text(repo["name"])}`.replace(/^\/$/, "");
113 for (const l of limbs.slice(0, LIMIT))
114 rows.push({
115 label: `#${text(l["number"])} ${text(l["name"])}`,
116 value: `${text(l["done"])}/${text(l["total"])} done · ${text(l["building"])} building`,
117 });
118 }
119 return { kind, title: CARD_TITLE[kind], status, rows, actions };
120 }
121
122 /**
123 * The page behind every `ui://forge/*` resource. The host posts the tool's
124 * result to it (`ui/notifications/tool-result`); it reads `card` from the
125 * structured content and draws it with `textContent`. A button asks the
outdated

Dead code: nothing in src/ calls cardHtml; only a test does. Remove it and its imports, along with the test lines that call it (test/mcp.test.ts 580-586).

126 * host to call the tool; the host decides whether to.
127 */
128 const SHELL_JS = `
129 const root = document.getElementById("card");
130 const el = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls) e.className = cls; return e; };
131 let n = 0;
132 function draw(card) {
133 root.replaceChildren(el("h2", card.title), el("p", card.status, "status"));
134 const dl = document.createElement("dl");
135 for (const r of card.rows) dl.append(el("dt", r.label), el("dd", r.value));
136 root.append(dl);
137 for (const a of card.actions) {
138 const b = el("button", a.label);
139 b.onclick = () => parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: a.args } }, "*");
140 root.append(b);
141 }
142 }
143 addEventListener("message", (e) => {
144 if (e.source !== parent) return;
145 const m = e.data;
146 if (m && m.method === "ui/notifications/tool-result") {
147 const card = m.params && m.params.structuredContent && m.params.structuredContent.card;
148 if (card) draw(card);
149 }
150 });
151 parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} }, "*");
152 `;
153
154 export const CARD_SHELL = (kind: CardKind): string =>
155 html`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${CARD_TITLE[kind]}</title><style>body{font:14px system-ui;margin:0;padding:12px}h2{margin:0 0 4px;font-size:16px}.status{margin:0 0 8px;opacity:.7}dt{font-weight:600;margin-top:6px}dd{margin:0}button{margin:8px 6px 0 0;padding:4px 10px}</style><div id="card"><h2>${CARD_TITLE[kind]}</h2></div><script>${raw(SHELL_JS)}</script>`.text;
test/mcp.test.ts +96
@@ -131,6 +131,7 @@ describe("the forge over MCP", () => {
131131 expect(init.body.result.protocolVersion).toBe("2025-03-26");
132132 expect(init.body.result.serverInfo.name).toBe("forge");
133133 expect(init.body.result.capabilities.tools).toEqual({});
134 expect(init.body.result.capabilities.resources).toEqual({});
134135 // A version it has never heard of gets the one it prefers.
135136 const future = await call(rw, "initialize", {
136137 protocolVersion: "2031-01-01",
@@ -546,4 +547,99 @@ describe("the forge over MCP", () => {
546547 });
547548 expect(res.status).toBe(401);
548549 });
550
551 test("ui:// card resources are listed, read, and tied to their tools", async () => {
552 const list = await call(rw, "resources/list");
553 const uris = list.body.result.resources.map((r: any) => r.uri).sort();
554 expect(uris).toEqual([
555 "ui://forge/app",
556 "ui://forge/decision",
557 "ui://forge/run",
558 "ui://forge/wave",
559 ]);
560 const read = await call(rw, "resources/read", { uri: "ui://forge/app" });
561 const c = read.body.result.contents[0];
562 expect(c.mimeType).toBe("text/html;profile=mcp-app");
563 expect(c.text).toContain("ui/notifications/tool-result");
564 expect(c.text).not.toContain("innerHTML");
565 const nope = await call(rw, "resources/read", { uri: "ui://forge/nope" });
566 expect(nope.body.error.code).toBe(-32002);
567 const tools = (await call(rw, "tools/list")).body.result.tools;
568 const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri;
569 expect(meta("apps")).toBe("ui://forge/app");
570 expect(meta("now")).toBe("ui://forge/run");
571 expect(meta("asks")).toBe("ui://forge/decision");
572 expect(meta("milestones")).toBe("ui://forge/wave");
573 });
574
575 test("a tool result carries the card it renders", async () => {
576 const { body } = await call(rw, "tools/call", { name: "apps", arguments: {} });
577 const card = body.result.structuredContent.card;
578 expect(card.kind).toBe("app");
579 expect(card.rows.some((r: any) => r.label === "alice/game")).toBe(true);
580 const asks = await call(rw, "tools/call", { name: "asks", arguments: {} });
581 expect(asks.body.result.structuredContent.card.kind).toBe("decision");
582 });
583
584 test("ship and close card actions never apply at once and always meet the target tool's required fields", async () => {
585 const { cardFor } = await import("../src/web/studio/cards.ts");
586 const tools = (await call(rw, "tools/list")).body.result.tools;
587 const cards = [
588 cardFor("apps", [{ app: "alice/game", live: "up" }]),
589 cardFor("now", [{ who: "scout", app: "alice/game", run: "r1", on: "#1", state: "going" }]),
590 cardFor("asks", {
591 rows: [
592 { line: "a", kind: "look", items: ["x:1"], words: {} },
593 { line: "b", kind: "review", items: ["x:2"], words: {} },
594 ],
595 }),
596 ];
597 let actions = 0;
598 for (const card of cards)
599 for (const a of card!.actions) {
600 actions++;
601 expect(a.args["dryRun"]).not.toBe(false);
602 const t = tools.find((x: any) => x.name === a.tool);
603 for (const f of t.inputSchema.required ?? []) {
604 expect(a.args[f]).toBeDefined();
605 expect(a.args[f]).not.toBe("");
606 }
607 }
608 expect(actions).toBeGreaterThan(2);
609 });
610
611 test("a tell carries where it was told from, and refuses a malformed context", async () => {
612 const told = await tool(rw, "tell", {
613 repo: "alice/game",
614 text: "make the button blue",
615 context: {
616 page: "https://game.example/play?level=2",
617 run: "run_abc",
618 commit: "deadbeef1",
619 said: "the button is hard to see",
620 },
621 });
622 const issues = listIssues(app.db, game.id, {});
623 const issue = issues.find((i) => i.title === "make the button blue");
624 expect(issue?.body).toContain("page: https://game.example/play?level=2");
625 expect(issue?.body).toContain("commit: deadbeef1");
626 expect(issue?.body).toContain("their words: the button is hard to see");
627 expect(told).toBeDefined();
628 for (const context of [
629 { page: "javascript:alert(1)" },
630 { commit: "--upload-pack=x" },
631 "text",
632 ]) {
633 const refused = await tool(rw, "tell", { repo: "alice/game", text: "x", context });
634 expect(refused.error).toContain("invalid");
635 }
636 });
637
638 test("GET /contract/openapi.json describes the public API", async () => {
639 const res = await fetch(`${base}/contract/openapi.json`);
640 expect(res.status).toBe(200);
641 const doc = (await res.json()) as any;
642 expect(doc.openapi).toBe("3.1.0");
643 expect(doc.paths["/mcp"].post).toBeDefined();
644 });
549645 });
test/openapi.test.ts added +22
@@ -0,0 +1,22 @@
1 /** The OpenAPI document names every route the manifest names. */
2 import { expect, test } from "bun:test";
3 import { readFileSync } from "node:fs";
4 import { openapi } from "../src/contract/openapi.ts";
5
6 test("every http route the manifest names is described", () => {
7 const src = readFileSync("src/actions/here.ts", "utf8");
8 const named = [...src.matchAll(/http: [`"]([^`"]+)[`"]/g)].flatMap((m) =>
9 m[1]!.split(", then ").map((one) => {
10 const [method, path] = one.replace(" {headRepo}", "").split(" ");
11 return [method!.toLowerCase(), path!.replaceAll("${r}", "/api/v1/repos/{owner}/{name}")] as const;
12 }),
13 );
14 expect(named.length).toBeGreaterThan(5);
15 const doc = openapi("https://f.test", "1") as any;
16 for (const [method, path] of named) {
17 const p = path.replace(/\{(\w+)\}/g, "{$1}");
18 expect(doc.paths[p]?.[method], `${method} ${p}`).toBeDefined();
19 }
20 expect(doc.openapi).toBe("3.1.0");
21 expect(doc.paths["/api/v1/repos/{owner}/{name}/issues"].post.security).toBeDefined();
22 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs