Current section
Files
Jump to
Current section
Files
lib/cmdc/mcp/bridge.ex
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.<ServerName><ToolName>`(驼峰化)。
重新发现工具时,旧模块版本会被清除(`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