Public: anyone can read this; the crew can change it.
MCP `new_app` makes an app from a sentence alone: the name is derived as the web and terminal do #8459
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8426-mcp-new-app-makes-an-app-from-a-sentence → main 4 commits fork
3 files
changed +57
−33
src/actions/apps.ts +5 −5
| @@ -461,8 +461,8 @@ export const NEW_APP_ERRORS: Record<string, string> = { | ||
| 461 | 461 | no_such_agent: NO_SUCH_AGENT, |
| 462 | 462 | }; |
| 463 | 463 | |
| 464 | export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name" | "description"> { | |
| 465 | /** The sentence the app is named from; it is also the first issue's description. */ | |
| 464 | export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name"> { | |
| 465 | /** The sentence the app is named from; it is also the first issue's description unless `description` is given. */ | |
| 466 | 466 | sentence: string; |
| 467 | 467 | } |
| 468 | 468 | |
| @@ -478,16 +478,16 @@ export type NewAppFromSentence = | ||
| 478 | 478 | export async function newAppFromSentence( |
| 479 | 479 | app: App, |
| 480 | 480 | actor: User | undefined, |
| 481 | { sentence, ...rest }: NewAppFromSentenceInput, | |
| 481 | { sentence, description, ...rest }: NewAppFromSentenceInput, | |
| 482 | 482 | onPhase?: (name: string, ms: number) => void, |
| 483 | 483 | ): Promise<NewAppFromSentence> { |
| 484 | 484 | const base = deriveAppName(sentence); |
| 485 | 485 | if (base === undefined) return { ok: false, code: "no_name" }; |
| 486 | 486 | let name = base; |
| 487 | let made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase); | |
| 487 | let made = await newApp(app, actor, { ...rest, description: description ?? sentence, name }, onPhase); | |
| 488 | 488 | for (let n = 2; !made.ok && made.error === "name_taken" && n <= MAX_NAME_ATTEMPTS; n++) { |
| 489 | 489 | name = `${base}-${n}`; |
| 490 | made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase); | |
| 490 | made = await newApp(app, actor, { ...rest, description: description ?? sentence, name }, onPhase); | |
| 491 | 491 | } |
| 492 | 492 | return made.ok ? { ok: true, name, ...made.value } : { ok: false, code: made.error }; |
| 493 | 493 | } |
src/http/mcp/tools/new_app.ts +25 −20
| @@ -2,22 +2,31 @@ import { appsRuntime } from "../../../apps/runtime.ts"; | ||
| 2 | 2 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 3 | 3 | import { getUserByName } from "../../../core/users.ts"; |
| 4 | 4 | import { repositoryLimitMessage } from "../../../actions/repos.ts"; |
| 5 | import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput, type NewAppResult } from "../../../actions/apps.ts"; | |
| 5 | import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts"; | |
| 6 | 6 | import { appLimitWords } from "../../../actions/app_limit.ts"; |
| 7 | 7 | import { storageLimitMessage } from "../../../core/storage.ts"; |
| 8 | 8 | import { VERIFY_FIRST } from "../../../actions/verify_words.ts"; |
| 9 | import { err, ok } from "../../../kernel/result.ts"; | |
| 10 | import type { User } from "../../../core/users.ts"; | |
| 11 | import type { App } from "../../../app.ts"; | |
| 9 | 12 | import { good, bad, type Tool, str } from "../shared.ts"; |
| 10 | 13 | |
| 14 | /** `newAppFromSentence`, answered in `newApp`'s shape so one switch maps both paths' refusals. */ | |
| 15 | async function fromSentence(app: App, user: User, sentence: string, input: Omit<NewAppInput, "name">) { | |
| 16 | const derived = await newAppFromSentence(app, user, { ...input, sentence }); | |
| 17 | return derived.ok ? ok(derived) : err(derived.code); | |
| 18 | } | |
| 19 | ||
| 11 | 20 | export const tool: Tool = { |
| 12 | 21 | name: "new_app", |
| 13 | doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; `name` wins when both are given, and `sentence` alone derives the name (`no_name` when nothing is left to name from); answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 22 | doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 14 | 23 | description: |
| 15 | 24 | "Make an app from a sentence: a repo, production, and with `description` a first issue.", |
| 16 | 25 | inputSchema: { |
| 17 | 26 | type: "object", |
| 18 | 27 | properties: { |
| 19 | 28 | name: { type: "string", maxLength: 100 }, |
| 20 | sentence: { type: "string", maxLength: 4000, description: "Names the app when `name` is absent." }, | |
| 29 | sentence: { type: "string", maxLength: 4000 }, | |
| 21 | 30 | description: { type: "string", maxLength: 4000, description: "The first issue." }, |
| 22 | 31 | owner: { type: "string", maxLength: 39 }, |
| 23 | 32 | visibility: { type: "string", enum: ["public", "private"] }, |
| @@ -32,26 +41,24 @@ export const tool: Tool = { | ||
| 32 | 41 | const owner = text("owner"); |
| 33 | 42 | const description = text("description"); |
| 34 | 43 | const agent = text("agent"); |
| 35 | const options: Omit<NewAppInput, "name" | "description"> = { | |
| 44 | const sentence = text("sentence"); | |
| 45 | const rest: Omit<NewAppInput, "name" | "description"> = { | |
| 36 | 46 | ...(owner === undefined ? {} : { owner }), |
| 37 | 47 | ...(visibility === "public" || visibility === "private" ? { visibility } : {}), |
| 38 | 48 | ...(agent === undefined ? {} : { agent }), |
| 39 | 49 | ...(args["pick"] === true ? { pickAgent: true } : {}), |
| 40 | 50 | }; |
| 41 | const sentence = text("sentence"); | |
| 42 | let made: { ok: true; value: NewAppResult } | { ok: false; error: string }; | |
| 43 | if (str(args["name"]) === "" && sentence !== undefined) { | |
| 44 | const r = await newAppFromSentence(ctx.app, user, { ...options, sentence: str(sentence) }); | |
| 45 | made = r.ok ? { ok: true, value: r } : { ok: false, error: r.code }; | |
| 46 | } else { | |
| 47 | made = await newApp(ctx.app, user, { | |
| 48 | name: str(args["name"]), | |
| 49 | ...(description === undefined ? {} : { description }), | |
| 50 | ...options, | |
| 51 | }); | |
| 52 | } | |
| 51 | const given = str(args["name"]).trim(); | |
| 52 | const first = description ?? sentence; | |
| 53 | const input = { ...rest, ...(first === undefined ? {} : { description: first }) }; | |
| 54 | const made = | |
| 55 | given !== "" | |
| 56 | ? await newApp(ctx.app, user, { ...input, name: given }) | |
| 57 | : await fromSentence(ctx.app, user, sentence ?? "", input); | |
| 53 | 58 | if (!made.ok) { |
| 54 | 59 | switch (made.error) { |
| 60 | case "no_name": | |
| 61 | return bad("no_name", NO_NAME_ERROR); | |
| 55 | 62 | case "unverified": |
| 56 | 63 | return bad("unverified", VERIFY_FIRST); |
| 57 | 64 | case "app_limit": { |
| @@ -62,20 +69,18 @@ export const tool: Tool = { | ||
| 62 | 69 | return bad("repository_limit", repositoryLimitMessage(ctx.app, user)); |
| 63 | 70 | case "storage_limit": |
| 64 | 71 | return bad("storage_limit", storageLimitMessage(ctx.app, user.id)); |
| 65 | case "no_name": | |
| 66 | return bad("no_name", NO_NAME_ERROR); | |
| 67 | 72 | default: |
| 68 | 73 | return bad(made.error, made.error); |
| 69 | 74 | } |
| 70 | 75 | } |
| 71 | 76 | const { repo, app: record, issue } = made.value; |
| 72 | const first = listActiveRuns(ctx.app.db, [repo.id])[0]; | |
| 77 | const run = listActiveRuns(ctx.app.db, [repo.id])[0]; | |
| 73 | 78 | return good({ |
| 74 | 79 | repo: `${repo.ownerName}/${repo.name}`, |
| 75 | 80 | url: appsRuntime(ctx.app)?.urlFor(record, "production") ?? null, |
| 76 | 81 | issue: issue === undefined ? null : issue.number, |
| 77 | 82 | agent: issue?.assigneeName ?? null, |
| 78 | run: first?.state ?? "none", | |
| 83 | run: run?.state ?? "none", | |
| 79 | 84 | }); |
| 80 | 85 | }, |
| 81 | 86 | }; |
test/mcp.test.ts +27 −8
| @@ -20,7 +20,7 @@ import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | ||
| 20 | 20 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 21 | 21 | import { addComment, createIssue, listIssues } from "../src/core/issues.ts"; |
| 22 | 22 | import { addDependency } from "../src/core/dependencies.ts"; |
| 23 | import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 23 | import { getRepo, getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 24 | 24 | import { createRun, listRuns, setRunBase } from "../src/core/runs.ts"; |
| 25 | 25 | import { getAccountCeiling } from "../src/core/account_ceiling.ts"; |
| 26 | 26 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -1658,14 +1658,33 @@ describe("the forge over MCP", () => { | ||
| 1658 | 1658 | expect((await tool(rw, "apps") as any).value.some((r: any) => r.app === "alice/readonly")).toBe(false); |
| 1659 | 1659 | }, 30_000); |
| 1660 | 1660 | |
| 1661 | test("new_app with a sentence and no name derives the name; name wins when both are given", async () => { | |
| 1662 | const first = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any; | |
| 1663 | expect(first.error).toBeUndefined(); | |
| 1664 | const again = (await tool(rw, "new_app", { sentence: "Keep notes" })) as any; | |
| 1665 | expect(again.value.repo).toBe(`${first.value.repo}-2`); | |
| 1666 | const named = (await tool(rw, "new_app", { name: "wins", sentence: "Something else" })) as any; | |
| 1667 | expect(named.value.repo).toBe("alice/wins"); | |
| 1661 | test("new_app names an app from a sentence alone, and -2 when taken", async () => { | |
| 1662 | const one = (await tool(rw, "new_app", { sentence: "a garden planner for my allotment" })) as any; | |
| 1663 | expect(one.value.repo).toMatch(/^alice\/[a-z0-9-]+$/); | |
| 1664 | expect(one.value.issue).toBe(1); | |
| 1665 | const two = (await tool(rw, "new_app", { sentence: "a garden planner for my allotment" })) as any; | |
| 1666 | expect(two.value.repo).toBe(`${one.value.repo}-2`); | |
| 1667 | const named = (await tool(rw, "new_app", { name: "keepname", sentence: "a garden planner" })) as any; | |
| 1668 | expect(named.value.repo).toBe("alice/keepname"); | |
| 1669 | expect(named.value.issue).toBe(1); | |
| 1670 | // The derived path forwards visibility and agent, and `description` is the first issue over `sentence`. | |
| 1671 | const bot = await createAgent(app, alice, { username: "sentbot", brief: "builds" }); | |
| 1672 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 1673 | const fwd = (await tool(rw, "new_app", { | |
| 1674 | sentence: "a recipe box for the family", | |
| 1675 | description: "Start with a list of recipes.", | |
| 1676 | visibility: "private", | |
| 1677 | agent: "sentbot", | |
| 1678 | })) as any; | |
| 1679 | expect(fwd.value.agent).toBe("sentbot"); | |
| 1680 | const [slug] = fwd.value.repo.split("/").slice(1); | |
| 1681 | const made = getRepo(app.db, "alice", slug)!; | |
| 1682 | expect(made.visibility).toBe("private"); | |
| 1683 | expect(listIssues(app.db, made.id, { state: "open" })[0]!.body).toContain("Start with a list of recipes."); | |
| 1684 | // A refusal maps as on the named path. | |
| 1685 | expect((await tool(rw, "new_app", { sentence: "a tide table", agent: "nobody-here" })).error).toContain("no_such_agent"); | |
| 1668 | 1686 | expect((await tool(rw, "new_app", { sentence: "!!!" })).error).toContain("no_name"); |
| 1687 | expect((await tool(rw, "new_app", {})).error).toContain("no_name"); | |
| 1669 | 1688 | }, 30_000); |
| 1670 | 1689 | |
| 1671 | 1690 | test("new_app with pick lets the planner name the agent", async () => { |
new above
offline