Public: anyone can read this; the crew can change it.
MCP claim_link answers the one line of what the recipient will see first, beside the link #11748
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11494-mcp-claim-link-answers-the-one-line-of-w → main 2 commits fork
2 files
changed +83
−1
src/http/mcp/tools/claim_link.ts +5 −1
| @@ -1,3 +1,5 @@ | ||
| 1 | import { childNamed } from "../../../actions/spawn.ts"; | |
| 2 | import { firstScreenLine } from "../../../core/next-steps.ts"; | |
| 1 | 3 | import { changeDoor, reissueDoor } from "../../../spawn/door.ts"; |
| 2 | 4 | import { good, bad, type Tool, str, REVEAL_ARG, LINK_KEPT, LINK_REVEALED } from "../shared.ts"; |
| 3 | 5 | |
| @@ -26,6 +28,8 @@ export const tool: Tool = { | ||
| 26 | 28 | const door = await reissueDoor(ctx, user, str(args["name"])); |
| 27 | 29 | if (door.status >= 400) |
| 28 | 30 | return bad(String(door.body["error"]), String(door.body["message"])); |
| 29 | return good({ says: LINK_REVEALED, ...door.body }); | |
| 31 | const child = await childNamed(ctx.app, str(args["name"])); | |
| 32 | const firstScreen = child === undefined ? {} : { firstScreen: firstScreenLine(child.apps) }; | |
| 33 | return good({ says: LINK_REVEALED, ...door.body, ...firstScreen }); | |
| 30 | 34 | }, |
| 31 | 35 | }; |
test/mcp-claim-first-screen.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** `claim_link` says, beside the link, what the recipient sees first: the one line the clients page reads. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { firstScreenLine } from "../src/core/next-steps.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { commitFiles } from "../src/git/write.ts"; | |
| 12 | import { repoDir } from "../src/git/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dir = ""; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let token = ""; | |
| 22 | let id = 1; | |
| 23 | ||
| 24 | const post = (path: string, body: unknown) => | |
| 25 | fetch(`${servers.url}${path}`, { | |
| 26 | method: "POST", | |
| 27 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 28 | body: JSON.stringify(body), | |
| 29 | }); | |
| 30 | const claimLink = async (name: string) => { | |
| 31 | const res = await post("/mcp", { | |
| 32 | jsonrpc: "2.0", id: id++, method: "tools/call", | |
| 33 | params: { name: "claim_link", arguments: { name, reveal: true } }, | |
| 34 | }); | |
| 35 | const body = (await res.json()) as any; | |
| 36 | return JSON.parse(body.result.content[0].text) as { firstScreen?: string; claimUrl?: string }; | |
| 37 | }; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dir = await mkdtemp(join(tmpdir(), "forge-claim-first-")); | |
| 41 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 42 | const alice = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true }); | |
| 43 | if (!alice.ok) throw new Error("user"); | |
| 44 | token = createToken(app.db, alice.value.id, "rw", "rw").token; | |
| 45 | for (const n of ["bakery", "cafe"]) { | |
| 46 | const repo = await createRepo(app, alice.value, { name: n }); | |
| 47 | if (!repo.ok) throw new Error("repo"); | |
| 48 | const c = await commitFiles(repoDir(app.reposRoot, "alice", n), { | |
| 49 | branch: "main", message: n, files: [{ path: "README.md", content: `# ${n}\n` }], | |
| 50 | author: { name: "a", email: "[email protected]" }, | |
| 51 | }); | |
| 52 | if (!c.ok) throw new Error("commit"); | |
| 53 | } | |
| 54 | servers = await serve(app); | |
| 55 | for (const [name, apps] of [["duo", ["alice/bakery", "alice/cafe"]], ["bare", []]] as const) { | |
| 56 | const made = await post("/api/v1/forges", { name, apps, dryRun: false }); | |
| 57 | if (made.status !== 201) throw new Error(`spawn ${name} ${made.status} ${await made.text()}`); | |
| 58 | } | |
| 59 | }, 60_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | servers.stop(); | |
| 63 | app.close(); | |
| 64 | await rm(dir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("two apps: the line names both, and is the clients page's own sentence", async () => { | |
| 68 | const got = await claimLink("duo"); | |
| 69 | expect(got.claimUrl).toContain("#"); | |
| 70 | expect(got.firstScreen).toBe(firstScreenLine(["alice/bakery", "alice/cafe"])); | |
| 71 | expect(got.firstScreen).toContain("alice/bakery, alice/cafe"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("no apps: the line says the apps they pick", async () => { | |
| 75 | const got = await claimLink("bare"); | |
| 76 | expect(got.firstScreen).toBe(firstScreenLine([])); | |
| 77 | expect(got.firstScreen).toContain("the apps you pick"); | |
| 78 | }); |
new above
offline