feat: integrate pi rust lab runtime
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"example-filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 打印 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);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* MNote MCP 扩展 — Pi Rust MCP 工具门面
|
||||
*
|
||||
* Pi Rust 0.1.21 的异步 pi.exec()/嵌套 HTTP hostcall 在真实网页工具调用中
|
||||
* 可能卡到扩展任务超时。官方 node:child_process shim 的 execFileSync 通过
|
||||
* __pi_exec_sync_native 执行,因此这里同步启动相邻 client.mjs。
|
||||
*
|
||||
* client.mjs 只读取扩展目录相邻的 .pi/mcp.json,并且请求只能选择其中已配置
|
||||
* 的 server,不接受任意命令或配置路径。
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
interface ExtensionAPI {
|
||||
registerTool: (spec: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||
details?: Record<string, unknown>;
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MCP_CLIENT_PATH = path.join(EXTENSION_DIR, "client.mjs");
|
||||
const MCP_REQUEST_MAX_CHARS = 128 * 1024;
|
||||
const MCP_CLIENT_TIMEOUT_MS = 90_000;
|
||||
const MCP_CLIENT_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
|
||||
const mcpToolParameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
server: {
|
||||
type: "string",
|
||||
description: "MCP 服务器名称(对应当前 MNote Pi session 的 mcp.json)",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["list", "status", "call"],
|
||||
description: "操作模式:list=列出工具,status=检查服务器状态,call=调用工具",
|
||||
},
|
||||
tool: {
|
||||
type: "string",
|
||||
description: "mode=call 时需要调用的工具名称",
|
||||
},
|
||||
arguments: {
|
||||
type: "object",
|
||||
description: "mode=call 时传入工具的参数对象",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ["server", "mode"],
|
||||
};
|
||||
|
||||
function executeMcpRequest(params: {
|
||||
server: string;
|
||||
mode: string;
|
||||
tool?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
if (!existsSync(MCP_CLIENT_PATH)) {
|
||||
throw new Error(`MNote MCP client 不存在: ${MCP_CLIENT_PATH}`);
|
||||
}
|
||||
const requestJson = JSON.stringify({
|
||||
server: params.server,
|
||||
mode: params.mode,
|
||||
tool: params.tool,
|
||||
arguments: params.arguments || {},
|
||||
});
|
||||
if (requestJson.length > MCP_REQUEST_MAX_CHARS) {
|
||||
throw new Error(`MCP 请求超过 ${MCP_REQUEST_MAX_CHARS} 字符限制`);
|
||||
}
|
||||
const stdout = execFileSync("node", [
|
||||
MCP_CLIENT_PATH,
|
||||
"--request-json",
|
||||
requestJson,
|
||||
], {
|
||||
cwd: EXTENSION_DIR,
|
||||
timeout: MCP_CLIENT_TIMEOUT_MS,
|
||||
maxBuffer: MCP_CLIENT_MAX_BUFFER,
|
||||
});
|
||||
const text = String(stdout || "").trim();
|
||||
if (!text) {
|
||||
throw new Error("MNote MCP client 未返回结果");
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(text);
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("返回值不是 JSON 对象");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`MNote MCP client 返回非 JSON: ${text.slice(0, 500)}`
|
||||
+ (error instanceof Error ? ` (${error.message})` : ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function mnoteMcpExtension(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "mcp",
|
||||
label: "MNote MCP",
|
||||
description: "通过 Pi Rust 同步本地 client 调用当前 session 配置的 MCP 服务器。" +
|
||||
"支持 list(列出工具)、status(检查连通性)、call(调用工具)三种模式。",
|
||||
parameters: mcpToolParameters,
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: { server: string; mode: string; tool?: string; arguments?: Record<string, unknown> },
|
||||
): Promise<ToolResult> {
|
||||
const { server, mode, tool, arguments: args } = params;
|
||||
if (!["list", "status", "call"].includes(mode)) {
|
||||
return {
|
||||
content: [{ type: "text", text: `无效 mode: "${mode}"。可选值: list, status, call` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (mode === "call" && !tool) {
|
||||
return {
|
||||
content: [{ type: "text", text: 'mode=call 时缺少必填参数 "tool"' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = executeMcpRequest({ server, mode, tool, arguments: args });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||
details: {
|
||||
server,
|
||||
mode,
|
||||
tool: tool || null,
|
||||
result,
|
||||
transport: "pi-rust-sync-client",
|
||||
},
|
||||
isError: result.ok === false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `MCP 调用失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user