Files
BlackBean/claude code/blackboard-server.mjs
2026-08-14 23:41:57 +08:00

298 lines
9.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<file>O_APPENDPOSIX 下并发不交错)
* - done 原子创建 DONE 标记(O_CREAT|O_EXCL,保证唯一创建)
*
* 路径安全:所有传入路径都做 traversal 校验,拒绝绝对路径与 .. 越界。
*
* 通信协议:MCP stdioJSON-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 用了内部 cleanNode 标准 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...<truncated>" : 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<missing>\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" : "<not created yet — cooperation is still in progress>\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>。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);