Public: anyone can read this; the crew can change it.
Thoughts, step 2: MCP `tell` without a repo writes a thought and returns its id #1722
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1659-thoughts-step-2-mcp-tell-without-a-repo → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +29
−3
src/http/mcp.ts +12 −2
| @@ -48,6 +48,7 @@ import { | ||
| 48 | 48 | CARD_URI, |
| 49 | 49 | cardFor, |
| 50 | 50 | } from "../web/studio/cards.ts"; |
| 51 | import { keepThought } from "../actions/thought.ts"; | |
| 51 | 52 | import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts"; |
| 52 | 53 | import { readReturns, reportSpend } from "../actions/returns.ts"; |
| 53 | 54 | import { |
| @@ -296,7 +297,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 296 | 297 | { |
| 297 | 298 | name: "tell", |
| 298 | 299 | description: |
| 299 | "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.", | |
| 300 | "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. A tell without a repo is kept, word for word, for the forge as a whole: nothing is built from it, and the answer is its thought id.", | |
| 300 | 301 | inputSchema: { |
| 301 | 302 | type: "object", |
| 302 | 303 | properties: { |
| @@ -314,10 +315,19 @@ const TOOLS: readonly Tool[] = [ | ||
| 314 | 315 | }, |
| 315 | 316 | }, |
| 316 | 317 | }, |
| 317 | required: ["repo", "text"], | |
| 318 | required: ["text"], | |
| 318 | 319 | }, |
| 319 | 320 | writes: true, |
| 320 | 321 | async run(ctx, user, args) { |
| 322 | if (args["repo"] === undefined || args["repo"] === null) { | |
| 323 | const kept = keepThought(ctx.app, user, args["text"], "mcp"); | |
| 324 | return kept.ok | |
| 325 | ? good({ thought: kept.value.id }) | |
| 326 | : bad( | |
| 327 | "invalid", | |
| 328 | "text is a string of at most 20,000 characters", | |
| 329 | ); | |
| 330 | } | |
| 321 | 331 | const repo = repoArg(ctx, user, args); |
| 322 | 332 | if (isAnswer(repo)) return repo; |
| 323 | 333 | const where = parseContext(args["context"]); |
test/mcp.test.ts +17 −1
| @@ -207,7 +207,7 @@ describe("the forge over MCP", () => { | ||
| 207 | 207 | listed.body.result.tools.map((t: any) => [t.name, t]), |
| 208 | 208 | ); |
| 209 | 209 | expect(byName.get("tell").annotations.readOnlyHint).toBe(false); |
| 210 | expect(byName.get("tell").inputSchema.required).toEqual(["repo", "text"]); | |
| 210 | expect(byName.get("tell").inputSchema.required).toEqual(["text"]); | |
| 211 | 211 | |
| 212 | 212 | expect((await call(rw, "nothing/here")).body.error.code).toBe(-32601); |
| 213 | 213 | expect( |
| @@ -249,6 +249,22 @@ describe("the forge over MCP", () => { | ||
| 249 | 249 | expect(listIssues(app.db, game.id, { state: "open" })).toHaveLength(0); |
| 250 | 250 | }); |
| 251 | 251 | |
| 252 | test("tell without a repo keeps a thought and returns its id", async () => { | |
| 253 | const kept = await tool(rw, "tell", { text: " a thought, as typed " }); | |
| 254 | expect(typeof kept.value.thought).toBe("string"); | |
| 255 | const row = app.db | |
| 256 | .query("SELECT author, text, source FROM thought WHERE id = ?1") | |
| 257 | .get(kept.value.thought) as any; | |
| 258 | expect(row).toEqual({ author: alice.id, text: " a thought, as typed ", source: "mcp" }); | |
| 259 | expect(listIssues(app.db, game.id, { state: "open" })).toHaveLength(0); | |
| 260 | expect((await tool(rw, "tell", { text: 5 })).error).toContain("invalid"); | |
| 261 | expect((await tool(ro, "tell", { text: "x" })).error).toContain("forbidden"); | |
| 262 | // With a repo it is still an issue. | |
| 263 | expect( | |
| 264 | (await tool(rw, "tell", { repo: "nobody/none", text: "x" })).error, | |
| 265 | ).toContain("not_found"); | |
| 266 | }); | |
| 267 | ||
| 252 | 268 | test("tell is the studio's tell: an issue, a builder, a run", async () => { |
| 253 | 269 | const empty = await tool(rw, "tell", { repo: "alice/game", text: " " }); |
| 254 | 270 | expect(empty.error).toContain("empty"); |
new above
offline
Nit: the 20,000 here is typed out by hand. If THOUGHT_MAX changes, this message goes stale.
src/http/api.tsbuilds its message withTHOUGHT_MAX.toLocaleString("en-US"); import THOUGHT_MAX and do the same here.