接入 MCP 工具
通过 MCP 服务把自己的工具提供给 EvoX。本页完成一个只读接入:让 Agent 读取固定项目的 npm 脚本,验证工具发现、调用和错误处理。连接管理与凭证表单见MCP 服务器;向其他 Agent 提供 Evolver 经验工具见Evolver Proxy 与 MCP。
定义工具的作用范围
本地示例只用于运行账号控制的项目目录及父目录;其他用户不得替换这些目录或在调用期间修改文件。服务拒绝符号链接、核对已打开文件的身份,并从同一描述符最多读取 64 KiB 加一个溢出检测字节;共享恶意文件系统的隔离由系统账号与权限负责。
示例工具 read_project_scripts 只读取启动时指定项目的 package.json,返回 scripts,不执行脚本、不写文件、不访问网络。项目路径由服务启动参数指定,不接受 Agent 随意传入文件路径。
| 契约 | 本例的决定 |
|---|---|
| 输入 | 空对象 {};拒绝额外字段 |
| 输出 | 文本内容中的 JSON 对象,包含 scripts |
| 资源 | 固定项目的普通 package.json,上限 64 KiB |
| 错误 | 无效参数返回协议错误;文件读取失败返回 isError: true |
| 副作用 | 无文件写入与命令执行 |
创建 stdio 服务
将以下代码保存为项目之外的 read-project-scripts.mjs。它使用 Node.js 内置模块,标准输出只写 MCP 协议消息。实际服务还需要按部署方式补充会话管理、资源限制与访问控制。
import { createInterface } from "node:readline";
import { realpathSync, lstatSync, openSync, fstatSync, readSync, closeSync, constants } from "node:fs";
import { join } from "node:path";
const root = realpathSync(process.argv[2]);
const file = join(root, "package.json");
const tool = {
name: "read_project_scripts",
description: "Read npm scripts from the configured project without executing them.",
inputSchema: { type: "object", properties: {}, additionalProperties: false }
};
const send = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
const fail = (id, code, message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
const protocolVersion = "2024-11-05";
let phase = "new";
const input = createInterface({ input: process.stdin });
input.on("line", (line) => {
let request;
try { request = JSON.parse(line); }
catch { fail(null, -32700, "Invalid JSON"); return; }
if (!request || typeof request !== "object" || Array.isArray(request)) {
fail(null, -32600, "Invalid request"); return;
}
const { id, method } = request;
if (id !== undefined && typeof id !== "string" && !(typeof id === "number" && Number.isFinite(id))) {
fail(null, -32600, "Invalid request ID"); return;
}
if (request.jsonrpc !== "2.0" || typeof method !== "string") {
fail(id ?? null, -32600, "Invalid request"); return;
}
if (request.params !== undefined && (!request.params || typeof request.params !== "object" || Array.isArray(request.params))) {
if (id !== undefined) fail(id, -32602, "Invalid params");
return;
}
const params = request.params ?? {};
if (id === undefined) {
if (method === "notifications/initialized" && phase === "negotiated") phase = "ready";
return;
}
if (method === "initialize") {
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
if (phase !== "new" || typeof params.protocolVersion !== "string" || !params.protocolVersion ||
!object(params.capabilities) || !object(params.clientInfo) ||
typeof params.clientInfo.name !== "string" || !params.clientInfo.name ||
typeof params.clientInfo.version !== "string" || !params.clientInfo.version) {
fail(id, -32602, "Invalid or repeated initialization"); return;
}
phase = "negotiated";
send(id, { protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "project-scripts", version: "1.0.0" } });
} else if (method === "ping") {
send(id, {});
} else if (phase !== "ready") {
fail(id, -32002, "Complete initialization before using tools");
} else if (method === "tools/list") {
send(id, { tools: [tool] });
} else if (method === "tools/call") {
const args = params.arguments === undefined ? {} : params.arguments;
if (params.name !== tool.name || !args || typeof args !== "object" || Array.isArray(args) || Object.keys(args).length) {
fail(id, -32602, "Unknown tool or invalid arguments"); return;
}
try {
if (realpathSync(file) !== file) throw new Error("Linked file rejected");
const before = lstatSync(file, { bigint: true });
if (!before.isFile() || before.ino === 0n || before.size > 65536n) throw new Error("File rejected");
const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
let text;
try {
const opened = fstatSync(fd, { bigint: true });
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("File changed");
const buffer = Buffer.alloc(65537);
let size = 0, count;
while (size < buffer.length && (count = readSync(fd, buffer, size, buffer.length - size, null)) > 0) size += count;
if (size > 65536) throw new Error("File too large");
text = buffer.toString("utf8", 0, size);
} finally { closeSync(fd); }
const pkg = JSON.parse(text);
const scripts = pkg.scripts ?? {};
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts) || Object.values(scripts).some(v => typeof v !== "string")) throw new Error("Invalid scripts");
send(id, { content: [{ type: "text", text: JSON.stringify({ scripts }) }] });
} catch {
send(id, { isError: true, content: [{ type: "text", text: "Cannot read a regular package.json with valid scripts within 64 KiB." }] });
}
} else {
fail(id, -32601, "Method not found");
}
});
这是验证单个只读工具的最小服务。输入按 JSON-RPC 单行消息处理,不提供 HTTP 端点、批量请求或完整生产服务治理;它也不提供操作系统沙箱。服务进程仍拥有启动用户的系统权限。
在 EvoX 中添加并验证
在扩展的 MCP 入口添加手动连接,选择 stdio,并分别填写:
| 字段 | 示例 |
|---|---|
| 名称 | project-scripts |
| 命令 | Node.js 可执行文件的绝对路径 |
| 参数 | read-project-scripts.mjs 的绝对路径,以及目标项目的绝对路径;作为两个独立参数 |
保存后检查连接和工具发现结果。向 Agent 发送:
使用 project-scripts 的 read_project_scripts 读取这个项目的 npm 脚本。
列出脚本名称和对应命令,不执行任何脚本。
把返回结果与目标项目的 package.json 对照。保存成功、进程启动、工具发现和实际调用分别确认;只看到服务名称不能证明工具可用。
检查协议与错误
下面是工具调用消息;连接建立时还需由 MCP 客户端完成 initialize 与 initialized 握手。
initialize 请求提供 protocolVersion、capabilities 对象,以及包含 name 和 version 字符串的 clientInfo。服务端支持协议 2024-11-05,协商时返回该版本;客户端不支持时断开连接。initialize 与 notifications/initialized 完成前拒绝工具发现和调用;ping 可在初始化前使用。
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_project_scripts","arguments":{}}}
| 验证项 | 应得到的结果 |
|---|---|
| 普通项目 | 返回与文件相同的脚本,不执行脚本 |
添加 path 参数 | 返回无效参数错误,不读取其他文件 |
| 项目没有 package.json | 工具返回读取错误,不伪造空项目结果 |
| 文件超出限制或为符号链接 | 拒绝读取 |
| 服务进程退出 | 连接报错,恢复服务后重新验证发现与调用 |
扩展为写入工具
写入使用独立工具名和 schema,明确资源标识、修改内容、预览、授权与幂等行为。服务端验证范围和账号权限,不能依赖模型遵守工具描述。超时后查询真实操作结果,避免重试导致重复写入。
升级时比较工具名称、schema、错误和副作用,并重跑相同接入用例。停止 MCP 连接后,远程服务的账号授权和凭证仍需在提供方撤销。