Files
mnote/packages/pi-mnote/extensions/mnote-mcp/client.mjs
T

522 lines
18 KiB
JavaScript
Raw 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.
/**
* 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);
});