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

Merged opened this

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

Merged

scout/forge:scout/1659-thoughts-step-2-mcp-tell-without-a-repo → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/1659-thoughts-step-2-mcp-tell-without-a-repo was merged into main as cb89767.

Review

Sign in to review this change.
2 files changed +29 −3
src/http/mcp.ts +12 −2
@@ -48,6 +48,7 @@ import {
4848 CARD_URI,
4949 cardFor,
5050 } from "../web/studio/cards.ts";
51 import { keepThought } from "../actions/thought.ts";
5152 import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts";
5253 import { readReturns, reportSpend } from "../actions/returns.ts";
5354 import {
@@ -296,7 +297,7 @@ const TOOLS: readonly Tool[] = [
296297 {
297298 name: "tell",
298299 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.",
300301 inputSchema: {
301302 type: "object",
302303 properties: {
@@ -314,10 +315,19 @@ const TOOLS: readonly Tool[] = [
314315 },
315316 },
316317 },
317 required: ["repo", "text"],
318 required: ["text"],
318319 },
319320 writes: true,
320321 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",

Nit: the 20,000 here is typed out by hand. If THOUGHT_MAX changes, this message goes stale. src/http/api.ts builds its message with THOUGHT_MAX.toLocaleString("en-US"); import THOUGHT_MAX and do the same here.

328 "text is a string of at most 20,000 characters",
329 );
330 }
321331 const repo = repoArg(ctx, user, args);
322332 if (isAnswer(repo)) return repo;
323333 const where = parseContext(args["context"]);
test/mcp.test.ts +17 −1
@@ -207,7 +207,7 @@ describe("the forge over MCP", () => {
207207 listed.body.result.tools.map((t: any) => [t.name, t]),
208208 );
209209 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"]);
211211
212212 expect((await call(rw, "nothing/here")).body.error.code).toBe(-32601);
213213 expect(
@@ -249,6 +249,22 @@ describe("the forge over MCP", () => {
249249 expect(listIssues(app.db, game.id, { state: "open" })).toHaveLength(0);
250250 });
251251
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
252268 test("tell is the studio's tell: an issue, a builder, a run", async () => {
253269 const empty = await tool(rw, "tell", { repo: "alice/game", text: " " });
254270 expect(empty.error).toContain("empty");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs