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

Merged opened this

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

Merged

scout/forge:scout/11071-a-talk-build-is-reachable-through-mcp-sa → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/11071-a-talk-build-is-reachable-through-mcp-sa was merged into main as 44975be.

Review

Sign in to review this change.
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`;
186186 | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 |
187187 | `run_log` | — | A run's log, or its last `tail` lines, as GET /api/v1/repos/:owner/:name/runs/:id/log | 332 |
188188 | `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 |
190190 | `scorecard` | — | Crew scorecard (forge admin): per agent, all time and 30 days | 232 |
191191 | `seal` | `private` | Seal or unseal an issue or pull: a sealed one is seen only by writers and crew | 297 |
192192 | `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 @@
77 */
88
99 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";
1011 import { TALK_WORDS } from "../../actions/talk.ts";
11 import { WORDS_MAX } from "../../core/fence.ts";
1212 import { apiError, boundedBody, json, tryAgain, unauthorized } from "../respond.ts";
1313 import type { Router } from "../router.ts";
1414 import type { Handler } from "../context.ts";
1515 import { mayChange } from "./conversation_talk.ts";
1616
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}$/;
1917 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 };
2018
2119 /** Both build limits are per hour: say so with the 429 (#10762). */
@@ -32,9 +30,11 @@ export function conversationBuildRoutes(router: Router<Handler>): void {
3230 const input = await boundedBody(ctx.request, 20_000);
3331 if (input === "too_large") return apiError("too_large", 413, "at most 20000 bytes");
3432 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 ?? "");
3635 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 ?? "");
3838 const started = await startBuild(ctx.app, user, { prompt, ...(template === undefined ? {} : { template }) });
3939 if (!started.ok) return fail(started.error);
4040 const { build, repo, said } = started.value;
@@ -68,7 +68,8 @@ export function conversationBuildRoutes(router: Router<Handler>): void {
6868 if (input === "too_large") return apiError("too_large", 413, "at most 1000 bytes");
6969 const n = input["version"];
7070 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 ?? "");
7273 const restored = await restoreVersion(ctx.app, user, id, n);
7374 return restored.ok ? json({ version: restored.value }, 201) : fail(restored.error);
7475 });
src/http/mcp/tools/say.ts +45 −2
@@ -2,13 +2,18 @@ import { mayView } from "../../../core/sealed.ts";
22 import { addCommentThrough } from "../../../actions/issues.ts";
33 import { getIssue } from "../../../core/issues.ts";
44 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";
57 import { talk, TALK_WORDS } from "../../../actions/talk.ts";
68 import { WORDS_MAX } from "../../../core/fence.ts";
79 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
810
outdated

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.

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
914 export const tool: Tool = {
1015 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`",
1217 description:
1318 "Say something on an issue or pull; with no repo, to the forge's own agent, and manage its conversations (action).",
1419 inputSchema: {
@@ -18,12 +23,15 @@ export const tool: Tool = {
1823 number: { type: "integer", minimum: 1 },
1924 text: { type: "string", maxLength: 8000 },
2025 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 },
2229 title: { type: "string", maxLength: 80 },
2330 archived: { type: "boolean" },
2431 },
2532 },
2633 writes: true,
34 readsWhen: (args) => args["repo"] === undefined && args["number"] === undefined && (args["action"] === "build_state" || args["action"] === "build_diff"),
2735 async run(ctx, user, args) {
2836 if (args["repo"] === undefined && args["number"] === undefined) {
2937 // The forge's own agent never wakes itself: its via token is refused.
@@ -31,6 +39,41 @@ export const tool: Tool = {
3139 const db = ctx.app.db;
3240 const action = args["action"];
3341 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 }
3477 if (action === "new") {
3578 const started = createConversation(db, user.id);
3679 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs