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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1116-one-mcp-server-beside-every-harness-tool → main 5 commits fork 6 threads · 6 unresolved
docs/MCP.md +19
| @@ -131,3 +131,22 @@ answers | ||
| 131 | 131 | "opened": [{ "key": "board", "number": 7 }, { "key": "moves", "number": 8 }, { "key": "play", "number": 9 }], |
| 132 | 132 | "ready": [7, 8], "later": [] } |
| 133 | 133 | |
| 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 | |
| 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[] = [ | ||
| 212 | 212 | "test/app-env.test.ts", |
| 213 | 213 | "test/apps-ssh.test.ts", |
| 214 | 214 | "test/pacing.test.ts", |
| 215 | "test/openapi.test.ts", | |
| 215 | 216 | "test/palette.test.ts", |
| 216 | 217 | "test/qr.test.ts", |
| 217 | 218 | "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[] = [ | ||
| 75 | 75 | "manifest.webmanifest", |
| 76 | 76 | "map", |
| 77 | 77 | "mcp", |
| 78 | "contract", | |
| 78 | 79 | "migrate", |
| 79 | 80 | "mode", |
| 80 | 81 | "new", |
src/http/mcp.ts +76 −3
| @@ -37,6 +37,15 @@ import { openPlan, parsePlan, PLAN_MAX } from "../actions/plan.ts"; | ||
| 37 | 37 | import { cancelRepoRun } from "../actions/runs.ts"; |
| 38 | 38 | import { clientRows } from "../spawn/clients.ts"; |
| 39 | 39 | 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"; | |
| 40 | 49 | import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts"; |
| 41 | 50 | import { readReturns, reportSpend } from "../actions/returns.ts"; |
| 42 | 51 | import { |
| @@ -73,6 +82,7 @@ import { plannerDeclines } from "../crew/autonomy.ts"; | ||
| 73 | 82 | import { listActiveRuns } from "../core/runs.ts"; |
| 74 | 83 | import { scopeAllows } from "../core/tokens.ts"; |
| 75 | 84 | import { getUsersByIds, type User } from "../core/users.ts"; |
| 85 | import { openapi } from "../contract/openapi.ts"; | |
| 76 | 86 | import { audit } from "../contract/audit.ts"; |
| 77 | 87 | import { probeApp } from "../contract/probes.ts"; |
| 78 | 88 | import { convergeFork, convergeState } from "../crew/converge.ts"; |
| @@ -272,14 +282,41 @@ const TOOLS: readonly Tool[] = [ | ||
| 272 | 282 | "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.", |
| 273 | 283 | inputSchema: { |
| 274 | 284 | 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 | }, | |
| 276 | 300 | required: ["repo", "text"], |
| 277 | 301 | }, |
| 278 | 302 | writes: true, |
| 279 | 303 | async run(ctx, user, args) { |
| 280 | 304 | const repo = repoArg(ctx, user, args); |
| 281 | 305 | 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 | ); | |
| 283 | 320 | if (!told.ok) |
| 284 | 321 | return bad( |
| 285 | 322 | told.error, |
| @@ -1651,6 +1688,12 @@ function adminNote(ctx: Ctx, user: User): string { | ||
| 1651 | 1688 | }`; |
| 1652 | 1689 | } |
| 1653 | 1690 | |
| 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 | ||
| 1654 | 1697 | function rpcOne( |
| 1655 | 1698 | ctx: Ctx, |
| 1656 | 1699 | user: User, |
| @@ -1682,7 +1725,7 @@ function rpcOne( | ||
| 1682 | 1725 | protocolVersion: (PROTOCOLS as readonly string[]).includes(asked) |
| 1683 | 1726 | ? asked |
| 1684 | 1727 | : PROTOCOLS[0], |
| 1685 | capabilities: { tools: {} }, | |
| 1728 | capabilities: { tools: {}, resources: {} }, | |
| 1686 | 1729 | serverInfo: server(), |
| 1687 | 1730 | 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)}`, |
| 1688 | 1731 | }); |
| @@ -1697,10 +1740,33 @@ function rpcOne( | ||
| 1697 | 1740 | description: t.description, |
| 1698 | 1741 | inputSchema: t.inputSchema, |
| 1699 | 1742 | 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 | }), | |
| 1700 | 1750 | })), |
| 1701 | 1751 | REPORT_TOOL, |
| 1702 | 1752 | ], |
| 1703 | 1753 | }); |
| 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 | } | |
| 1704 | 1770 | case "tools/call": { |
| 1705 | 1771 | const tool = TOOLS.find((t) => t.name === params["name"]); |
| 1706 | 1772 | if (tool === undefined) |
| @@ -1722,6 +1788,7 @@ function rpcOne( | ||
| 1722 | 1788 | answer.ok |
| 1723 | 1789 | ? { |
| 1724 | 1790 | content: [{ type: "text", text: JSON.stringify(answer.value) }], |
| 1791 | ...cardResult(String(params["name"]), answer.value), | |
| 1725 | 1792 | } |
| 1726 | 1793 | : { |
| 1727 | 1794 | content: [ |
| @@ -1881,4 +1948,10 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 1881 | 1948 | // for a proxy to cut. |
| 1882 | 1949 | router.add("GET", "/mcp", () => NOT_HERE("POST")); |
| 1883 | 1950 | 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 | ); | |
| 1884 | 1957 | } |
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 |
||
| 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: |
||
| 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: |
||
| 111 | const repo = obj(obj(value)["repo"]); | |
outdated
Medium: |
||
| 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 |
||
| 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", () => { | ||
| 131 | 131 | expect(init.body.result.protocolVersion).toBe("2025-03-26"); |
| 132 | 132 | expect(init.body.result.serverInfo.name).toBe("forge"); |
| 133 | 133 | expect(init.body.result.capabilities.tools).toEqual({}); |
| 134 | expect(init.body.result.capabilities.resources).toEqual({}); | |
| 134 | 135 | // A version it has never heard of gets the one it prefers. |
| 135 | 136 | const future = await call(rw, "initialize", { |
| 136 | 137 | protocolVersion: "2031-01-01", |
| @@ -546,4 +547,99 @@ describe("the forge over MCP", () => { | ||
| 546 | 547 | }); |
| 547 | 548 | expect(res.status).toBe(401); |
| 548 | 549 | }); |
| 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 | }); | |
| 549 | 645 | }); |
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 | }); |
Once
cardHtmlis gone, drop "which also renders it as escaped HTML".