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
Check · Review · Land · Live done — Landed and live in production.
scout/tempo:scout/77-a-claude-session-can-actually-connect-an → main 2 commits fork
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 | ||
| 121 | 121 | write validates exactly as its page and terminal counterpart does — |
| 122 | 122 | the same trimmed, length-capped title or name, the same board caps — |
| 123 | 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. | |
| 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. | |
| 128 | 142 | - **Concurrent drags**: two people dragging cards at once never tear |
| 129 | 143 | each other's write — every move runs in one transaction — and cards |
| 130 | 144 | 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 | ||
| 24 | 24 | const BOARD_ID = /^[a-z0-9]{8}$/; |
| 25 | 25 | const ENTITY_ID = /^[a-z0-9]{12}$/; |
| 26 | 26 | |
| 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 | ||
| 27 | 51 | type Result<T> = { ok: true; value: T } | { ok: false; error: string; message: string }; |
| 28 | 52 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 29 | 53 | const fail = <T>(error: string, message: string): Result<T> => ({ ok: false, error, message }); |
| @@ -32,6 +56,7 @@ interface Tool { | ||
| 32 | 56 | readonly name: string; |
| 33 | 57 | readonly description: string; |
| 34 | 58 | readonly inputSchema: Record<string, unknown>; |
| 59 | readonly outputSchema: Record<string, unknown>; | |
| 35 | 60 | // Whether calling this tool mutates a board — a write tool spends a |
| 36 | 61 | // token from writeBucket itself, right before the db call that would |
| 37 | 62 | // actually cost something, the same as src/routes.ts. |
| @@ -44,11 +69,51 @@ const boardOf = (args: Record<string, unknown>): db.Board | undefined => { | ||
| 44 | 69 | return BOARD_ID.test(id) ? (db.getBoard(id) ?? undefined) : undefined; |
| 45 | 70 | }; |
| 46 | 71 | |
| 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 | ||
| 47 | 111 | export const tools: readonly Tool[] = [ |
| 48 | 112 | { |
| 49 | 113 | name: "list_boards", |
| 50 | 114 | description: "The boards you have kept by signing in. An anonymous caller sees none.", |
| 51 | 115 | inputSchema: { type: "object", properties: {} }, |
| 116 | outputSchema: { type: "array", items: boardSchema }, | |
| 52 | 117 | write: false, |
| 53 | 118 | run: (who, _args) => ok(who.role === "anon" ? [] : db.listBoardsByOwner(who.id)), |
| 54 | 119 | }, |
| @@ -57,6 +122,8 @@ export const tools: readonly Tool[] = [ | ||
| 57 | 122 | description: |
| 58 | 123 | "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.", |
| 59 | 124 | inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] }, |
| 125 | outputSchema: { type: "array", items: columnWithCountSchema }, | |
| 126 | write: false, | |
| 60 | 127 | run: (_who, args) => { |
| 61 | 128 | const board = boardOf(args); |
| 62 | 129 | if (!board) return fail("not_found", "no such board"); |
| @@ -76,6 +143,7 @@ export const tools: readonly Tool[] = [ | ||
| 76 | 143 | name: "list_cards", |
| 77 | 144 | description: "Every card on a board, each with the column it's in. Call this before move_card — a card_id comes from here.", |
| 78 | 145 | inputSchema: { type: "object", properties: { board_id: { type: "string" } }, required: ["board_id"] }, |
| 146 | outputSchema: { type: "array", items: cardSchema }, | |
| 79 | 147 | write: false, |
| 80 | 148 | run: (_who, args) => { |
| 81 | 149 | const board = boardOf(args); |
| @@ -91,6 +159,7 @@ export const tools: readonly Tool[] = [ | ||
| 91 | 159 | properties: { board_id: { type: "string" }, column_id: { type: "string" }, title: { type: "string", maxLength: 200 } }, |
| 92 | 160 | required: ["board_id", "column_id", "title"], |
| 93 | 161 | }, |
| 162 | outputSchema: cardSchema, | |
| 94 | 163 | write: true, |
| 95 | 164 | run: (who, args, key) => { |
| 96 | 165 | const board = boardOf(args); |
| @@ -121,6 +190,7 @@ export const tools: readonly Tool[] = [ | ||
| 121 | 190 | }, |
| 122 | 191 | required: ["board_id", "card_id", "column_id"], |
| 123 | 192 | }, |
| 193 | outputSchema: idResultSchema, | |
| 124 | 194 | write: true, |
| 125 | 195 | run: (who, args, key) => { |
| 126 | 196 | const board = boardOf(args); |
| @@ -146,6 +216,7 @@ export const tools: readonly Tool[] = [ | ||
| 146 | 216 | properties: { board_id: { type: "string" }, card_id: { type: "string" }, title: { type: "string", maxLength: 200 } }, |
| 147 | 217 | required: ["board_id", "card_id", "title"], |
| 148 | 218 | }, |
| 219 | outputSchema: idResultSchema, | |
| 149 | 220 | write: true, |
| 150 | 221 | run: (who, args, key) => { |
| 151 | 222 | const board = boardOf(args); |
| @@ -168,6 +239,7 @@ export const tools: readonly Tool[] = [ | ||
| 168 | 239 | properties: { board_id: { type: "string" }, card_id: { type: "string" } }, |
| 169 | 240 | required: ["board_id", "card_id"], |
| 170 | 241 | }, |
| 242 | outputSchema: idResultSchema, | |
| 171 | 243 | write: true, |
| 172 | 244 | run: (who, args, key) => { |
| 173 | 245 | const board = boardOf(args); |
| @@ -188,6 +260,7 @@ export const tools: readonly Tool[] = [ | ||
| 188 | 260 | properties: { board_id: { type: "string" }, name: { type: "string", maxLength: 60 } }, |
| 189 | 261 | required: ["board_id", "name"], |
| 190 | 262 | }, |
| 263 | outputSchema: columnSchema, | |
| 191 | 264 | write: true, |
| 192 | 265 | run: (who, args, key) => { |
| 193 | 266 | const board = boardOf(args); |
| @@ -204,7 +277,7 @@ export const tools: readonly Tool[] = [ | ||
| 204 | 277 | ]; |
| 205 | 278 | |
| 206 | 279 | /** 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> => | |
| 208 | 281 | typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; |
| 209 | 282 | |
| 210 | 283 | /** 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; | ||
| 218 | 291 | const params = fields(m.params); |
| 219 | 292 | switch (m.method) { |
| 220 | 293 | 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 | }); | |
| 222 | 299 | case "ping": |
| 223 | 300 | return reply({}); |
| 224 | 301 | 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 | }); | |
| 226 | 305 | case "tools/call": { |
| 227 | 306 | const tool = tools.find((t) => t.name === params.name); |
| 228 | 307 | 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; | ||
| 235 | 314 | const result = tool.run(who, fields(params.arguments), key); |
| 236 | 315 | return reply( |
| 237 | 316 | result.ok |
| 238 | ? { content: [{ type: "text", text: JSON.stringify(result.value) }] } | |
| 317 | ? { content: [{ type: "text", text: JSON.stringify(result.value) }], structuredContent: result.value } | |
| 239 | 318 | : { content: [{ type: "text", text: `${result.error}: ${result.message}` }], isError: true }, |
| 240 | 319 | ); |
| 241 | 320 | } |
src/routes.ts +13 −1
| @@ -8,7 +8,7 @@ import * as bus from "./bus.ts"; | ||
| 8 | 8 | import type { EventKind } from "./bus.ts"; |
| 9 | 9 | import * as db from "./db.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | 13 | import * as presence from "./presence.ts"; |
| 14 | 14 | import { ROBOTS_TXT } from "./robots.ts"; |
| @@ -270,6 +270,18 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s | ||
| 270 | 270 | if (method === "POST" && parts.length === 1 && parts[0] === "mcp") { |
| 271 | 271 | const message = await request.json().catch(() => undefined); |
| 272 | 272 | 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 | } | |
| 273 | 285 | const { status, body } = rpc(who, message, writeKey); |
| 274 | 286 | return body === undefined ? new Response(null, { status }) : json(body, status); |
| 275 | 287 | } |
src/server.test.ts +57
| @@ -13,6 +13,7 @@ const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_ | ||
| 13 | 13 | await import("./limits.ts"); |
| 14 | 14 | const db = await import("./db.ts"); |
| 15 | 15 | const { THEME_JS } = await import("./pages/board.ts"); |
| 16 | const { PROTOCOL_VERSIONS } = await import("./mcp.ts"); | |
| 16 | 17 | // The rate limits are process-wide state shared by every test file in one |
| 17 | 18 | // `bun test` run (see limits.ts). Nothing here is about being refused, so |
| 18 | 19 | // 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 () = | ||
| 663 | 664 | expect(missing.error.code).toBe(-32602); |
| 664 | 665 | }); |
| 665 | 666 | |
| 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 | ||
| 666 | 723 | test("add_card strips control bytes from a title the same way the web form does", async () => { |
| 667 | 724 | const id = db.createBoard("MCP control-byte board").id; // see the note above on why not POST /boards |
| 668 | 725 | const [todo] = db.listColumns(id); |
new above
offline