#!/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);
