Current section
Files
Jump to
Current section
Files
lib/cmdc/mcp/config.ex
defmodule CMDC.MCP.Config do
@moduledoc """
MCP server 配置发现与解析。
从标准文件位置发现 `mcp.json` 配置,并转换为 CMDC 内部格式
`%{name: String.t(), transport: tuple}` 列表。
## 发现顺序(越靠前优先级越高)
**项目本地**(相对 `working_dir`):
1. `.vscode/mcp.json`
2. `.github/mcp.json`
3. `.cursor/mcp.json`
4. `.cmdc/mcp.json`
5. `.mcp.json`
**用户全局**:
6. `~/.cmdc/mcp.json`
同名 server 以第一次出现为准(项目本地覆盖全局)。
## 支持的格式
同时兼容两种 JSON 顶层键:
- `"servers"` — CMDC 原生格式
- `"mcpServers"` — Cursor / VS Code 格式
## 支持的 transport
- Stdio 服务器:`command` + `args` + 可选 `env`
- HTTP/SSE 服务器:`type`("http" 或 "sse")+ `url` + 可选 `headers`
"""
@default_config_files [
".vscode/mcp.json",
".github/mcp.json",
".cursor/mcp.json",
".cmdc/mcp.json",
".mcp.json"
]
# ==========================================================================
# Public API
# ==========================================================================
@doc """
从标准文件路径发现 MCP server 配置。
## 参数
* `working_dir` — 项目根目录路径
* `opts` — 可选:
* `:extra_files` — 额外配置文件路径列表(绝对路径或相对 working_dir 的路径)
## 返回
`[%{name: String.t(), transport: tuple}]` 列表,按 server name 去重(先出现者优先)。
"""
@spec discover(String.t(), keyword()) :: [map()]
def discover(working_dir, opts \\ []) do
extra_files = Keyword.get(opts, :extra_files, [])
project_files = Enum.map(@default_config_files, &Path.join(working_dir, &1))
global_files = [
Path.join(System.user_home!(), ".cmdc/mcp.json")
]
extra_resolved =
Enum.map(extra_files, fn f ->
if Path.type(f) == :absolute, do: f, else: Path.join(working_dir, f)
end)
(project_files ++ extra_resolved ++ global_files)
|> Enum.flat_map(&parse_file/1)
|> Enum.uniq_by(& &1.name)
end
@doc """
解析单个 mcp.json 文件,返回 server 配置列表。
文件不存在或格式无效时返回 `[]`。
"""
@spec parse_file(String.t()) :: [map()]
def parse_file(path) do
with {:ok, content} <- File.read(path),
{:ok, json} <- Jason.decode(content),
{:ok, servers} <- extract_servers(json) do
servers
|> Enum.map(fn {name, config} -> parse_server(name, config) end)
|> Enum.reject(&is_nil/1)
else
_ -> []
end
end
@doc """
解析 VS Code 格式的单条 server 配置。
返回 `%{name: String.t(), transport: tuple}` 或 `nil`(格式无效时)。
"""
@spec parse_server(String.t(), map()) :: map() | nil
def parse_server(name, config) when is_binary(name) and is_map(config) do
case detect_transport(config) do
{:ok, transport} -> %{name: name, transport: transport}
:error -> nil
end
end
def parse_server(_name, _config), do: nil
# ==========================================================================
# 私有辅助 — 提取 servers map(兼容 "servers" 和 "mcpServers" 两种格式)
# ==========================================================================
defp extract_servers(%{"servers" => servers}) when is_map(servers), do: {:ok, servers}
defp extract_servers(%{"mcpServers" => servers}) when is_map(servers), do: {:ok, servers}
defp extract_servers(_), do: :error
# ==========================================================================
# 私有辅助 — Transport 检测
# ==========================================================================
defp detect_transport(%{"type" => type, "url" => url} = config) when type in ["http", "sse"] do
transport_type = if type == "sse", do: :sse, else: :streamable_http
headers = parse_headers(config["headers"])
{base_url, mcp_path} = split_mcp_url(url)
opts = [{:base_url, base_url}, {:mcp_path, mcp_path}] |> maybe_add(:headers, headers)
{:ok, {transport_type, opts}}
end
defp detect_transport(%{"url" => url} = config) do
headers = parse_headers(config["headers"])
{base_url, mcp_path} = split_mcp_url(url)
opts = [{:base_url, base_url}, {:mcp_path, mcp_path}] |> maybe_add(:headers, headers)
{:ok, {:streamable_http, opts}}
end
defp detect_transport(%{"command" => command} = config) do
args = config["args"] || []
env = parse_env(config["env"])
{final_cmd, final_args} = wrap_stdio_stderr(command, args)
opts = [{:command, final_cmd}, {:args, final_args}] |> maybe_add(:env, env)
{:ok, {:stdio, opts}}
end
defp detect_transport(_), do: :error
# ==========================================================================
# 私有辅助 — 值解析
# ==========================================================================
defp parse_headers(nil), do: []
defp parse_headers(headers) when is_map(headers) do
Enum.map(headers, fn {k, v} -> {k, resolve_value(v)} end)
end
defp parse_headers(_), do: []
defp parse_env(nil), do: %{}
defp parse_env(env) when is_map(env) do
Map.new(env, fn {k, v} -> {k, resolve_value(v)} end)
end
defp parse_env(_), do: %{}
defp resolve_value(value) when is_binary(value) do
Regex.replace(~r/\$\{input:([^}]+)\}/, value, fn _match, var_name ->
System.get_env(String.upcase(var_name)) || "${input:#{var_name}}"
end)
end
defp resolve_value(value), do: value
defp maybe_add(opts, _key, val) when val == [] or val == %{}, do: opts
defp maybe_add(opts, key, val), do: opts ++ [{key, val}]
defp split_mcp_url(url) do
uri = URI.parse(url)
default_port = URI.default_port(uri.scheme || "https")
port_str = if uri.port && uri.port != default_port, do: ":#{uri.port}", else: ""
base = "#{uri.scheme}://#{uri.host}#{port_str}"
path = uri.path || "/"
{base, path}
end
defp wrap_stdio_stderr(command, args) do
if :os.type() == {:win32, :nt} do
{command, args}
else
quoted_args = Enum.map_join(args, " ", &shell_quote/1)
sh_script = "#{shell_quote(command)} #{quoted_args} 2>/dev/null"
{"sh", ["-c", sh_script]}
end
end
defp shell_quote(str) do
"'" <> String.replace(str, "'", "'\\''") <> "'"
end
end