#!/usr/bin/env node /** * blackboard MCP server:让 Claude Code 适配 blackboard 协作协议。 * * 与 pi-coop/coop.ts 的 blackboard 工具行为对齐: * - read 无 path → 全局快照(task.md / messages / workspace / DONE 状态) * 有 path → 单文件内容 * - post 原子追加一条消息到 messages/(O_APPEND,POSIX 下并发不交错) * - done 原子创建 DONE 标记(O_CREAT|O_EXCL,保证唯一创建) * * 路径安全:所有传入路径都做 traversal 校验,拒绝绝对路径与 .. 越界。 * * 通信协议:MCP stdio(JSON-RPC 2.0)。Claude Code 通过 --mcp-config 加载本 server, * 工具命名空间为 mcp__blackboard__read / mcp__blackboard__post / mcp__blackboard__done。 * * 环境变量: * BB 黑板根目录(默认 /blackboard,由 supervisor 注入) */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { openSync, closeSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, } from "node:fs"; import { join, resolve, sep, isAbsolute, normalize, basename } from "node:path"; // clean 等价于 normalize:规范化路径(消除 . 和 ..,合并分隔符)。 // pi 的 coop.ts 用了内部 clean,Node 标准 path 模块对应方法是 normalize。 const clean = normalize; // 单条 post 上限:保证单次原子写,且不撑爆上下文(与 pigo / pi-coop 一致)。 const MAX_POST_BYTES = 32 * 1024; // 单文件 read 上限。 const MAX_READ_BYTES = 32 * 1024; const bbRoot = process.env.BB ?? "/blackboard"; // ---- 路径安全:safePath 把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界 ---- function safePath(p) { const trimmed = (p ?? "").trim(); if (trimmed === "") throw new Error("empty path"); const c = clean(trimmed); if (isAbsolute(c)) throw new Error(`path "${p}" must be relative to the blackboard root`); const rootClean = clean(bbRoot); const full = join(rootClean, c); if (full !== rootClean && !full.startsWith(rootClean + sep)) { throw new Error(`path "${p}" escapes the blackboard root`); } return full; } function truncate(s, max) { return s.length > max ? s.slice(0, max) + "\n..." : s; } // ---- blackboard 工具实现 ---- // read:无 path → 全局快照;有 path → 单文件内容。 function readAction(pathParam) { const p = pathParam != null ? String(pathParam) : ""; if (p.trim() !== "") return readFile(p); const b = []; // task.md try { const data = readFileSync(join(bbRoot, "task.md"), "utf8"); b.push("# task.md\n" + truncate(data, MAX_READ_BYTES) + "\n"); } catch { b.push("# task.md\n\n"); } // messages/ b.push("\n# messages/ (" + listNames("messages").join(", ") + ")\n"); for (const name of listListing("messages")) b.push(" - " + name + "\n"); // workspace/ b.push("\n# workspace/ (your workspace)\n"); for (const name of listListing("workspace")) b.push(" - " + name + "\n"); // DONE let doneText = ""; try { doneText = truncate(readFileSync(join(bbRoot, "DONE"), "utf8"), 4096); } catch {} b.push("\n# DONE\n"); b.push(doneText ? "EXISTS:\n" + doneText + "\n" : "\n"); // environment const env = []; for (const k of ["BB", "ROUND", "NAME"]) { const v = (process.env[k] ?? "").trim(); if (v) env.push(` ${k}=${v}`); } if (env.length) b.push("\n# environment\n" + env.join("\n") + "\n"); return b.join(""); } function readFile(p) { const full = safePath(p); let info; try { info = statSync(full); } catch (e) { throw new Error(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`); } if (info.isDirectory()) throw new Error(`blackboard read: ${p} is a directory; only files can be read`); const data = readFileSync(full, "utf8"); return "# " + p + "\n" + truncate(data, MAX_READ_BYTES); } // post:原子追加一条消息到 messages/。file 必须是裸 *.md 名。 function postAction(fileParam, contentParam) { const name = String(fileParam ?? "").trim(); if (!validMessageName(name)) { throw new Error('blackboard post: file must be a bare name ending in .md (e.g. "round-1-a.md"), no path separators, no ".."'); } const content = String(contentParam ?? "").trim(); if (content === "") throw new Error("blackboard post: content must not be empty"); if (Buffer.byteLength(content) > MAX_POST_BYTES) { throw new Error(`blackboard post: content too large (${Buffer.byteLength(content)} bytes, max ${MAX_POST_BYTES})`); } const dir = join(bbRoot, "messages"); mkdirSync(dir, { recursive: true }); // O_APPEND 单次写:POSIX 下并发 post 不会交错字节。 appendFileSync(join(dir, name), content + "\n"); return `Message appended to messages/${name}`; } // done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。 function doneAction(summaryParam) { const summary = String(summaryParam ?? "").trim(); if (summary === "") throw new Error("blackboard done: summary must not be empty (include the final delivery summary)"); const header = "Blackboard cooperation DONE\ncreated: " + new Date().toISOString() + "\n\n"; const target = join(bbRoot, "DONE"); let fd; try { fd = openSync(target, "wx"); } catch (e) { if (existsSync(target)) { const existing = readFileSync(target, "utf8"); throw new Error("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, 4096)); } throw e; } try { writeFileSync(fd, header + summary); } finally { closeSync(fd); } return "DONE marker created. Cooperation finished."; } // 校验 post 文件名:裸 *.md,无分隔符,无 . / .. function validMessageName(name) { if (name === "" || !name.endsWith(".md")) return false; if (/[\\/]/.test(name) || name === "." || name === "..") return false; const base = name.slice(0, -3); if (base === "" || base.startsWith(".") || base.includes("..")) return false; return true; } function listNames(sub) { try { return readdirSync(join(bbRoot, sub)).sort(); } catch { return []; } } function listListing(sub) { try { return readdirSync(join(bbRoot, sub)) .sort() .map((name) => { try { const info = statSync(join(bbRoot, sub, name)); if (info.isDirectory()) return `${name} (dir)`; return `${name} (${info.size} bytes, ${info.mtime.toISOString().slice(11, 19)})`; } catch { return name; } }); } catch { return []; } } // ---- MCP server ---- const server = new Server( { name: "blackboard", version: "1.0.0" }, { capabilities: { tools: {} } }, ); // 列出工具:三个 action 拆成独立工具,便于 Claude Code 自动批准白名单匹配。 server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "read", description: "Read the task blackboard used by the coop runner. " + "Without a path: returns a global snapshot (task.md, messages listing, workspace listing, DONE state). " + "With a path like \"workspace/exploit.py\": returns the contents of that one file. " + "The blackboard root, current round and your name are in the environment as BB, ROUND, NAME.", inputSchema: { type: "object", properties: { path: { type: "string", description: 'A path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.', }, }, }, }, { name: "post", description: "Atomically append a progress note to the blackboard messages/ directory. " + "The file must be a bare .md name under messages/ (e.g. round-1-a.md). Max 32 KiB per post.", inputSchema: { type: "object", properties: { file: { type: "string", description: 'Bare file name under messages/, must end in .md (e.g. round-1-a.md).', }, content: { type: "string", description: "The message body (max 32 KiB).", }, }, required: ["file", "content"], }, }, { name: "done", description: "Atomically create the DONE marker with a final delivery summary. " + "Only call this when the deliverable is truly complete (or confirmed unsolvable). Fails if DONE already exists.", inputSchema: { type: "object", properties: { summary: { type: "string", description: "Final delivery summary written into DONE (include key results / flag / submit response / artifact list).", }, }, required: ["summary"], }, }, ], })); // 调用工具:根据 name 分发到对应实现,错误转为文本返回(不抛异常,保持 MCP 协议干净)。 server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { let text; switch (name) { case "read": text = readAction(args?.path); break; case "post": text = postAction(args?.file, args?.content); break; case "done": text = doneAction(args?.summary); break; default: return { content: [{ type: "text", text: `blackboard: unknown tool "${name}" (want read|post|done)` }], isError: true, }; } return { content: [{ type: "text", text }] }; } catch (e) { return { content: [{ type: "text", text: `blackboard ${name}: ${e instanceof Error ? e.message : String(e)}` }], isError: true, }; } }); // 启动 stdio transport,由 Claude Code 通过子进程方式拉起。 const transport = new StdioServerTransport(); await server.connect(transport);