feat: integrate pi rust lab runtime

This commit is contained in:
Agent Board
2026-07-10 10:54:34 +08:00
parent c8472bb898
commit 896c94b696
73 changed files with 19852 additions and 1152 deletions
@@ -0,0 +1,521 @@
/**
* MNote MCP Client — 最小 MCP client,仅使用 Node 内置模块
*
* 支持协议:
* - stdio JSONL (child_process.spawn)
* - streamable-http POST (http/https 模块)
*
* 请求输入格式 (JSON)
* { "server": "name", "mode": "list|status|call",
* "tool": "toolName", "arguments": {} }
* 默认读取 argv[2] 指向的文件;argv[2] 为 "-" 时从 stdin 读取;
* argv[2] 为 "--request-json" 时直接读取 argv[3]。
*
* 输出:stdout 打印 JSONstderr 仅用于诊断
*
* 限制:不含 OAuth/UI/资源写入
*/
import { spawn } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
/* ============================================================
* 常量
* ============================================================ */
const MCP_VERSION = "2024-11-05";
const CLIENT_NAME = "mnote-mcp-client";
const CLIENT_VERSION = "0.1.0";
const REQUEST_TIMEOUT = 30_000;
const SSE_TIMEOUT = 60_000;
/* ============================================================
* 工具
* ============================================================ */
function resolveEnv(value) {
if (typeof value !== "string") return value;
return value.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] ?? "");
}
let _reqId = 0;
function nextId() { return ++_reqId; }
function errorResult(message, details = null) {
return { ok: false, error: message, details, timestamp: new Date().toISOString() };
}
function okResult(data) {
return { ok: true, data, timestamp: new Date().toISOString() };
}
function extractResult(resp) {
if (resp == null) throw new Error("Empty response from server");
if (resp.error) throw new Error(`JSON-RPC error: ${resp.error.message || JSON.stringify(resp.error)}`);
if (resp.result !== undefined) return resp.result;
return resp;
}
/* ============================================================
* 读取 MCP 配置
* ============================================================ */
function loadMcpConfig(extDir) {
const configPath = process.env.MNOTE_MCP_CONFIG_PATH || resolve(extDir, ".pi", "mcp.json");
if (!existsSync(configPath)) return { servers: {} };
try {
const raw = readFileSync(configPath, "utf-8").trim();
if (!raw) return { servers: {} };
const parsed = JSON.parse(raw);
const servers = parsed.mcpServers || parsed.servers || parsed;
if (typeof servers !== "object" || Array.isArray(servers)) {
return { servers: {}, _parseError: "Config root must be an object with mcpServers key" };
}
return { servers };
} catch (e) {
return { servers: {}, _parseError: e.message };
}
}
/* ============================================================
* stdio transport
* 行读取器 + 响应队列,支持 send(等响应)和 sendNotify(不等)
* ============================================================ */
function createStdioTransport(serverConfig, timeout = REQUEST_TIMEOUT) {
const cmd = resolveEnv(serverConfig.command);
if (!cmd) throw new Error("stdio transport requires command");
const args = (serverConfig.args || []).map(resolveEnv);
const env = serverConfig.env
? { ...process.env, ...Object.fromEntries(
Object.entries(serverConfig.env).map(([k, v]) => [k, resolveEnv(v)])
)}
: process.env;
return new Promise((resolvePromise, rejectPromise) => {
const detached = process.platform !== "win32";
const child = spawn(cmd, args, {
env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
detached,
});
let buf = "";
let stderrBuf = "";
let pending = null; // { resolve, reject, timer }
let closed = false;
const startTimer = () => {
return setTimeout(() => {
if (pending) {
const p = pending;
pending = null;
p.reject(new Error(`Response timeout after ${timeout}ms`));
clearTimeout(p.timer);
}
}, timeout);
};
child.stdout.on("data", (chunk) => {
buf += chunk.toString();
if (!pending) return;
const nl = buf.indexOf("\n");
if (nl < 0) return;
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const p = pending;
pending = null;
clearTimeout(p.timer);
try { p.resolve(JSON.parse(line)); }
catch { p.reject(new Error(`Invalid JSON: ${line.slice(0, 200)}`)); }
});
child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString(); });
child.on("error", (err) => {
if (closed) return;
closed = true;
if (pending) { pending.reject(new Error(`Spawn error: ${err.message}`)); clearTimeout(pending.timer); pending = null; }
rejectPromise(new Error(`Spawn error: ${err.message}`));
});
child.on("close", (code) => {
if (closed) return;
closed = true;
if (pending) {
pending.reject(new Error(`Process exited (${code}) before response: ${stderrBuf.slice(0, 300)}`));
clearTimeout(pending.timer);
pending = null;
}
});
const transport = {
_transport: "stdio",
_child: child,
/** 发送并等待响应 */
async send(msg) {
if (closed) throw new Error("Transport closed");
if (pending) throw new Error("Concurrent stdio MCP requests are not supported");
const line = JSON.stringify(msg) + "\n";
return new Promise((resolve, reject) => {
const timer = startTimer();
const current = { resolve, reject, timer };
pending = current;
child.stdin.write(line, (err) => {
if (!err || pending !== current) return;
pending = null;
clearTimeout(timer);
reject(new Error(`Write error: ${err.message}`));
});
// 尝试立即读取(数据可能已在缓冲区)
if (!pending) return;
const nl = buf.indexOf("\n");
if (nl < 0) return;
const p = pending;
pending = null;
clearTimeout(p.timer);
const line2 = buf.slice(0, nl);
buf = buf.slice(nl + 1);
try { p.resolve(JSON.parse(line2)); }
catch { p.reject(new Error(`Invalid JSON: ${line2.slice(0, 200)}`)); }
});
},
/** 发送通知(不等待响应) */
async sendNotify(msg) {
if (closed) return;
const line = JSON.stringify(msg) + "\n";
return new Promise((resolve, reject) => {
child.stdin.write(line, (err) => {
if (err) reject(new Error(`Write error: ${err.message}`));
else resolve();
});
});
},
async close() {
if (pending) { pending.reject(new Error("Transport closed")); clearTimeout(pending.timer); pending = null; }
if (closed) return;
closed = true;
child.stdin.end();
const kill = (signal) => {
try {
if (detached && child.pid) process.kill(-child.pid, signal);
else child.kill(signal);
} catch {}
};
kill("SIGTERM");
await new Promise((resolve) => {
if (child.exitCode !== null) {
resolve();
return;
}
const forceTimer = setTimeout(() => {
kill("SIGKILL");
resolve();
}, 1000);
child.once("close", () => {
clearTimeout(forceTimer);
resolve();
});
});
},
};
resolvePromise(transport);
});
}
/* ============================================================
* HTTP transport
* ============================================================ */
function createHttpTransport(serverConfig) {
const urlStr = resolveEnv(serverConfig.url);
if (!urlStr) throw new Error("HTTP transport requires url");
const url = new URL(urlStr);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error(`Unsupported HTTP URL scheme: ${url.protocol}`);
}
const isHttps = url.protocol === "https:";
const requester = isHttps ? httpsRequest : httpRequest;
const configuredHeaders = Object.fromEntries(
Object.entries(serverConfig.headers || {}).map(([key, value]) => [key, resolveEnv(value)]),
);
let sessionId = null;
function parseSseResponse(raw, requestId) {
const payloads = [];
let dataLines = [];
const flush = () => {
if (dataLines.length === 0) return;
const data = dataLines.join("\n");
dataLines = [];
try {
payloads.push(JSON.parse(data));
} catch {
payloads.push({ _rawData: data });
}
};
for (const rawLine of raw.split(/\r?\n/)) {
if (rawLine === "") {
flush();
continue;
}
if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trimStart());
}
}
flush();
return payloads.find((payload) => payload?.id === requestId)
|| payloads.find((payload) => payload?.result !== undefined || payload?.error)
|| payloads.at(-1)
|| { _sseRaw: raw };
}
return {
_transport: "http",
_url: urlStr,
async send(msg, t = SSE_TIMEOUT) {
const body = JSON.stringify(msg);
return new Promise((resolve, reject) => {
let settled = false;
let req;
const finishResolve = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const finishReject = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(error);
};
const timer = setTimeout(() => {
req?.destroy();
finishReject(new Error(`HTTP timeout after ${t}ms`));
}, t);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": MCP_VERSION,
...configuredHeaders,
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
const opts = {
hostname: url.hostname, port: url.port, path: url.pathname + url.search,
method: "POST",
headers,
};
req = requester(opts, (res) => {
sessionId = res.headers["mcp-session-id"] || sessionId;
const ct = res.headers["content-type"] || "";
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const raw = Buffer.concat(chunks).toString();
if ((res.statusCode || 500) >= 400) {
finishReject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`));
return;
}
if (!raw.trim()) {
finishResolve({});
return;
}
if (ct.includes("text/event-stream")) {
finishResolve(parseSseResponse(raw, msg.id));
} else {
try { finishResolve(JSON.parse(raw)); }
catch { finishResolve({ _raw: raw }); }
}
});
res.on("error", (err) => finishReject(new Error(`HTTP response error: ${err.message}`)));
});
req.on("error", (err) => finishReject(new Error(`HTTP error: ${err.message}`)));
req.write(body);
req.end();
});
},
async sendNotify(msg) {
await this.send(msg, 5000);
},
async close() {},
};
}
async function createTransport(serverConfig) {
const transport = String(serverConfig.transport || "").trim().toLowerCase();
if (serverConfig.url || transport === "streamable-http" || transport === "sse" || transport === "http") {
return createHttpTransport(serverConfig);
}
if (serverConfig.command || transport === "stdio" || !transport) {
return createStdioTransport(serverConfig);
}
throw new Error(`Unsupported MCP transport: ${transport}`);
}
/* ============================================================
* MCP 会话
* ============================================================ */
class McpSession {
constructor(transport, serverName) {
this.transport = transport;
this.serverName = serverName;
this.initialized = false;
this.serverCapabilities = null;
this.serverVersion = null;
}
async initialize() {
const initMsg = {
jsonrpc: "2.0", id: nextId(), method: "initialize",
params: {
protocolVersion: MCP_VERSION,
capabilities: { tools: {} },
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
},
};
const resp = await this.transport.send(initMsg, REQUEST_TIMEOUT);
const result = extractResult(resp);
if (result.protocolVersion) {
this.serverCapabilities = result.capabilities || {};
this.serverVersion = result.serverInfo?.name
? `${result.serverInfo.name} ${result.serverInfo.version || ""}`
: "unknown";
this.initialized = true;
// 通知(不等待响应)
const notif = { jsonrpc: "2.0", method: "notifications/initialized" };
this.transport.sendNotify(notif).catch(() => {});
} else {
throw new Error(`Unexpected initialize result: ${JSON.stringify(result).slice(0, 300)}`);
}
}
async listTools() {
if (!this.initialized) await this.initialize();
const msg = { jsonrpc: "2.0", id: nextId(), method: "tools/list", params: {} };
const resp = await this.transport.send(msg, REQUEST_TIMEOUT);
const result = extractResult(resp);
if (result.tools) return result.tools;
throw new Error(`tools/list missing 'tools': ${JSON.stringify(result).slice(0, 300)}`);
}
async callTool(name, args = {}) {
if (!this.initialized) await this.initialize();
const msg = {
jsonrpc: "2.0", id: nextId(), method: "tools/call",
params: { name, arguments: args },
};
const resp = await this.transport.send(msg, REQUEST_TIMEOUT);
return extractResult(resp);
}
async close() { await this.transport.close(); }
}
async function readRequestInput(requestArg, inlineJson) {
if (requestArg === "--request-json") {
if (!inlineJson) throw new Error("--request-json 缺少 JSON 参数");
return inlineJson;
}
if (requestArg !== "-") {
return readFileSync(requestArg, "utf-8");
}
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf-8");
}
/* ============================================================
* 主流程
* ============================================================ */
async function main() {
const requestArg = process.argv[2];
if (!requestArg) {
console.log(JSON.stringify(errorResult(
"Usage: node client.mjs <request.json|-> | --request-json '<json>'",
)));
process.exit(1);
}
let request;
try { request = JSON.parse(await readRequestInput(requestArg, process.argv[3])); }
catch (e) { console.log(JSON.stringify(errorResult(`Cannot read request: ${e.message}`))); process.exit(1); }
const { server: serverName, mode } = request;
if (!serverName) { console.log(JSON.stringify(errorResult("Missing 'server'"))); process.exit(1); }
const extDir = dirname(fileURLToPath(import.meta.url));
const config = loadMcpConfig(extDir);
const serverConfig = config.servers[serverName];
if (mode === "status") {
const result = {
configured: !!serverConfig, serverName,
configError: config._parseError || null,
config: serverConfig ? {
transport: serverConfig.transport || null,
command: serverConfig.command || null, url: serverConfig.url || null,
argsCount: serverConfig.args?.length || 0, hasEnv: !!serverConfig.env, disabled: !!serverConfig.disabled,
} : null,
availableServers: Object.keys(config.servers),
};
if (serverConfig && !serverConfig.disabled) {
try {
const transport = await createTransport(serverConfig);
const session = new McpSession(transport, serverName);
await session.initialize();
result.connected = true;
result.serverVersion = session.serverVersion;
result.serverCapabilities = session.serverCapabilities;
await session.close();
} catch (e) { result.connected = false; result.connectError = e.message; }
} else if (serverConfig?.disabled) { result.statusNote = "disabled"; }
console.log(JSON.stringify(okResult(result)));
return;
}
if (!serverConfig) {
console.log(JSON.stringify(errorResult(`Server "${serverName}" not found. Available: ${Object.keys(config.servers).join(", ") || "(none)"}`)));
process.exit(1);
}
if (serverConfig.disabled) {
console.log(JSON.stringify(errorResult(`Server "${serverName}" is disabled`)));
process.exit(1);
}
let transport;
try { transport = await createTransport(serverConfig); }
catch (e) { console.log(JSON.stringify(errorResult(`Transport error: ${e.message}`))); process.exit(1); }
const session = new McpSession(transport, serverName);
try {
if (mode === "list") {
const tools = await session.listTools();
console.log(JSON.stringify(okResult({ server: serverName, tools, toolCount: tools.length })));
} else if (mode === "call") {
const { tool, arguments: args } = request;
if (!tool) { console.log(JSON.stringify(errorResult("Missing 'tool'"))); process.exit(1); }
const result = await session.callTool(tool, args || {});
console.log(JSON.stringify(okResult({ server: serverName, tool, result })));
} else {
console.log(JSON.stringify(errorResult(`Unknown mode: ${mode}`)));
process.exit(1);
}
} catch (e) {
console.log(JSON.stringify(errorResult(`MCP ${mode} error: ${e.message}`)));
} finally {
await session.close();
}
}
main().catch((e) => {
console.log(JSON.stringify(errorResult(`Fatal: ${e.message}`)));
process.exit(1);
});