Packages
fnord
0.8.49
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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" | "streamable_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", "streamable_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
case t do
"stdio" ->
with command when is_binary(command) <- Map.get(cfg1, "command"),
args <- Map.get(cfg1, "args", []),
true <- is_list(args),
env <- Map.get(cfg1, "env", %{}),
true <- is_map(env) do
normalized = %{
"transport" => "stdio",
"command" => command,
"args" => args,
"env" => env
}
normalized =
if Map.has_key?(cfg1, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg1["timeout_ms"]),
else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'command' for stdio transport"}
false -> {:error, "Invalid 'args' or 'env' for stdio transport"}
end
"streamable_http" ->
with base_url when is_binary(base_url) <- Map.get(cfg1, "base_url"),
headers <- Map.get(cfg1, "headers", %{}),
true <- is_map(headers) do
normalized = %{
"transport" => "streamable_http",
"base_url" => base_url,
"headers" => headers
}
normalized =
if Map.has_key?(cfg1, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg1["timeout_ms"]),
else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'base_url' for streamable_http transport"}
false -> {:error, "Invalid 'headers' for streamable_http transport"}
end
"websocket" ->
with base_url when is_binary(base_url) <- Map.get(cfg1, "base_url"),
headers <- Map.get(cfg1, "headers", %{}),
true <- is_map(headers) do
normalized = %{
"transport" => "websocket",
"base_url" => base_url,
"headers" => headers
}
normalized =
if Map.has_key?(cfg1, "timeout_ms"),
do: Map.put(normalized, "timeout_ms", cfg1["timeout_ms"]),
else: normalized
{:ok, normalized}
else
nil -> {:error, "Missing 'base_url' for websocket transport"}
false -> {:error, "Invalid 'headers' for websocket transport"}
end
end
end
defp validate_server_config(_), do: {:error, "Missing or invalid 'transport' field"}
end