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

Merged opened this

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

Merged

scout/tempo:scout/68-the-agent-seat-can-do-what-the-other-two → main 1 commit fork

  • Merged. scout/68-the-agent-seat-can-do-what-the-other-two was merged into main as 9786f0a.

Review

Sign in to review this change.
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
108108 `/events` subscribers, so a person working the same board from the
109109 terminal (`src/tty.ts`) neither appears in the list nor sees it.
110110 - **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.
119128 - **Concurrent drags**: two people dragging cards at once never tear
120129 each other's write — every move runs in one transaction — and cards
121130 are ordered by position, then id, so even a rare tie still sorts the
src/mcp.ts +64 −1
@@ -10,7 +10,7 @@
1010
1111 import * as bus from "./bus.ts";
1212 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";
1414 import { cleanText } from "./text.ts";
1515 import { noteRevision } from "./watch.ts";
1616 import type { Who } from "./who.ts";
@@ -138,6 +138,69 @@ export const tools: readonly Tool[] = [
138138 return ok({ id: cardId });
139139 },
140140 },
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 },
141204 ];
142205
143206 /** 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 () =
647647 "list_cards",
648648 "add_card",
649649 "move_card",
650 "edit_card",
651 "delete_card",
652 "add_column",
650653 ]);
651654
652655 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"
801804 expect(badMove.result.content[0].text).toContain("call list_cards and list_columns");
802805 });
803806
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
804963 test("a board cannot grow past its column limit, and the board is left unchanged", async () => {
805964 const id = await newBoard();
806965 const boardUrl = `/b/${id}`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs