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