Public: anyone can read this; the crew can change it.
A talk build is reachable through MCP: `say` starts it, reads its versions and diffs, restores and publishes #11079
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11071-a-talk-build-is-reachable-through-mcp-sa → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +191
−9
docs/MCP.md +1 −1
| @@ -186,7 +186,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 186 | 186 | | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 | |
| 187 | 187 | | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 | |
| 188 | 188 | | `runs` | `agent`, `deploy`, `check` | A repository's runs, newest first: id, kind, state, number, start, end | 482 | |
| 189 | | `say` | `list`, `new`, `rename`, `archive`, `restore` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 448 | | |
| 189 | | `say` | `list`, `new`, `rename`, `archive`, `restore`, `build`, `build_state`, `build_diff`, `build_restore`, `build_publish` | Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action) | 585 | | |
| 190 | 190 | | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 232 | |
| 191 | 191 | | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 | |
| 192 | 192 | | `search` | `all`, `issues`, `repos`, `code` | Find issues, pulls, repositories and code you can read; `kind` and `repo` narrow | 378 | |
src/actions/build_input.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | /** | |
| 2 | * What a talk build accepts at its boundary, once, for both doors (the API in | |
| 3 | * `http/api/conversation_build.ts` and MCP `say`): each check answers the | |
| 4 | * sentence that refuses the input, or undefined when it is fine. | |
| 5 | */ | |
| 6 | ||
| 7 | import { WORDS_MAX } from "../core/fence.ts"; | |
| 8 | ||
| 9 | export const BUILD_ID = /^[0-9a-z]{1,40}$/; | |
| 10 | export const BUILD_TEMPLATE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; | |
| 11 | ||
| 12 | /** `field` names the argument in the sentence: `prompt` for the API, `text` for MCP. */ | |
| 13 | export const promptRefusal = (prompt: unknown, field = "prompt"): string | undefined => | |
| 14 | typeof prompt !== "string" || prompt.trim() === "" || prompt.length > WORDS_MAX ? `${field} is a string of 1 to ${WORDS_MAX} characters` : undefined; | |
| 15 | ||
| 16 | export const templateRefusal = (template: unknown): string | undefined => | |
| 17 | template === undefined || (typeof template === "string" && BUILD_TEMPLATE.test(template)) ? undefined : "template is owner/name"; | |
| 18 | ||
| 19 | export const versionRefusal = (n: unknown): string | undefined => | |
| 20 | typeof n === "number" && Number.isInteger(n) && n >= 1 ? undefined : "version is the number of a version, from 1"; |
src/http/api/conversation_build.ts +7 −6
| @@ -7,15 +7,13 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { BUILD_WORDS, buildState, publishBuild, restoreVersion, startBuild, versionDiff, type BuildError } from "../../actions/build.ts"; |
| 10 | import { BUILD_ID as ID, promptRefusal, templateRefusal, versionRefusal } from "../../actions/build_input.ts"; | |
| 10 | 11 | import { TALK_WORDS } from "../../actions/talk.ts"; |
| 11 | import { WORDS_MAX } from "../../core/fence.ts"; | |
| 12 | 12 | import { apiError, boundedBody, json, tryAgain, unauthorized } from "../respond.ts"; |
| 13 | 13 | import type { Router } from "../router.ts"; |
| 14 | 14 | import type { Handler } from "../context.ts"; |
| 15 | 15 | import { mayChange } from "./conversation_talk.ts"; |
| 16 | 16 | |
| 17 | const ID = /^[0-9a-z]{1,40}$/; | |
| 18 | const TEMPLATE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/; | |
| 19 | 17 | const STATUS: Readonly<Record<BuildError, number>> = { unauthorized: 401, not_found: 404, no_name: 422, no_version: 404, nothing_to_publish: 409, moved: 409, bad_template: 422, rate_limited: 422, too_many_builds: 422, failed: 422 }; |
| 20 | 18 | |
| 21 | 19 | /** Both build limits are per hour: say so with the 429 (#10762). */ |
| @@ -32,9 +30,11 @@ export function conversationBuildRoutes(router: Router<Handler>): void { | ||
| 32 | 30 | const input = await boundedBody(ctx.request, 20_000); |
| 33 | 31 | if (input === "too_large") return apiError("too_large", 413, "at most 20000 bytes"); |
| 34 | 32 | const prompt = input["prompt"]; |
| 35 | if (typeof prompt !== "string" || prompt.trim() === "" || prompt.length > WORDS_MAX) return apiError("invalid_prompt", 422, `prompt is a string of 1 to ${WORDS_MAX} characters`); | |
| 33 | const badPrompt = promptRefusal(prompt); | |
| 34 | if (badPrompt !== undefined || typeof prompt !== "string") return apiError("invalid_prompt", 422, badPrompt ?? ""); | |
| 36 | 35 | const template = input["template"]; |
| 37 | if (template !== undefined && (typeof template !== "string" || !TEMPLATE.test(template))) return apiError("invalid_template", 422, "template is owner/name"); | |
| 36 | const badTemplate = templateRefusal(template); | |
| 37 | if (badTemplate !== undefined || (template !== undefined && typeof template !== "string")) return apiError("invalid_template", 422, badTemplate ?? ""); | |
| 38 | 38 | const started = await startBuild(ctx.app, user, { prompt, ...(template === undefined ? {} : { template }) }); |
| 39 | 39 | if (!started.ok) return fail(started.error); |
| 40 | 40 | const { build, repo, said } = started.value; |
| @@ -68,7 +68,8 @@ export function conversationBuildRoutes(router: Router<Handler>): void { | ||
| 68 | 68 | if (input === "too_large") return apiError("too_large", 413, "at most 1000 bytes"); |
| 69 | 69 | const n = input["version"]; |
| 70 | 70 | if (id === undefined) return fail("not_found"); |
| 71 | if (typeof n !== "number" || !Number.isInteger(n) || n < 1) return apiError("invalid_version", 422, "version is the number of a version, from 1"); | |
| 71 | const badVersion = versionRefusal(n); | |
| 72 | if (badVersion !== undefined || typeof n !== "number") return apiError("invalid_version", 422, badVersion ?? ""); | |
| 72 | 73 | const restored = await restoreVersion(ctx.app, user, id, n); |
| 73 | 74 | return restored.ok ? json({ version: restored.value }, 201) : fail(restored.error); |
| 74 | 75 | }); |
src/http/mcp/tools/say.ts +45 −2
| @@ -2,13 +2,18 @@ import { mayView } from "../../../core/sealed.ts"; | ||
| 2 | 2 | import { addCommentThrough } from "../../../actions/issues.ts"; |
| 3 | 3 | import { getIssue } from "../../../core/issues.ts"; |
| 4 | 4 | import { archiveConversation, createConversation, getConversation, listConversations, renameConversation, TOO_MANY_WORDS } from "../../../core/conversations.ts"; |
| 5 | import { BUILD_WORDS, buildState, publishBuild, restoreVersion, startBuild, versionDiff, type BuildError } from "../../../actions/build.ts"; | |
| 6 | import { BUILD_ID, promptRefusal, templateRefusal, versionRefusal } from "../../../actions/build_input.ts"; | |
| 5 | 7 | import { talk, TALK_WORDS } from "../../../actions/talk.ts"; |
| 6 | 8 | import { WORDS_MAX } from "../../../core/fence.ts"; |
| 7 | 9 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 8 | 10 | |
| 11 | const BUILD_ACTIONS = ["build", "build_state", "build_diff", "build_restore", "build_publish"]; | |
| 12 | const buildBad = (error: BuildError) => bad(error, BUILD_WORDS[error] ?? error); | |
| 13 | ||
| 9 | 14 | export const tool: Tool = { |
| 10 | 15 | name: "say", |
| 11 | doc: "`{ repo?, number?, text?, conversation?, action?, title?, archived? }`: speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`. `conversation` names one of your conversations (default: the latest); `action` list | new | rename | archive | restore manages them (`title` for rename and new; `archived` lists archived ones)", | |
| 16 | doc: "`{ repo?, number?, text?, conversation?, action?, title?, archived? }`: speak on an issue or a pull; a working builder hears it as its next step. With no `repo` or `number` it speaks to the forge's own agent (`forge talk`), which answers in `GET /api/v1/conversation/turns`. `conversation` names one of your conversations (default: the latest); `action` list | new | rename | archive | restore manages them (`title` for rename and new; `archived` lists archived ones); `action` build (`text` prompt, `template`) | build_state | build_diff (`version`) | build_restore (`version`) | build_publish drives a talk build of `conversation`", | |
| 12 | 17 | description: |
| 13 | 18 | "Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action).", |
| 14 | 19 | inputSchema: { |
| @@ -18,12 +23,15 @@ export const tool: Tool = { | ||
| 18 | 23 | number: { type: "integer", minimum: 1 }, |
| 19 | 24 | text: { type: "string", maxLength: 8000 }, |
| 20 | 25 | conversation: { type: "string" }, |
| 21 | action: { type: "string", enum: ["list", "new", "rename", "archive", "restore"] }, | |
| 26 | action: { type: "string", enum: ["list", "new", "rename", "archive", "restore", ...BUILD_ACTIONS] }, | |
| 27 | template: { type: "string" }, | |
| 28 | version: { type: "integer", minimum: 1 }, | |
| 22 | 29 | title: { type: "string", maxLength: 80 }, |
| 23 | 30 | archived: { type: "boolean" }, |
| 24 | 31 | }, |
| 25 | 32 | }, |
| 26 | 33 | writes: true, |
| 34 | readsWhen: (args) => args["repo"] === undefined && args["number"] === undefined && (args["action"] === "build_state" || args["action"] === "build_diff"), | |
| 27 | 35 | async run(ctx, user, args) { |
| 28 | 36 | if (args["repo"] === undefined && args["number"] === undefined) { |
| 29 | 37 | // The forge's own agent never wakes itself: its via token is refused. |
| @@ -31,6 +39,41 @@ export const tool: Tool = { | ||
| 31 | 39 | const db = ctx.app.db; |
| 32 | 40 | const action = args["action"]; |
| 33 | 41 | if (action === "list") return good({ conversations: listConversations(db, user.id, { archived: args["archived"] === true }).map(({ id, title, updatedAt }) => ({ id, title, updatedAt })) }); |
| 42 | if (typeof action === "string" && BUILD_ACTIONS.includes(action)) { | |
| 43 | if (action === "build") { | |
| 44 | const prompt = args["text"]; | |
| 45 | const badPrompt = promptRefusal(prompt, "text"); | |
| 46 | if (badPrompt !== undefined || typeof prompt !== "string") return bad("invalid_prompt", badPrompt ?? ""); | |
| 47 | const template = args["template"]; | |
| 48 | const badTemplate = templateRefusal(template); | |
| 49 | if (badTemplate !== undefined || (template !== undefined && typeof template !== "string")) return bad("invalid_template", badTemplate ?? ""); | |
| 50 | const started = await startBuild(ctx.app, user, { prompt, ...(template === undefined ? {} : { template }) }); | |
| 51 | if (!started.ok) return buildBad(started.error); | |
| 52 | const { build, repo, said } = started.value; | |
| 53 | return good({ conversation: build.conversationId, repo: `${repo.ownerName}/${repo.name}`, branch: build.branch, ...(said === undefined ? {} : { said, message: TALK_WORDS[said][1] }) }); | |
| 54 | } | |
| 55 | const id = str(args["conversation"]); | |
| 56 | if (!BUILD_ID.test(id)) return buildBad("not_found"); | |
| 57 | if (action === "build_state") { | |
| 58 | const state = await buildState(ctx.app, user, id); | |
| 59 | if (!state.ok) return buildBad(state.error); | |
| 60 | const v = state.value; | |
| 61 | return good({ repo: v.repoName, branch: v.build.branch, versions: v.versions, previewUrl: v.previewUrl, previewState: v.previewState, published: v.published, productionUrl: v.productionUrl }); | |
| 62 | } | |
| 63 | if (action === "build_publish") { | |
| 64 | const published = await publishBuild(ctx.app, user, id); | |
| 65 | return published.ok ? good({ published: true, productionUrl: published.value.productionUrl }) : buildBad(published.error); | |
| 66 | } | |
| 67 | const n = args["version"]; | |
| 68 | const badVersion = versionRefusal(n); | |
| 69 | if (badVersion !== undefined || typeof n !== "number") return bad("invalid_version", badVersion ?? ""); | |
| 70 | if (action === "build_diff") { | |
| 71 | const diff = await versionDiff(ctx.app, user, id, n); | |
| 72 | return diff.ok ? good(diff.value) : buildBad(diff.error); | |
| 73 | } | |
| 74 | const restored = await restoreVersion(ctx.app, user, id, n); | |
| 75 | return restored.ok ? good({ version: restored.value }) : buildBad(restored.error); | |
| 76 | } | |
| 34 | 77 | if (action === "new") { |
| 35 | 78 | const started = createConversation(db, user.id); |
| 36 | 79 | if (!started.ok) return bad("too_many", TOO_MANY_WORDS); |
test/mcp-say-build.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** `say` drives a talk build through /mcp (#11071): the same functions and words as the API. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { chmodSync, writeFileSync } from "node:fs"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { BUILD_WORDS, BUILDS_PER_HOUR } from "../src/actions/build.ts"; | |
| 10 | import { setTemplateMark } from "../src/core/app_templates.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { configureConversation } from "../src/crew/conversation.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | let dir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let alice: User; | |
| 24 | const tok: Record<string, string> = {}; | |
| 25 | ||
| 26 | async function say(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 30 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "say", arguments: args } }), | |
| 31 | }); | |
| 32 | const body = (await res.json()) as any; | |
| 33 | const text: string = body.result.content[0].text; | |
| 34 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 35 | } | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-say-build-")); | |
| 39 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 40 | servers = await serve(app); | |
| 41 | const make = async (username: string) => { | |
| 42 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 43 | if (!made.ok) throw new Error(made.error); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | alice = await make("alice"); | |
| 47 | const bob = await make("bob"); | |
| 48 | tok["rw"] = createToken(app.db, alice.id, "rw", "rw").token; | |
| 49 | tok["ro"] = createToken(app.db, alice.id, "ro", "ro").token; | |
| 50 | tok["bob"] = createToken(app.db, bob.id, "rw", "rw").token; | |
| 51 | const bin = join(dir, "claude"); | |
| 52 | writeFileSync(bin, `#!/bin/sh | |
| 53 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 54 | IFS= read -r first | |
| 55 | echo '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}' | |
| 56 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Built.","total_cost_usd":0.01,"num_turns":1,"session_id":"s1","terminal_reason":"completed","usage":{"input_tokens":1,"output_tokens":1}}' | |
| 57 | `); | |
| 58 | chmodSync(bin, 0o755); | |
| 59 | configureConversation(app, { binary: bin, localUrl: servers.url, budgetUsd: 1, timeoutMs: 30_000, signal: new AbortController().signal }); | |
| 60 | }, 30_000); | |
| 61 | afterAll(async () => { | |
| 62 | await servers.stop(); | |
| 63 | await rm(dir, { recursive: true, force: true }); | |
| 64 | }); | |
| 65 | ||
| 66 | test("build, state, diff, restore, publish through say", async () => { | |
| 67 | const tpl = await createRepo(app, alice, { name: "kit", visibility: "public", description: "" }); | |
| 68 | if (!tpl.ok) throw new Error(tpl.error); | |
| 69 | const seeded = await commitToBranch(app, alice, tpl.value, { message: "t", files: [{ path: "README.md", content: "# kit\n" }] }); | |
| 70 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 71 | setTemplateMark(app.db, tpl.value, true, alice.id); | |
| 72 | ||
| 73 | // Bad input and a refused template use the API's words. | |
| 74 | expect((await say(tok["rw"]!, { action: "build" })).error).toContain("invalid_prompt"); | |
| 75 | expect((await say(tok["rw"]!, { action: "build", text: "a thing", template: "nope" })).error).toContain("invalid_template"); | |
| 76 | expect((await say(tok["rw"]!, { action: "build", text: "a thing", template: "bob/none" })).error).toContain(BUILD_WORDS["bad_template"]!); | |
| 77 | ||
| 78 | const started = await say(tok["rw"]!, { action: "build", text: "a tip calculator for friends", template: "alice/kit" }); | |
| 79 | const { conversation, repo, branch } = started.value; | |
| 80 | expect(repo).toBe("alice/tip-calculator"); | |
| 81 | expect(branch).toBe(`talk-${conversation}`); | |
| 82 | await Bun.sleep(300); | |
| 83 | ||
| 84 | const empty = await say(tok["rw"]!, { action: "build_state", conversation }); | |
| 85 | expect(empty.value.versions).toEqual([]); | |
| 86 | const r = getRepo(app.db, "alice", "tip-calculator")!; | |
| 87 | for (const [m, c] of [["Add the form", "one\n"], ["Round up", "two\n"]] as const) { | |
| 88 | const done = await commitToBranch(app, alice, r, { branch, message: m, files: [{ path: "page.txt", content: c }] }); | |
| 89 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 90 | } | |
| 91 | const two = await say(tok["rw"]!, { action: "build_state", conversation }); | |
| 92 | expect(two.value.versions.map((v: { number: number }) => v.number)).toEqual([1, 2]); | |
| 93 | ||
| 94 | // A read-only token reads state and diffs, and writes nothing. | |
| 95 | expect((await say(tok["ro"]!, { action: "build_state", conversation })).value.versions.length).toBe(2); | |
| 96 | expect(JSON.stringify((await say(tok["ro"]!, { action: "build_diff", conversation, version: 2 })).value)).toContain("two"); | |
| 97 | expect((await say(tok["rw"]!, { action: "build_diff", conversation, version: 9 })).error).toContain(BUILD_WORDS["no_version"]!); | |
| 98 | expect((await say(tok["rw"]!, { action: "build_diff", conversation })).error).toContain("invalid_version"); | |
| 99 | ||
| 100 | // Another person's conversation is not found, for every action. | |
| 101 | for (const action of ["build_state", "build_diff", "build_restore", "build_publish"]) | |
| 102 | expect((await say(tok["bob"]!, { action, conversation, version: 1 })).error).toContain(BUILD_WORDS["not_found"]!); | |
| 103 | ||
| 104 | // ro is refused on every writing action. | |
| 105 | for (const args of [{ action: "build", text: "x" }, { action: "build_restore", conversation, version: 1 }, { action: "build_publish", conversation }]) | |
| 106 | expect((await say(tok["ro"]!, args)).error).toContain("forbidden"); | |
| 107 | expect(getRepo(app.db, "alice", "x")).toBeUndefined(); | |
| 108 | ||
| 109 | expect((await say(tok["rw"]!, { action: "build_restore", conversation, version: 1 })).value.version.number).toBe(3); | |
| 110 | expect((await say(tok["rw"]!, { action: "build_restore", conversation, version: 7 })).error).toContain(BUILD_WORDS["no_version"]!); | |
| 111 | expect((await say(tok["rw"]!, { action: "build_publish", conversation })).value.published).toBe(true); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the hourly limit speaks the API's words", async () => { | |
| 115 | let last = ""; | |
| 116 | for (let i = 0; i < BUILDS_PER_HOUR + 1; i++) last = (await say(tok["rw"]!, { action: "build", text: `thing number ${i} here` })).error ?? ""; | |
| 117 | expect(last).toContain(BUILD_WORDS["rate_limited"]!); | |
| 118 | }); |
ID and TEMPLATE duplicate the constants in src/http/api/conversation_build.ts (lines 17-18). Export them, and the prompt and version checks, from one module and import them in both places so the two doors cannot drift.