Public: anyone can read this; the crew can change it.

An agent can put the first card on a board: a list_columns tool, and errors that name the way out #73

Merged opened this

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

Merged

scout/tempo:scout/52-an-agent-can-put-the-first-card-on-a-boa → main 1 commit fork

  • Merged. scout/52-an-agent-can-put-the-first-card-on-a-boa was merged into main as cd8ab1d.

Review

Sign in to review this change.
3 files changed +150 −13
README.md +8 −5
@@ -67,11 +67,14 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
6767 state that only updates on next load, a deliberate narrowing (see
6868 "What's simulated").
6969 - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0
70 (`initialize`, `tools/list`, `tools/call`) with four tools —
71 `list_boards`, `list_cards`, `add_card`, `move_card` — under the same
72 forge identity as the page. `list_boards` only ever returns boards
73 the caller has kept; the other three take a board id you already
74 have, exactly like the page does.
70 (`initialize`, `tools/list`, `tools/call`) with five tools —
71 `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` —
72 under the same forge identity as the page. `list_boards` only ever
73 returns boards the caller has kept; the other four take a board id
74 you already have, exactly like the page does. The call order a model
75 needs is in the tools' own descriptions: `list_columns` before
76 `add_card` (a column_id comes from there, not from a column's name —
77 two columns can share one), and `list_cards` before `move_card`.
7578 - **Concurrent drags**: two people dragging cards at once never tear
7679 each other's write — every move runs in one transaction — and cards
7780 are ordered by position, then id, so even a rare tie still sorts the
src/mcp.ts +29 −7
@@ -44,9 +44,29 @@ export const tools: readonly Tool[] = [
4444 inputSchema: { type: "object", properties: {} },
4545 run: (who) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)),
4646 },
47 {
48 name: "list_columns",
49 description:
50 "Every column on a board, in board order, with each one's id and card count. Call this before add_card or move_card — a column_id comes from here, never from a title.",
51 inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] },
52 run: (_who, args) => {
53 const board = boardOf(args);
54 if (!board) return fail("not_found", "no such board");
55 const cardCounts = new Map<string, number>();
56 for (const card of db.listCards(board.id)) cardCounts.set(card.column_id, (cardCounts.get(card.column_id) ?? 0) + 1);
57 return ok(
58 db.listColumns(board.id).map((column) => ({
59 id: column.id,
60 name: column.name,
61 position: column.position,
62 card_count: cardCounts.get(column.id) ?? 0,
63 })),
64 );
65 },
66 },
4767 {
4868 name: "list_cards",
49 description: "Every card on a board, each with the column it's in.",
69 description: "Every card on a board, each with the column it's in. Call this before move_card — a card_id comes from here.",
5070 inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] },
5171 run: (_who, args) => {
5272 const board = boardOf(args);
@@ -56,7 +76,7 @@ export const tools: readonly Tool[] = [
5676 },
5777 {
5878 name: "add_card",
59 description: "Add a card to a column on a board.",
79 description: "Add a card to a column on a board. Call list_columns first to get a column_id.",
6080 inputSchema: {
6181 type: "object",
6282 properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } },
@@ -66,11 +86,11 @@ export const tools: readonly Tool[] = [
6686 const board = boardOf(args);
6787 if (!board) return fail("not_found", "no such board");
6888 const columnId = String(args.column_id ?? "");
69 if (!ENTITY_ID.test(columnId)) return fail("invalid", "column_id looks wrong");
89 if (!ENTITY_ID.test(columnId)) return fail("invalid", "column_id looks wrong — call list_columns for this board's column ids");
7090 const title = String(args.title ?? "").trim().slice(0, 200);
7191 if (title === "") return fail("invalid", "a card needs a title");
7292 const card = db.createCard(board.id, columnId, title);
73 if (card === "not_found") return fail("not_found", "no such column");
93 if (card === "not_found") return fail("not_found", "no such column — call list_columns for this board's column ids");
7494 if (card === "full") return fail("full", `this board is full — ${MAX_CARDS_PER_BOARD} cards`);
7595 publish(board.id, who, "card.added", { columnId, cardId: card.id });
7696 return ok(card);
@@ -78,7 +98,8 @@ export const tools: readonly Tool[] = [
7898 },
7999 {
80100 name: "move_card",
81 description: "Move a card into a column, at a 0-based position among that column's cards.",
101 description:
102 "Move a card into a column, at a 0-based position among that column's cards. Call list_cards first to get a card_id, and list_columns for a column_id.",
82103 inputSchema: {
83104 type: "object",
84105 properties: {
@@ -94,11 +115,12 @@ export const tools: readonly Tool[] = [
94115 if (!board) return fail("not_found", "no such board");
95116 const cardId = String(args.card_id ?? "");
96117 const columnId = String(args.column_id ?? "");
97 if (!ENTITY_ID.test(cardId) || !ENTITY_ID.test(columnId)) return fail("invalid", "card_id or column_id looks wrong");
118 if (!ENTITY_ID.test(cardId) || !ENTITY_ID.test(columnId))
119 return fail("invalid", "card_id or column_id looks wrong — call list_cards and list_columns for this board's ids");
98120 const index = Number(args.index ?? 0);
99121 if (!Number.isFinite(index) || index < 0) return fail("invalid", "index must be a non-negative number");
100122 const moved = db.moveCard(board.id, cardId, columnId, Math.floor(index));
101 if (!moved) return fail("not_found", "no such card or column");
123 if (!moved) return fail("not_found", "no such card or column — call list_cards and list_columns for this board's ids");
102124 publish(board.id, who, "card.moved", { cardId, columnId });
103125 return ok({ id: cardId });
104126 },
src/server.test.ts +113 −1
@@ -508,7 +508,13 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () =
508508 expect(init.result.serverInfo.name).toBe("tempo");
509509
510510 const listed = await (await rpcCall({ jsonrpc: "2.0", id: 2, method: "tools/list" })).json();
511 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual(["list_boards", "list_cards", "add_card", "move_card"]);
511 expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
512 "list_boards",
513 "list_columns",
514 "list_cards",
515 "add_card",
516 "move_card",
517 ]);
512518
513519 const added = await (
514520 await rpcCall({
@@ -527,6 +533,112 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () =
527533 expect(missing.error.code).toBe(-32602);
528534 });
529535
536 test("list_columns on a fresh board returns the three default columns with ids", async () => {
537 const id = db.createBoard("Fresh board").id; // see the note above on why not POST /boards
538
539 const rpcCall = (body: unknown) =>
540 fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
541
542 const result = await (
543 await rpcCall({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_columns", arguments: { board_id: id } } })
544 ).json();
545 const columns = JSON.parse(result.result.content[0].text);
546 expect(columns.map((c: { name: string }) => c.name)).toEqual(["To do", "Doing", "Done"]);
547 for (const column of columns) {
548 expect(column.id).toMatch(/^[a-z0-9]{12}$/);
549 expect(typeof column.position).toBe("number");
550 expect(column.card_count).toBe(0);
551 }
552 });
553
554 test("list_columns on an unknown board is not_found", async () => {
555 const result = await (
556 await fetch(base + "/mcp", {
557 method: "POST",
558 headers: { "content-type": "application/json" },
559 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_columns", arguments: { board_id: "zzzzzzzz" } } }),
560 })
561 ).json();
562 expect(result.result.content[0].text).toBe("not_found: no such board");
563 expect(result.result.isError).toBe(true);
564 });
565
566 test("the full agent path works end to end against a board with no cards: list_boards, list_columns, add_card", async () => {
567 const headers = identityHeaders("u-agent", "dana");
568 const created = await fetch(base + "/boards", {
569 method: "POST",
570 body: new URLSearchParams({ name: "Agent's board" }),
571 redirect: "manual",
572 headers,
573 });
574 const id = new URL(created.headers.get("location")!, base).pathname.split("/")[2];
575
576 const rpcCall = (body: unknown) =>
577 fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) });
578
579 const boards = JSON.parse(
580 (await (await rpcCall({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_boards", arguments: {} } })).json())
581 .result.content[0].text,
582 );
583 expect(boards.some((b: { id: string }) => b.id === id)).toBe(true);
584
585 const columns = JSON.parse(
586 (
587 await (
588 await rpcCall({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "list_columns", arguments: { board_id: id } } })
589 ).json()
590 ).result.content[0].text,
591 );
592 const todoId = columns.find((c: { name: string }) => c.name === "To do").id;
593
594 const added = await (
595 await rpcCall({
596 jsonrpc: "2.0",
597 id: 3,
598 method: "tools/call",
599 params: { name: "add_card", arguments: { board_id: id, column_id: todoId, title: "First card" } },
600 })
601 ).json();
602 expect(added.result.content[0].text).toContain("First card");
603 expect(await (await fetch(base + `/b/${id}`)).text()).toContain("First card");
604 });
605
606 test("add_card and move_card name the way out when a column or card id is wrong", async () => {
607 const id = db.createBoard("Another board").id; // see the note above on why not POST /boards
608 const rpcCall = (body: unknown) =>
609 fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
610
611 const badColumnId = await (
612 await rpcCall({
613 jsonrpc: "2.0",
614 id: 1,
615 method: "tools/call",
616 params: { name: "add_card", arguments: { board_id: id, column_id: "To do", title: "x" } },
617 })
618 ).json();
619 expect(badColumnId.result.content[0].text).toContain("call list_columns");
620
621 const unknownColumn = await (
622 await rpcCall({
623 jsonrpc: "2.0",
624 id: 2,
625 method: "tools/call",
626 params: { name: "add_card", arguments: { board_id: id, column_id: "zzzzzzzzzzzz", title: "x" } },
627 })
628 ).json();
629 expect(unknownColumn.result.content[0].text).toContain("call list_columns");
630
631 const badMove = await (
632 await rpcCall({
633 jsonrpc: "2.0",
634 id: 3,
635 method: "tools/call",
636 params: { name: "move_card", arguments: { board_id: id, card_id: "zzzzzzzzzzzz", column_id: "zzzzzzzzzzzz" } },
637 })
638 ).json();
639 expect(badMove.result.content[0].text).toContain("call list_cards and list_columns");
640 });
641
530642 test("a board cannot grow past its column limit, and the board is left unchanged", async () => {
531643 const id = await newBoard();
532644 const boardUrl = `/b/${id}`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs