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
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/52-an-agent-can-put-the-first-card-on-a-boa → main 1 commit fork
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 | ||
| 67 | 67 | state that only updates on next load, a deliberate narrowing (see |
| 68 | 68 | "What's simulated"). |
| 69 | 69 | - **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`. | |
| 75 | 78 | - **Concurrent drags**: two people dragging cards at once never tear |
| 76 | 79 | each other's write — every move runs in one transaction — and cards |
| 77 | 80 | 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[] = [ | ||
| 44 | 44 | inputSchema: { type: "object", properties: {} }, |
| 45 | 45 | run: (who) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)), |
| 46 | 46 | }, |
| 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 | }, | |
| 47 | 67 | { |
| 48 | 68 | 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.", | |
| 50 | 70 | inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] }, |
| 51 | 71 | run: (_who, args) => { |
| 52 | 72 | const board = boardOf(args); |
| @@ -56,7 +76,7 @@ export const tools: readonly Tool[] = [ | ||
| 56 | 76 | }, |
| 57 | 77 | { |
| 58 | 78 | 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.", | |
| 60 | 80 | inputSchema: { |
| 61 | 81 | type: "object", |
| 62 | 82 | properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } }, |
| @@ -66,11 +86,11 @@ export const tools: readonly Tool[] = [ | ||
| 66 | 86 | const board = boardOf(args); |
| 67 | 87 | if (!board) return fail("not_found", "no such board"); |
| 68 | 88 | 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"); | |
| 70 | 90 | const title = String(args.title ?? "").trim().slice(0, 200); |
| 71 | 91 | if (title === "") return fail("invalid", "a card needs a title"); |
| 72 | 92 | 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"); | |
| 74 | 94 | if (card === "full") return fail("full", `this board is full — ${MAX_CARDS_PER_BOARD} cards`); |
| 75 | 95 | publish(board.id, who, "card.added", { columnId, cardId: card.id }); |
| 76 | 96 | return ok(card); |
| @@ -78,7 +98,8 @@ export const tools: readonly Tool[] = [ | ||
| 78 | 98 | }, |
| 79 | 99 | { |
| 80 | 100 | 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.", | |
| 82 | 103 | inputSchema: { |
| 83 | 104 | type: "object", |
| 84 | 105 | properties: { |
| @@ -94,11 +115,12 @@ export const tools: readonly Tool[] = [ | ||
| 94 | 115 | if (!board) return fail("not_found", "no such board"); |
| 95 | 116 | const cardId = String(args.card_id ?? ""); |
| 96 | 117 | 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"); | |
| 98 | 120 | const index = Number(args.index ?? 0); |
| 99 | 121 | if (!Number.isFinite(index) || index < 0) return fail("invalid", "index must be a non-negative number"); |
| 100 | 122 | 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"); | |
| 102 | 124 | publish(board.id, who, "card.moved", { cardId, columnId }); |
| 103 | 125 | return ok({ id: cardId }); |
| 104 | 126 | }, |
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 () = | ||
| 508 | 508 | expect(init.result.serverInfo.name).toBe("tempo"); |
| 509 | 509 | |
| 510 | 510 | 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 | ]); | |
| 512 | 518 | |
| 513 | 519 | const added = await ( |
| 514 | 520 | await rpcCall({ |
| @@ -527,6 +533,112 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () = | ||
| 527 | 533 | expect(missing.error.code).toBe(-32602); |
| 528 | 534 | }); |
| 529 | 535 | |
| 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 | ||
| 530 | 642 | test("a board cannot grow past its column limit, and the board is left unchanged", async () => { |
| 531 | 643 | const id = await newBoard(); |
| 532 | 644 | const boardUrl = `/b/${id}`; |
new above
offline