522 lines
18 KiB
JavaScript
522 lines
18 KiB
JavaScript
/**
|
||
* 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 打印 JSON,stderr 仅用于诊断
|
||
*
|
||
* 限制:不含 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);
|
||
});
|