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

A Claude session can actually connect: answer the protocol version it asked for, and results it can parse #131

Merged opened this

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

Merged

scout/tempo:scout/77-a-claude-session-can-actually-connect-an → main 2 commits fork

  • Merged. scout/77-a-claude-session-can-actually-connect-an was merged into main as fc047f8.

Review

Sign in to review this change.
4 files changed +171 −9
README.md +18 −4
@@ -121,10 +121,24 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
121121 write validates exactly as its page and terminal counterpart does —
122122 the same trimmed, length-capped title or name, the same board caps —
123123 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.
124 every open page within a second. Every `tools/call` result carries
125 its value twice — `structuredContent`, matching the tool's declared
126 `outputSchema`, and the same JSON as text for a client that only
127 reads that — so nothing has to parse an id back out of prose. Left
128 out on purpose: `undo` (an agent reversing a person's last change is
129 its own decision) and deleting a column (it would cascade every card
130 in it); a deleted card is still undoable from the page itself.
131 `/mcp` is stateless — no `Mcp-Session-Id`, no `GET /mcp` stream —
132 every call stands on its own, so `initialize` negotiates nothing
133 that outlives the request. It speaks protocol versions `2025-06-18`
134 and `2024-11-05` (`src/mcp.ts`'s `PROTOCOL_VERSIONS`, newest first):
135 `initialize` echoes back whichever of those the client asked for, or
136 the newest one if it asked for something else; a later request's
137 `MCP-Protocol-Version` header naming anything else is refused with a
138 JSON-RPC error that names the versions this server does speak. Only
139 `tools` are advertised — `resources/list` and `prompts/list` answer
140 `-32601` because there is nothing behind them to list, not because
141 they were missed.
128142 - **Concurrent drags**: two people dragging cards at once never tear
129143 each other's write — every move runs in one transaction — and cards
130144 are ordered by position, then id, so even a rare tie still sorts the
src/mcp.ts +83 −4
@@ -24,6 +24,30 @@ function publish(boardId: string, who: Who, kind: bus.EventKind, ids: Record<str
2424 const BOARD_ID = /^[a-z0-9]{8}$/;
2525 const ENTITY_ID = /^[a-z0-9]{12}$/;
2626
27 // The versions this server speaks, newest first. `initialize` echoes back
28 // whichever of these the client asked for, or the newest one if it asked
29 // for something else — the spec's answer to a version it doesn't support.
30 export const PROTOCOL_VERSIONS = ["2025-06-18", "2024-11-05"] as const;
31 const LATEST_PROTOCOL_VERSION = PROTOCOL_VERSIONS[0];
32
33 function negotiateProtocolVersion(requested: unknown): string {
34 return typeof requested === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(requested)
35 ? requested
36 : LATEST_PROTOCOL_VERSION;
37 }
38
39 /**
40 * The `MCP-Protocol-Version` header a client sends on every request after
41 * the handshake. A missing header is left alone — this server is stateless,
42 * so nothing here ever established a session to check it against — but a
43 * present one naming a version we don't speak is refused, by name, rather
44 * than dropped as a bare HTTP status.
45 */
46 export function checkProtocolVersionHeader(header: string | null): { code: number; message: string } | undefined {
47 if (header === null || (PROTOCOL_VERSIONS as readonly string[]).includes(header)) return undefined;
48 return { code: -32600, message: `unsupported MCP-Protocol-Version ${header} — this server speaks ${PROTOCOL_VERSIONS.join(", ")}` };
49 }
50
2751 type Result<T> = { ok: true; value: T } | { ok: false; error: string; message: string };
2852 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
2953 const fail = <T>(error: string, message: string): Result<T> => ({ ok: false, error, message });
@@ -32,6 +56,7 @@ interface Tool {
3256 readonly name: string;
3357 readonly description: string;
3458 readonly inputSchema: Record<string, unknown>;
59 readonly outputSchema: Record<string, unknown>;
3560 // Whether calling this tool mutates a board — a write tool spends a
3661 // token from writeBucket itself, right before the db call that would
3762 // actually cost something, the same as src/routes.ts.
@@ -44,11 +69,51 @@ const boardOf = (args: Record<string, unknown>): db.Board | undefined => {
4469 return BOARD_ID.test(id) ? (db.getBoard(id) ?? undefined) : undefined;
4570 };
4671
72 // Shared shapes for the outputSchema each tool declares, matching what its
73 // run() actually returns — a model reading structuredContent gets the same
74 // fields a hand-parsed text blob would have had, without the parsing.
75 const boardSchema = {
76 type: "object",
77 properties: {
78 id: { type: "string" },
79 name: { type: "string" },
80 owner_id: { type: ["string", "null"] },
81 owner_name: { type: ["string", "null"] },
82 created_at: { type: "string" },
83 },
84 required: ["id", "name", "owner_id", "owner_name", "created_at"],
85 };
86 const columnSchema = {
87 type: "object",
88 properties: { id: { type: "string" }, board_id: { type: "string" }, name: { type: "string" }, position: { type: "number" } },
89 required: ["id", "board_id", "name", "position"],
90 };
91 const columnWithCountSchema = {
92 type: "object",
93 properties: { id: { type: "string" }, name: { type: "string" }, position: { type: "number" }, card_count: { type: "number" } },
94 required: ["id", "name", "position", "card_count"],
95 };
96 const cardSchema = {
97 type: "object",
98 properties: {
99 id: { type: "string" },
100 board_id: { type: "string" },
101 column_id: { type: "string" },
102 title: { type: "string" },
103 position: { type: "number" },
104 created_at: { type: "string" },
105 updated_at: { type: "string" },
106 },
107 required: ["id", "board_id", "column_id", "title", "position", "created_at", "updated_at"],
108 };
109 const idResultSchema = { type: "object", properties: { id: { type: "string" } }, required: ["id"] };
110
47111 export const tools: readonly Tool[] = [
48112 {
49113 name: "list_boards",
50114 description: "The boards you have kept by signing in. An anonymous caller sees none.",
51115 inputSchema: { type: "object", properties: {} },
116 outputSchema: { type: "array", items: boardSchema },
52117 write: false,
53118 run: (who, _args) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)),
54119 },
@@ -57,6 +122,8 @@ export const tools: readonly Tool[] = [
57122 description:
58123 "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.",
59124 inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] },
125 outputSchema: { type: "array", items: columnWithCountSchema },
126 write: false,
60127 run: (_who, args) => {
61128 const board = boardOf(args);
62129 if (!board) return fail("not_found", "no such board");
@@ -76,6 +143,7 @@ export const tools: readonly Tool[] = [
76143 name: "list_cards",
77144 description: "Every card on a board, each with the column it's in. Call this before move_card — a card_id comes from here.",
78145 inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] },
146 outputSchema: { type: "array", items: cardSchema },
79147 write: false,
80148 run: (_who, args) => {
81149 const board = boardOf(args);
@@ -91,6 +159,7 @@ export const tools: readonly Tool[] = [
91159 properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } },
92160 required: ["board_id", "column_id", "title"],
93161 },
162 outputSchema: cardSchema,
94163 write: true,
95164 run: (who, args, key) => {
96165 const board = boardOf(args);
@@ -121,6 +190,7 @@ export const tools: readonly Tool[] = [
121190 },
122191 required: ["board_id", "card_id", "column_id"],
123192 },
193 outputSchema: idResultSchema,
124194 write: true,
125195 run: (who, args, key) => {
126196 const board = boardOf(args);
@@ -146,6 +216,7 @@ export const tools: readonly Tool[] = [
146216 properties: { board_id: { type: "string" }, card_id: { type: "string" }, title: { type: "string", maxLength: 200 } },
147217 required: ["board_id", "card_id", "title"],
148218 },
219 outputSchema: idResultSchema,
149220 write: true,
150221 run: (who, args, key) => {
151222 const board = boardOf(args);
@@ -168,6 +239,7 @@ export const tools: readonly Tool[] = [
168239 properties: { board_id: { type: "string" }, card_id: { type: "string" } },
169240 required: ["board_id", "card_id"],
170241 },
242 outputSchema: idResultSchema,
171243 write: true,
172244 run: (who, args, key) => {
173245 const board = boardOf(args);
@@ -188,6 +260,7 @@ export const tools: readonly Tool[] = [
188260 properties: { board_id: { type: "string" }, name: { type: "string", maxLength: 60 } },
189261 required: ["board_id", "name"],
190262 },
263 outputSchema: columnSchema,
191264 write: true,
192265 run: (who, args, key) => {
193266 const board = boardOf(args);
@@ -204,7 +277,7 @@ export const tools: readonly Tool[] = [
204277 ];
205278
206279 /** A JSON object's fields, whatever the wire carried. */
207 const fields = (value: unknown): Record<string, unknown> =>
280 export const fields = (value: unknown): Record<string, unknown> =>
208281 typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
209282
210283 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). `key` is who a write's rate limit is charged to — see callerKey in src/limits.ts. */
@@ -218,11 +291,17 @@ export function rpc(who: Who, message: unknown, key: string): { status: number;
218291 const params = fields(m.params);
219292 switch (m.method) {
220293 case "initialize":
221 return reply({ protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "tempo", version: "1" } });
294 return reply({
295 protocolVersion: negotiateProtocolVersion(params.protocolVersion),
296 capabilities: { tools: {} },
297 serverInfo: { name: "tempo", version: "1" },
298 });
222299 case "ping":
223300 return reply({});
224301 case "tools/list":
225 return reply({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
302 return reply({
303 tools: tools.map(({ name, description, inputSchema, outputSchema }) => ({ name, description, inputSchema, outputSchema })),
304 });
226305 case "tools/call": {
227306 const tool = tools.find((t) => t.name === params.name);
228307 if (tool === undefined) return refuse(-32602, `no tool named ${String(params.name)}`);
@@ -235,7 +314,7 @@ export function rpc(who: Who, message: unknown, key: string): { status: number;
235314 const result = tool.run(who, fields(params.arguments), key);
236315 return reply(
237316 result.ok
238 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
317 ? { content: [{ type: "text", text: JSON.stringify(result.value) }], structuredContent: result.value }
239318 : { content: [{ type: "text", text: `${result.error}: ${result.message}` }], isError: true },
240319 );
241320 }
src/routes.ts +13 −1
@@ -8,7 +8,7 @@ import * as bus from "./bus.ts";
88 import type { EventKind } from "./bus.ts";
99 import * as db from "./db.ts";
1010 import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts";
11 import { rpc } from "./mcp.ts";
11 import { checkProtocolVersionHeader, fields, rpc } from "./mcp.ts";
1212 import { boardPage, landingPage } from "./pages/board.ts";
1313 import * as presence from "./presence.ts";
1414 import { ROBOTS_TXT } from "./robots.ts";
@@ -270,6 +270,18 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s
270270 if (method === "POST" && parts.length === 1 && parts[0] === "mcp") {
271271 const message = await request.json().catch(() => undefined);
272272 if (message === undefined) return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
273 const parsed = fields(message);
274 // The handshake itself (`initialize`) carries the version in its body,
275 // not this header — a client has nothing to put in it before a server
276 // has answered once. Every request after that is expected to send it,
277 // and one naming a version we don't speak is refused here, at the
278 // boundary, in the same JSON-RPC shape as any other refusal — never a
279 // bare HTTP status (see src/server.ts's sameOrigin check for the same
280 // point made about the origin header).
281 if (parsed.method !== "initialize") {
282 const headerError = checkProtocolVersionHeader(request.headers.get("mcp-protocol-version"));
283 if (headerError) return json({ jsonrpc: "2.0", id: (parsed.id as unknown) ?? null, error: headerError });
284 }
273285 const { status, body } = rpc(who, message, writeKey);
274286 return body === undefined ? new Response(null, { status }) : json(body, status);
275287 }
src/server.test.ts +57
@@ -13,6 +13,7 @@ const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_
1313 await import("./limits.ts");
1414 const db = await import("./db.ts");
1515 const { THEME_JS } = await import("./pages/board.ts");
16 const { PROTOCOL_VERSIONS } = await import("./mcp.ts");
1617 // The rate limits are process-wide state shared by every test file in one
1718 // `bun test` run (see limits.ts). Nothing here is about being refused, so
1819 // every test starts with them out of the way — whatever this file or any
@@ -663,6 +664,62 @@ test("the MCP server lists its tools and calls add_card as JSON-RPC", async () =
663664 expect(missing.error.code).toBe(-32602);
664665 });
665666
667 test("initialize echoes back an older protocol version this server still supports", async () => {
668 const older = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1];
669 expect(older).not.toBe(PROTOCOL_VERSIONS[0]);
670 const result = await (
671 await fetch(base + "/mcp", {
672 method: "POST",
673 headers: { "content-type": "application/json" },
674 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: older } }),
675 })
676 ).json();
677 expect(result.result.protocolVersion).toBe(older);
678 });
679
680 test("initialize answers this server's newest protocol version when asked for one it doesn't speak", async () => {
681 const result = await (
682 await fetch(base + "/mcp", {
683 method: "POST",
684 headers: { "content-type": "application/json" },
685 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "1999-01-01" } }),
686 })
687 ).json();
688 expect(result.result.protocolVersion).toBe(PROTOCOL_VERSIONS[0]);
689 });
690
691 test("a request after the handshake naming an MCP-Protocol-Version this server doesn't speak is refused as JSON-RPC, not a bare status", async () => {
692 const response = await fetch(base + "/mcp", {
693 method: "POST",
694 headers: { "content-type": "application/json", "mcp-protocol-version": "1999-01-01" },
695 body: JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/list" }),
696 });
697 expect(response.status).toBe(200);
698 const body = await response.json();
699 expect(body.id).toBe(5);
700 expect(body.error.message).toContain("1999-01-01");
701 for (const version of PROTOCOL_VERSIONS) expect(body.error.message).toContain(version);
702 });
703
704 test("a tools/call result's structuredContent matches the JSON already in its text", async () => {
705 const id = db.createBoard("MCP structuredContent board").id; // see the note above on why not POST /boards
706 const [column] = db.listColumns(id);
707 const result = await (
708 await fetch(base + "/mcp", {
709 method: "POST",
710 headers: { "content-type": "application/json" },
711 body: JSON.stringify({
712 jsonrpc: "2.0",
713 id: 1,
714 method: "tools/call",
715 params: { name: "add_card", arguments: { board_id: id, column_id: column.id, title: "Structured" } },
716 }),
717 })
718 ).json();
719 expect(result.result.structuredContent).toEqual(JSON.parse(result.result.content[0].text));
720 expect(result.result.structuredContent.title).toBe("Structured");
721 });
722
666723 test("add_card strips control bytes from a title the same way the web form does", async () => {
667724 const id = db.createBoard("MCP control-byte board").id; // see the note above on why not POST /boards
668725 const [todo] = db.listColumns(id);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs