Public: anyone can read this; the crew can change it.
The agent seat can do what the other two can: edit, delete and add a column over MCP #126
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/68-the-agent-seat-can-do-what-the-other-two → main 1 commit fork
3 files
changed +240
−9
README.md +17 −8
| @@ -108,14 +108,23 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 108 | 108 | `/events` subscribers, so a person working the same board from the |
| 109 | 109 | terminal (`src/tty.ts`) neither appears in the list nor sees it. |
| 110 | 110 | - **Agents**: `POST /mcp` speaks MCP over plain JSON-RPC 2.0 |
| 111 | (`initialize`, `tools/list`, `tools/call`) with five tools — | |
| 112 | `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card` — | |
| 113 | under the same forge identity as the page. `list_boards` only ever | |
| 114 | returns boards the caller has kept; the other four take a board id | |
| 115 | you already have, exactly like the page does. The call order a model | |
| 116 | needs is in the tools' own descriptions: `list_columns` before | |
| 117 | `add_card` (a column_id comes from there, not from a column's name — | |
| 118 | two columns can share one), and `list_cards` before `move_card`. | |
| 111 | (`initialize`, `tools/list`, `tools/call`) with eight tools — | |
| 112 | `list_boards`, `list_columns`, `list_cards`, `add_card`, `move_card`, | |
| 113 | `edit_card`, `delete_card`, `add_column` — under the same forge | |
| 114 | identity as the page. `list_boards` only ever returns boards the | |
| 115 | caller has kept; the other seven take a board id you already have, | |
| 116 | exactly like the page does. The call order a model needs is in the | |
| 117 | tools' own descriptions: `list_columns` before `add_card` (a | |
| 118 | column_id comes from there, not from a column's name — two columns | |
| 119 | can share one), and `list_cards` before `move_card`, `edit_card` and | |
| 120 | `delete_card` (a card_id comes from there, not from a title). Each | |
| 121 | write validates exactly as its page and terminal counterpart does — | |
| 122 | the same trimmed, length-capped title or name, the same board caps — | |
| 123 | and its bus event carries the caller's identity, so it shows up on | |
| 124 | every open page within a second. Left out on purpose: `undo` (an | |
| 125 | agent reversing a person's last change is its own decision) and | |
| 126 | deleting a column (it would cascade every card in it); a deleted | |
| 127 | card is still undoable from the page itself. | |
| 119 | 128 | - **Concurrent drags**: two people dragging cards at once never tear |
| 120 | 129 | each other's write — every move runs in one transaction — and cards |
| 121 | 130 | are ordered by position, then id, so even a rare tie still sorts the |
src/mcp.ts +64 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import * as bus from "./bus.ts"; |
| 12 | 12 | import * as db from "./db.ts"; |
| 13 | import { MAX_CARDS_PER_BOARD, writeBucket } from "./limits.ts"; | |
| 13 | import { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, writeBucket } from "./limits.ts"; | |
| 14 | 14 | import { cleanText } from "./text.ts"; |
| 15 | 15 | import { noteRevision } from "./watch.ts"; |
| 16 | 16 | import type { Who } from "./who.ts"; |
| @@ -138,6 +138,69 @@ export const tools: readonly Tool[] = [ | ||
| 138 | 138 | return ok({ id: cardId }); |
| 139 | 139 | }, |
| 140 | 140 | }, |
| 141 | { | |
| 142 | name: "edit_card", | |
| 143 | description: "Rename a card's title. Call list_cards first to get a card_id.", | |
| 144 | inputSchema: { | |
| 145 | type: "object", | |
| 146 | properties: { board_id: { type: "string" }, card_id: { type: "string" }, title: { type: "string", maxLength: 200 } }, | |
| 147 | required: ["board_id", "card_id", "title"], | |
| 148 | }, | |
| 149 | write: true, | |
| 150 | run: (who, args, key) => { | |
| 151 | const board = boardOf(args); | |
| 152 | if (!board) return fail("not_found", "no such board"); | |
| 153 | const cardId = String(args.card_id ?? ""); | |
| 154 | if (!ENTITY_ID.test(cardId)) return fail("invalid", "card_id looks wrong — call list_cards for this board's card ids"); | |
| 155 | const title = cleanText(args.title, 200); | |
| 156 | if (!title) return fail("invalid", "a card needs a title"); | |
| 157 | if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly"); | |
| 158 | if (!db.editCard(board.id, cardId, title)) return fail("not_found", "no such card — call list_cards for this board's card ids"); | |
| 159 | publish(board.id, who, "card.edited", { cardId }); | |
| 160 | return ok({ id: cardId }); | |
| 161 | }, | |
| 162 | }, | |
| 163 | { | |
| 164 | name: "delete_card", | |
| 165 | description: "Remove a card from a board. Call list_cards first to get a card_id. This can be undone from the page, not from here.", | |
| 166 | inputSchema: { | |
| 167 | type: "object", | |
| 168 | properties: { board_id: { type: "string" }, card_id: { type: "string" } }, | |
| 169 | required: ["board_id", "card_id"], | |
| 170 | }, | |
| 171 | write: true, | |
| 172 | run: (who, args, key) => { | |
| 173 | const board = boardOf(args); | |
| 174 | if (!board) return fail("not_found", "no such board"); | |
| 175 | const cardId = String(args.card_id ?? ""); | |
| 176 | if (!ENTITY_ID.test(cardId)) return fail("invalid", "card_id looks wrong — call list_cards for this board's card ids"); | |
| 177 | if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly"); | |
| 178 | if (!db.deleteCard(board.id, cardId)) return fail("not_found", "no such card — call list_cards for this board's card ids"); | |
| 179 | publish(board.id, who, "card.deleted", { cardId }); | |
| 180 | return ok({ id: cardId }); | |
| 181 | }, | |
| 182 | }, | |
| 183 | { | |
| 184 | name: "add_column", | |
| 185 | description: "Add a column to a board, at the end. Call list_columns first if you need to check what's already there.", | |
| 186 | inputSchema: { | |
| 187 | type: "object", | |
| 188 | properties: { board_id: { type: "string" }, name: { type: "string", maxLength: 60 } }, | |
| 189 | required: ["board_id", "name"], | |
| 190 | }, | |
| 191 | write: true, | |
| 192 | run: (who, args, key) => { | |
| 193 | const board = boardOf(args); | |
| 194 | if (!board) return fail("not_found", "no such board"); | |
| 195 | const name = cleanText(args.name, 60); | |
| 196 | if (!name) return fail("invalid", "a column needs a name"); | |
| 197 | if (!writeBucket.take(key)) return fail("rate_limited", "too many changes just now — try again shortly"); | |
| 198 | const column = db.createColumn(board.id, name); | |
| 199 | if (column === "full") return fail("full", `this board is full — ${MAX_COLUMNS_PER_BOARD} columns`); | |
| 200 | publish(board.id, who, "column.added", { columnId: column.id }); | |
| 201 | return ok(column); | |
| 202 | }, | |
| 203 | }, | |
| 141 | 204 | ]; |
| 142 | 205 | |
| 143 | 206 | /** A JSON object's fields, whatever the wire carried. */ |
src/server.test.ts +159
| @@ -647,6 +647,9 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () = | ||
| 647 | 647 | "list_cards", |
| 648 | 648 | "add_card", |
| 649 | 649 | "move_card", |
| 650 | "edit_card", | |
| 651 | "delete_card", | |
| 652 | "add_column", | |
| 650 | 653 | ]); |
| 651 | 654 | |
| 652 | 655 | const added = await ( |
| @@ -801,6 +804,162 @@ test("add_card and move_card name the way out when a column or card id is wrong" | ||
| 801 | 804 | expect(badMove.result.content[0].text).toContain("call list_cards and list_columns"); |
| 802 | 805 | }); |
| 803 | 806 | |
| 807 | test("edit_card and delete_card over MCP change the board and publish their events within a second", async () => { | |
| 808 | const id = db.createBoard("MCP edit/delete board").id; // see the note above on why not POST /boards | |
| 809 | const [column] = db.listColumns(id); | |
| 810 | const card = db.createCard(id, column.id, "Original title") as db.Card; | |
| 811 | const rpcCall = (body: unknown) => | |
| 812 | fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 813 | ||
| 814 | const stream = await fetch(base + `/b/${id}/events`); | |
| 815 | const reader = stream.body!.getReader(); | |
| 816 | const decoder = new TextDecoder(); | |
| 817 | let buffer = ""; | |
| 818 | async function nextFrame(): Promise<string> { | |
| 819 | while (!buffer.includes("\n\n")) { | |
| 820 | const { value, done } = await reader.read(); | |
| 821 | if (done) throw new Error("stream ended"); | |
| 822 | buffer += decoder.decode(value, { stream: true }); | |
| 823 | } | |
| 824 | const at = buffer.indexOf("\n\n"); | |
| 825 | const frame = buffer.slice(0, at); | |
| 826 | buffer = buffer.slice(at + 2); | |
| 827 | return frame; | |
| 828 | } | |
| 829 | async function nextEvent(): Promise<{ kind: string; boardId: string; ids: Record<string, string> }> { | |
| 830 | for (let i = 0; i < 5; i++) { | |
| 831 | const frame = await nextFrame(); | |
| 832 | if (frame.startsWith("event:")) continue; // a presence frame, not a board-change one | |
| 833 | const data = frame.match(/^data: (.*)$/m)?.[1]; | |
| 834 | if (data) return JSON.parse(data); | |
| 835 | } | |
| 836 | throw new Error("no board-change frame arrived"); | |
| 837 | } | |
| 838 | ||
| 839 | const edited = await ( | |
| 840 | await rpcCall({ | |
| 841 | jsonrpc: "2.0", | |
| 842 | id: 1, | |
| 843 | method: "tools/call", | |
| 844 | params: { name: "edit_card", arguments: { board_id: id, card_id: card.id, title: "Renamed by an agent" } }, | |
| 845 | }) | |
| 846 | ).json(); | |
| 847 | expect(edited.result.content[0].text).toContain(card.id); | |
| 848 | expect(db.getCard(id, card.id)!.title).toBe("Renamed by an agent"); | |
| 849 | expect(await nextEvent()).toMatchObject({ kind: "card.edited", boardId: id, ids: { cardId: card.id } }); | |
| 850 | ||
| 851 | const deleted = await ( | |
| 852 | await rpcCall({ | |
| 853 | jsonrpc: "2.0", | |
| 854 | id: 2, | |
| 855 | method: "tools/call", | |
| 856 | params: { name: "delete_card", arguments: { board_id: id, card_id: card.id } }, | |
| 857 | }) | |
| 858 | ).json(); | |
| 859 | expect(deleted.result.content[0].text).toContain(card.id); | |
| 860 | expect(db.getCard(id, card.id)).toBeNull(); | |
| 861 | expect(await nextEvent()).toMatchObject({ kind: "card.deleted", boardId: id, ids: { cardId: card.id } }); | |
| 862 | ||
| 863 | await reader.cancel(); | |
| 864 | }); | |
| 865 | ||
| 866 | test("edit_card and delete_card refuse a card id that doesn't exist without touching the board", async () => { | |
| 867 | const id = db.createBoard("MCP bad-id board").id; // see the note above on why not POST /boards | |
| 868 | const [column] = db.listColumns(id); | |
| 869 | const card = db.createCard(id, column.id, "Untouched") as db.Card; | |
| 870 | const rpcCall = (body: unknown) => | |
| 871 | fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 872 | ||
| 873 | const badEdit = await ( | |
| 874 | await rpcCall({ | |
| 875 | jsonrpc: "2.0", | |
| 876 | id: 1, | |
| 877 | method: "tools/call", | |
| 878 | params: { name: "edit_card", arguments: { board_id: id, card_id: "zzzzzzzzzzzz", title: "won't land" } }, | |
| 879 | }) | |
| 880 | ).json(); | |
| 881 | expect(badEdit.result.isError).toBe(true); | |
| 882 | expect(badEdit.result.content[0].text).toContain("call list_cards"); | |
| 883 | ||
| 884 | const badDelete = await ( | |
| 885 | await rpcCall({ | |
| 886 | jsonrpc: "2.0", | |
| 887 | id: 2, | |
| 888 | method: "tools/call", | |
| 889 | params: { name: "delete_card", arguments: { board_id: id, card_id: "zzzzzzzzzzzz" } }, | |
| 890 | }) | |
| 891 | ).json(); | |
| 892 | expect(badDelete.result.isError).toBe(true); | |
| 893 | expect(badDelete.result.content[0].text).toContain("call list_cards"); | |
| 894 | ||
| 895 | expect(db.getCard(id, card.id)!.title).toBe("Untouched"); | |
| 896 | }); | |
| 897 | ||
| 898 | test("add_column over MCP adds a column, publishes column.added, and respects the board's column cap", async () => { | |
| 899 | const id = db.createBoard("MCP add_column board").id; // see the note above on why not POST /boards | |
| 900 | const rpcCall = (body: unknown) => | |
| 901 | fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); | |
| 902 | ||
| 903 | const stream = await fetch(base + `/b/${id}/events`); | |
| 904 | const reader = stream.body!.getReader(); | |
| 905 | const decoder = new TextDecoder(); | |
| 906 | let buffer = ""; | |
| 907 | async function nextFrame(): Promise<string> { | |
| 908 | while (!buffer.includes("\n\n")) { | |
| 909 | const { value, done } = await reader.read(); | |
| 910 | if (done) throw new Error("stream ended"); | |
| 911 | buffer += decoder.decode(value, { stream: true }); | |
| 912 | } | |
| 913 | const at = buffer.indexOf("\n\n"); | |
| 914 | const frame = buffer.slice(0, at); | |
| 915 | buffer = buffer.slice(at + 2); | |
| 916 | return frame; | |
| 917 | } | |
| 918 | ||
| 919 | const added = await ( | |
| 920 | await rpcCall({ | |
| 921 | jsonrpc: "2.0", | |
| 922 | id: 1, | |
| 923 | method: "tools/call", | |
| 924 | params: { name: "add_column", arguments: { board_id: id, name: "From an agent" } }, | |
| 925 | }) | |
| 926 | ).json(); | |
| 927 | expect(added.result.content[0].text).toContain("From an agent"); | |
| 928 | expect(db.listColumns(id).some((c) => c.name === "From an agent")).toBe(true); | |
| 929 | ||
| 930 | let event: { kind: string; boardId: string } | undefined; | |
| 931 | for (let i = 0; i < 5 && !event; i++) { | |
| 932 | const frame = await nextFrame(); | |
| 933 | if (frame.startsWith("event:")) continue; | |
| 934 | const data = frame.match(/^data: (.*)$/m)?.[1]; | |
| 935 | if (data) event = JSON.parse(data); | |
| 936 | } | |
| 937 | expect(event?.kind).toBe("column.added"); | |
| 938 | expect(event?.boardId).toBe(id); | |
| 939 | await reader.cancel(); | |
| 940 | ||
| 941 | for (let count = db.listColumns(id).length; count < MAX_COLUMNS_PER_BOARD; count++) { | |
| 942 | const created = await rpcCall({ | |
| 943 | jsonrpc: "2.0", | |
| 944 | id: 2, | |
| 945 | method: "tools/call", | |
| 946 | params: { name: "add_column", arguments: { board_id: id, name: `Column ${count}` } }, | |
| 947 | }); | |
| 948 | expect((await created.json()).result.isError).toBeUndefined(); | |
| 949 | } | |
| 950 | const refused = await ( | |
| 951 | await rpcCall({ | |
| 952 | jsonrpc: "2.0", | |
| 953 | id: 3, | |
| 954 | method: "tools/call", | |
| 955 | params: { name: "add_column", arguments: { board_id: id, name: "One too many" } }, | |
| 956 | }) | |
| 957 | ).json(); | |
| 958 | expect(refused.result.isError).toBe(true); | |
| 959 | expect(refused.result.content[0].text).toContain(`${MAX_COLUMNS_PER_BOARD} columns`); | |
| 960 | expect(db.listColumns(id).length).toBe(MAX_COLUMNS_PER_BOARD); | |
| 961 | }); | |
| 962 | ||
| 804 | 963 | test("a board cannot grow past its column limit, and the board is left unchanged", async () => { |
| 805 | 964 | const id = await newBoard(); |
| 806 | 965 | const boardUrl = `/b/${id}`; |
new above
offline