Packages

fnord

0.9.34

AI code archaeology

Current section

Files

Jump to
fnord lib settings mcp.ex
Raw

lib/settings/mcp.ex

defmodule Settings.MCP do
@moduledoc """
Manage Hermes MCP server configuration under the "mcp_servers" key in settings.
MCP configuration can be stored at both global and project scopes without
being automatically written unless the user performs a configuration action.
Configuration format:
"mcp_servers": %{server_name => server_config}
server_config fields:
- "transport": "stdio" | "http" | "websocket"
- "timeout_ms": integer (optional)
- stdio-specific:
- "command": string
- "args": [string]
- "env": %{string => string}
- http/ws-specific:
- "base_url": string
- "headers": %{string => string}
"""
@typedoc "Underlying Settings struct"
@type settings :: Settings.t()
@typedoc "Scope of MCP configuration: global or project"
@type scope :: :global | :project
@typedoc "Configuration for an individual MCP server"
@type server_config :: map()
@typedoc "Full MCP configuration - map of server names to configs"
@type config :: map()
@doc "Retrieve MCP server configurations for the given scope"
@spec get_config(settings(), scope()) :: config()
def get_config(settings, :global) do
raw = Settings.get(settings, "mcp_servers", %{})
extract_servers(raw)
end
def get_config(settings, :project) do
case Settings.get_project(settings) do
{:ok, project} ->
raw = Map.get(project, "mcp_servers", %{})
extract_servers(raw)
_ ->
%{}
end
end
# Extract servers from either old format {"enabled": _, "servers": _} or new flat format
defp extract_servers(%{"servers" => servers}) when is_map(servers), do: servers
defp extract_servers(config) when is_map(config), do: config
defp extract_servers(_), do: %{}
@doc "Set MCP configuration for the given scope"
@spec set_config(settings, scope, map()) :: settings
def set_config(settings, :global, cfg) do
Settings.update(settings, "mcp_servers", fn _ -> cfg end)
end
def set_config(settings, :project, cfg) do
case Settings.get_selected_project() do
{:ok, project} ->
existing = Settings.get_project_data(settings, project) || %{}
updated = Map.put(existing, "mcp_servers", cfg)
Settings.set_project_data(settings, project, updated)
{:error, :project_not_set} ->
settings
end
end
# Fallback for unexpected scope
def set_config(settings, _scope, _cfg), do: settings
@doc "List configured MCP servers by name for the given scope"
@spec list_servers(settings, scope) :: map()
def list_servers(settings, scope), do: get_config(settings, scope)
@doc "Add a new MCP server configuration"
@spec add_server(settings, scope, String.t(), map()) :: {:ok, settings} | {:error, any}
def add_server(settings, scope, name, raw_cfg) do
with {:ok, cfg} <- validate_server_config(raw_cfg),
existing <- get_config(settings, scope),
false <- Map.has_key?(existing, name) do
servers = Map.put(existing, name, cfg)
{:ok, set_config(settings, scope, servers)}
else
true -> {:error, :exists}
{:error, msg} -> {:error, msg}
end
end
@doc "Update an existing MCP server configuration"
@spec update_server(settings, scope, String.t(), map()) :: {:ok, settings} | {:error, any}
def update_server(settings, scope, name, raw_cfg) do
with {:ok, cfg} <- validate_server_config(raw_cfg),
existing <- get_config(settings, scope),
true <- Map.has_key?(existing, name) do
servers = Map.put(existing, name, cfg)
{:ok, set_config(settings, scope, servers)}
else
false -> {:error, :not_found}
{:error, msg} -> {:error, msg}
end
end
@doc "Remove an existing MCP server configuration"
@spec remove_server(settings, scope, String.t()) :: {:ok, settings} | {:error, any}
def remove_server(settings, scope, name) do
existing = get_config(settings, scope)
if Map.has_key?(existing, name) do
servers = Map.delete(existing, name)
{:ok, set_config(settings, scope, servers)}
else
{:error, :not_found}
end
end
@doc "Merge global and project MCP configurations, applying project overrides"
@spec effective_config(settings()) :: config()
def effective_config(settings) do
g = get_config(settings, :global)
p = get_config(settings, :project)
Map.merge(g, p)
end
@spec validate_server_config(map()) :: {:ok, map()} | {:error, String.t()}
defp validate_server_config(%{"transport" => t} = cfg)
when t in ["stdio", "http", "websocket"] do
# Common optional field: timeout_ms
cfg1 =
case Map.get(cfg, "timeout_ms") do
tm when is_integer(tm) and tm >= 0 -> Map.put(cfg, "timeout_ms", tm)
_ -> Map.delete(cfg, "timeout_ms")
end
# Optional nested OAuth configuration is allowed for any transport
{cfg_oauth, oauth_norm} =
case Map.get(cfg1, "oauth") do
nil ->
{cfg1, nil}
oauth when is_map(oauth) ->
case normalize_oauth(oauth) do
{:ok, norm} -> {cfg1, norm}
{:error, msg} -> {Map.put(cfg1, :__oauth_error, msg), nil}
end
_ ->
{Map.put(cfg1, :__oauth_error, "Invalid 'oauth' (must be a map)"), nil}
end
case t do
"stdio" ->
with command when is_binary(command) <- Map.get(cfg_oauth, "command"),
args <- Map.get(cfg_oauth, "args", []),
true <- is_list(args),
env <- Map.get(cfg_oauth, "env", %{}),
true <- is_map(env) do
normalized = %{
"transport" => "stdio",
"command" => command,
"args" => args,
"env" => env
}
normalized =
if Map.has_key?(cfg_oauth, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg_oauth["timeout_ms"]),
else: normalized
normalized =
if oauth_norm, do: Map.put(normalized, "oauth", oauth_norm), else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'command' for stdio transport"}
false -> {:error, "Invalid 'args' or 'env' for stdio transport"}
{:error, msg} -> {:error, msg}
end
"http" ->
with base_url when is_binary(base_url) <- Map.get(cfg_oauth, "base_url"),
headers <- Map.get(cfg_oauth, "headers", %{}),
true <- is_map(headers) do
normalized = %{
"transport" => "http",
"base_url" => base_url,
"headers" => headers
}
normalized =
if Map.has_key?(cfg_oauth, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg_oauth["timeout_ms"]),
else: normalized
normalized =
if oauth_norm, do: Map.put(normalized, "oauth", oauth_norm), else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'base_url' for http transport"}
false -> {:error, "Invalid 'headers' for http transport"}
{:error, msg} -> {:error, msg}
end
"websocket" ->
with base_url when is_binary(base_url) <- Map.get(cfg_oauth, "base_url"),
headers <- Map.get(cfg_oauth, "headers", %{}),
true <- is_map(headers) do
normalized = %{
"transport" => "websocket",
"base_url" => base_url,
"headers" => headers
}
normalized =
if Map.has_key?(cfg_oauth, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg_oauth["timeout_ms"]),
else: normalized
normalized =
if oauth_norm, do: Map.put(normalized, "oauth", oauth_norm), else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'base_url' for websocket transport"}
false -> {:error, "Invalid 'headers' for websocket transport"}
{:error, msg} -> {:error, msg}
end
end
end
defp validate_server_config(_), do: {:error, "Missing or invalid 'transport' field"}
@spec normalize_oauth(map()) :: {:ok, map()} | {:error, String.t()}
defp normalize_oauth(%{} = oauth) do
with discovery when is_binary(discovery) <- Map.get(oauth, "discovery_url"),
client_id when is_binary(client_id) <- Map.get(oauth, "client_id"),
scopes when is_list(scopes) <- Map.get(oauth, "scopes"),
true <- Enum.all?(scopes, &is_binary/1) do
norm = %{
"discovery_url" => discovery,
"client_id" => client_id,
"scopes" => scopes
}
norm =
case Map.get(oauth, "client_secret") do
nil -> norm
cs when is_binary(cs) -> Map.put(norm, "client_secret", cs)
_ -> norm
end
norm =
case Map.get(oauth, "redirect_uri") do
nil -> norm
ru when is_binary(ru) -> Map.put(norm, "redirect_uri", ru)
_ -> norm
end
norm =
case Map.get(oauth, "credentials_path") do
nil -> norm
cp when is_binary(cp) -> Map.put(norm, "credentials_path", cp)
_ -> norm
end
norm =
case Map.get(oauth, "refresh_margin") do
rm when is_integer(rm) and rm >= 0 -> Map.put(norm, "refresh_margin", rm)
_ -> norm
end
norm =
case Map.get(oauth, "redirect_port") do
port when is_integer(port) and port > 0 -> Map.put(norm, "redirect_port", port)
_ -> norm
end
{:ok, norm}
else
nil -> {:error, "Missing required oauth fields: discovery_url, client_id, scopes"}
false -> {:error, "Invalid 'scopes' (must be list of strings)"}
_ -> {:error, "Invalid oauth configuration"}
end
end
end