/** * 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 ''", ))); 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); });