码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
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,
};