298 lines
9.5 KiB
JavaScript
298 lines
9.5 KiB
JavaScript
#!/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_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...<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);
|