码桶
发现社区成员的开源项目
index.js11.3 KB
/**
* 插件:MCP(Model Context Protocol)客户端
* --------------------------------------------------
* 每个机器人可连接多个 MCP 服务器,自动发现其工具,
* 并将所有 MCP 工具暴露给智能助手(smart)调用。
*
* 使用方式:
* 在控制台「MCP 配置」中添加 MCP 服务器 URL 和 API Key,
* 然后直接让智能助手使用这些工具即可。
*
* 发送「mcp 工具」列出当前可用工具
* 发送「mcp 连接 名称」测试 MCP 服务器连接
*
* 协议:JSON-RPC 2.0 over HTTP POST
*/
const db = require('../../lib/db');
// MCP 服务器缓存:botId -> [{id, name, url, apiKey, tools:[]}]
const serverCache = new Map();
let cacheTimestamp = 0;
const CACHE_TTL = 60_000; // 1 分钟
// ==================== MCP JSON-RPC 调用 ====================
/**
* 调用 MCP 服务器的一个方法
* @param {string} serverUrl - MCP 服务器 URL
* @param {string} apiKey - API Key(可选,放 Authorization header)
* @param {string} method - JSON-RPC 方法名
* @param {object} params - 参数对象
* @returns {Promise<object>}
*/
async function mcpCall(serverUrl, apiKey, method, params = {}) {
const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const resp = await fetch(serverUrl, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
}
const data = await resp.json();
if (data.error) {
throw new Error(data.error.message || JSON.stringify(data.error));
}
return data.result;
} finally {
clearTimeout(timeout);
}
}
// ==================== MCP 服务器管理 ====================
/** 加载某机器人的 MCP 服务器配置 */
async function loadServers(botId) {
// 检查缓存
if (serverCache.has(botId) && Date.now() - cacheTimestamp < CACHE_TTL) {
return serverCache.get(botId);
}
try {
const row = await db.row(
'SELECT config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=? AND config_key=?',
[botId, 'mcp', 'servers']
);
let servers = [];
if (row && row.config_value) {
try {
servers = JSON.parse(row.config_value);
if (!Array.isArray(servers)) servers = [];
} catch (_) {
servers = [];
}
}
serverCache.set(botId, servers);
cacheTimestamp = Date.now();
return servers;
} catch (e) {
console.error('[mcp] loadServers error:', e.message);
return [];
}
}
/** 刷新某个机器人的 MCP 缓存 */
function invalidateCache(botId) {
serverCache.delete(botId);
// 同时清理该 bot 的所有 toolServerMap 条目
for (const key of toolServerMap.keys()) {
if (key.startsWith(botId + ':')) toolServerMap.delete(key);
}
}
// 每 5 分钟清理已删除 bot 的缓存条目(防内存泄漏)
setInterval(async () => {
try {
const allBots = await db.rows('SELECT id FROM bots');
const validIds = new Set(allBots.map(b => b.id));
for (const botId of serverCache.keys()) {
if (!validIds.has(botId)) serverCache.delete(botId);
}
for (const key of toolServerMap.keys()) {
const botId = key.split(':')[0];
if (!validIds.has(Number(botId))) toolServerMap.delete(key);
}
} catch (e) { /* 静默 */ }
}, 5 * 60 * 1000).unref();
// ==================== 工具发现 ====================
/**
* 从 MCP 服务器获取工具列表
* 先 initialize,再 tools/list
*/
async function discoverTools(botId, server) {
try {
// Step 1: initialize
await mcpCall(server.url, server.apiKey, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
clientInfo: { name: 'ngbot', version: '1.0.0' },
});
// Step 2: list tools
const result = await mcpCall(server.url, server.apiKey, 'tools/list');
if (!result || !Array.isArray(result.tools)) {
console.error('[mcp] tools/list 返回格式异常:', JSON.stringify(result).slice(0, 200));
return [];
}
return result.tools.map(t => ({
...t,
_serverId: server.id,
_serverName: server.name,
}));
} catch (e) {
console.error(`[mcp] 发现 MCP 工具失败 (${server.name}):`, e.message);
return [];
}
}
/**
* 获取某机器人所有 MCP 可用工具
* @returns {Promise<Array>}
*/
async function getAllMcpTools(botId) {
const servers = await loadServers(botId);
if (!servers.length) return [];
const allTools = [];
for (const server of servers) {
const tools = await discoverTools(botId, server);
allTools.push(...tools);
}
return allTools;
}
/** MCP 工具名 → server 映射缓存 */
const toolServerMap = new Map(); // key: "botId:toolName" -> server entry
async function getServerForTool(botId, toolName) {
const cacheKey = `${botId}:${toolName}`;
if (toolServerMap.has(cacheKey)) {
return toolServerMap.get(cacheKey);
}
const servers = await loadServers(botId);
for (const server of servers) {
// 先检查缓存中的 tools 列表
const cached = serverCache.get(botId);
if (cached) {
const s = cached.find(s => s.id === server.id);
if (s && s.tools && s.tools.some(t => t.name === toolName)) {
toolServerMap.set(cacheKey, server);
return server;
}
}
}
// 没命中缓存,逐个查询
for (const server of servers) {
const tools = await discoverTools(botId, server);
if (tools.some(t => t.name === toolName)) {
toolServerMap.set(cacheKey, server);
return server;
}
}
return null;
}
// ==================== 插件元数据 ====================
const meta = {
id: 'mcp',
name: 'MCP 服务器',
category: 'AI对话',
description: '连接外部 MCP(Model Context Protocol)服务器,将第三方工具能力接入智能助手',
usage: '在控制台「自定义设置」中添加 MCP 服务器后,智能助手自动获取其工具能力。\n发送「mcp 工具」查看当前可用工具;\n发送「mcp 连接 服务器名」测试连接。',
builtin: true,
configurable: true,
customConfig: 'mcp',
// aiTools 动态返回 — 见 onMessage 中的 collectPluginTools 集成
};
// ==================== 消息处理 ====================
async function onMessage(msg, ctx) {
const text = (msg.text || msg.content || '').trim();
const botId = ctx.bot.id;
// 命令:mcp 工具
if (/^mcp\s*(工具|tools)/i.test(text)) {
const servers = await loadServers(botId);
if (!servers.length) {
await ctx.sendText('[Info] 当前未配置 MCP 服务器。\n请在控制台「MCP 配置」中添加服务器。');
return true;
}
const allTools = await getAllMcpTools(botId);
if (!allTools.length) {
await ctx.sendText('[Info] 已配置 MCP 服务器,但未发现任何工具。\n请检查服务器地址和 API Key 是否正确。');
return true;
}
// 按服务器分组
const groups = {};
for (const t of allTools) {
const sname = t._serverName || '未知';
if (!groups[sname]) groups[sname] = [];
groups[sname].push(t);
}
const lines = ['[Info] MCP 可用工具:', ''];
for (const [sname, tools] of Object.entries(groups)) {
lines.push(` ${sname}(${tools.length} 个工具)`);
for (const t of tools) {
const desc = t.description || '无描述';
lines.push(` • ${t.name} — ${desc.length > 60 ? desc.slice(0, 60) + '…' : desc}`);
}
lines.push('');
}
lines.push('使用智能助手直接说出你的需求,AI 会自动调用合适的工具。');
await ctx.sendText(lines.join('\n'));
return true;
}
// 命令:mcp 连接 <name>
const connMatch = text.match(/^mcp\s*(连接|connect|ping)\s+(.+)/i);
if (connMatch) {
const targetName = connMatch[2].trim();
const servers = await loadServers(botId);
const server = servers.find(s => s.name === targetName);
if (!server) {
await ctx.sendText(`[NG] 未找到名为「${targetName}」的 MCP 服务器。\n已配置的服务器:${servers.map(s => s.name).join(', ') || '无'}`);
return true;
}
try {
const start = Date.now();
await mcpCall(server.url, server.apiKey, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
clientInfo: { name: 'ngbot', version: '1.0.0' },
});
const elapsed = Date.now() - start;
const tools = await discoverTools(botId, server);
await ctx.sendText(`[OK] 已连接到「${targetName}」(${elapsed}ms)\n发现 ${tools.length} 个工具。`);
} catch (e) {
await ctx.sendText(`[NG] 连接「${targetName}」失败:${e.message}`);
}
return true;
}
return false;
}
// ==================== AI 工具集成 ====================
/**
* 返回当前机器人可用的 MCP AI 工具定义(OpenAI function schema)
* 由 smart 的 collectPluginTools 自动调用
*/
async function getAiTools(botId) {
const servers = await loadServers(botId);
if (!servers.length) return [];
const mcpTools = await getAllMcpTools(botId);
if (!mcpTools.length) return [];
// 转换为 OpenAI function calling schema
return mcpTools.map(t => ({
type: 'function',
function: {
name: `mcp_${t.name}`.replace(/[^a-zA-Z0-9_-]/g, '_'),
description: `[MCP:${t._serverName}] ${t.description || `调用 MCP 工具 "${t.name}"`}`,
parameters: t.inputSchema || { type: 'object', properties: {} },
},
}));
}
/**
* 执行 MCP AI 工具调用
* 由 smart 的 executeTool 在遍历 pluginHandlers 时调用
*/
async function handleAiTool(toolName, args, ctx) {
// strip 'mcp_' prefix
let originalName = toolName;
if (toolName.startsWith('mcp_')) {
originalName = toolName.slice(4);
}
const botId = ctx.bot.id;
const server = await getServerForTool(botId, originalName);
if (!server) {
return `MCP 工具 "${originalName}" 所属的服务器未找到,可能已被移除。`;
}
try {
const result = await mcpCall(server.url, server.apiKey, 'tools/call', {
name: originalName,
arguments: args || {},
});
// 格式化返回内容
if (result && result.content && Array.isArray(result.content)) {
return result.content
.map(c => (typeof c.text === 'string' ? c.text : JSON.stringify(c)))
.join('\n');
}
return JSON.stringify(result);
} catch (e) {
console.error(`[mcp] 工具调用失败 (${originalName}):`, e.message);
return `MCP 工具 "${originalName}" 执行失败:${e.message}`;
}
}
// ==================== 导出 ====================
module.exports = {
meta,
getAiTools, // 供 smart 收集插件工具
handleAiTool, // 供 smart 执行插件工具
onMessage,
invalidateCache,
// 以下供 API 使用
loadServers,
mcpCall,
discoverTools,
};