defmodule CMDC.MCP.Bridge do @moduledoc """ MCP 工具桥接器 — 将 MCP server 工具包装为 `CMDC.Tool` behaviour 模块。 Anubis 客户端连接并完成协商后,Bridge 查询每个客户端的可用工具, 将其包装为实现 `CMDC.Tool` 接口的匿名模块。 这样 Agent 可以像调用内置工具一样调用 MCP 工具,无需特殊分发逻辑。 ## 工具命名 MCP 工具默认保留原始名称(如 `get_weather`、`search_issues`)。 当两个 server 暴露同名工具时,在名称前加 server 前缀以消歧: `weather_get_weather`、`backup_get_weather`。 与已存在的 native 工具同名时同样触发前缀。 ## 工具模块生命周期 生成的模块名为 `CMDC.MCP.Tool.`(驼峰化)。 重新发现工具时,旧模块版本会被清除(`purge` + `delete`), 确保重连后的新工具定义生效。 """ @dialyzer {:no_match, do_discover_tools: 1} require Logger alias CMDC.MCP.Client # ========================================================================== # Public API # ========================================================================== @doc """ 从单个命名 MCP client 发现工具,返回 CMDC 兼容的工具信息 map 列表。 每个 map 包含: * `:name` — 工具名称(可能加了 server 前缀) * `:description` — MCP server 提供的工具描述 * `:parameters` — JSON Schema input schema * `:server` — MCP server 名称(String) * `:original_name` — 工具在 MCP server 上的原始名称 client 未连接或发现失败时返回 `[]`。 """ @spec discover_tools(String.t()) :: [map()] def discover_tools(server_name) do case Client.await_ready(server_name, 5_000) do {:error, :timeout} -> Logger.warning("[MCP.Bridge] server #{server_name} did not initialize in time") [] :ok -> do_discover_tools(server_name) end catch kind, reason -> Logger.warning( "[MCP.Bridge] tool discovery for #{server_name} failed: #{inspect(kind)} #{inspect(reason)}" ) [] end @doc """ 从所有配置的 MCP server 发现工具。 接受 server config map 列表(每个包含 `:name` key), 返回所有 server 工具的扁平列表。 """ @spec discover_all_tools([map()]) :: [map()] def discover_all_tools(mcp_servers) do mcp_servers |> Enum.flat_map(fn %{name: name} -> discover_tools(name) end) end @doc """ 从所有 MCP server 发现工具并生成 `CMDC.Tool` 行为实现模块。 使用原始工具名,同名时加 server 前缀消歧。 `existing_names` 是已注册的 native 工具名 MapSet,也会触发前缀。 """ @spec discover_tool_modules([map()], MapSet.t()) :: [module()] def discover_tool_modules(mcp_servers, existing_names \\ MapSet.new()) do all_tools = mcp_servers |> Enum.flat_map(fn %{name: server_name} -> server_name |> discover_tools() |> Enum.map(&Map.put(&1, :server, server_name)) end) name_counts = Enum.frequencies_by(all_tools, & &1.original_name) Enum.map(all_tools, fn tool -> needs_prefix = Map.get(name_counts, tool.original_name, 0) > 1 or MapSet.member?(existing_names, tool.original_name) resolved_name = if needs_prefix, do: "#{tool.server}_#{tool.original_name}", else: tool.original_name create_tool_module( tool.server, %{ "name" => tool.original_name, "description" => tool.description, "inputSchema" => tool.parameters }, resolved_name ) end) end @doc """ 在运行时为 MCP 工具创建实现 `CMDC.Tool` 行为的模块。 生成的模块通过 Anubis client 路由工具调用。 重连后若工具定义有变,旧版本模块会被清除并重新生成。 """ @spec create_tool_module(String.t(), map(), String.t()) :: module() # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def create_tool_module(server_name, tool, resolved_name) do mod_name = Module.concat(CMDC.MCP.Tool, Macro.camelize("#{server_name}_#{tool["name"]}")) tool_desc = tool["description"] || "" tool_params = tool["inputSchema"] || %{} original_name = tool["name"] if :erlang.module_loaded(mod_name) do :code.purge(mod_name) :code.delete(mod_name) end contents = quote do @moduledoc false @behaviour CMDC.Tool @impl true def name, do: unquote(resolved_name) @impl true def description, do: unquote(tool_desc) @impl true def parameters, do: unquote(Macro.escape(tool_params)) @doc false def mcp_server, do: unquote(server_name) @impl true def execute(args, _context) do case Client.server_call_tool( unquote(server_name), unquote(original_name), args ) do {:ok, %{result: %{"content" => content}}} -> {:ok, extract_text_content(content)} {:ok, %{result: result}} -> {:ok, inspect(result)} {:error, reason} -> {:error, "MCP tool error: #{inspect(reason)}"} end end defp extract_text_content(content) when is_list(content) do content |> Enum.map_join("\n", fn %{"type" => "text", "text" => text} -> text other -> inspect(other) end) end defp extract_text_content(content) when is_binary(content), do: content defp extract_text_content(content), do: inspect(content) end Module.create(mod_name, contents, Macro.Env.location(__ENV__)) mod_name end # ========================================================================== # 私有辅助 # ========================================================================== defp do_discover_tools(server_name) do case Client.server_list_tools(server_name, timeout: 5_000) do {:ok, %{result: %{"tools" => tools}}} -> Enum.map(tools, &wrap_tool(server_name, &1)) {:ok, %{result: result}} when is_list(result) -> Enum.map(result, &wrap_tool(server_name, &1)) {:error, reason} -> Logger.warning( "[MCP.Bridge] failed to discover tools from #{server_name}: #{inspect(reason)}" ) [] end catch kind, reason -> Logger.warning( "[MCP.Bridge] tool discovery for #{server_name} crashed: #{inspect(kind)} #{inspect(reason)}" ) [] end defp wrap_tool(server_name, tool) do %{ name: tool["name"], description: tool["description"] || "", parameters: tool["inputSchema"] || %{}, server: server_name, original_name: tool["name"] } end end