defmodule Services.MCP do @moduledoc false alias Settings.MCP, as: MCPSettings alias Services.Once alias MCP.Tools alias UI @expiry_grace_seconds 5 @spec start(String.t() | atom() | nil) :: :ok def start(command \\ nil) do # Configure Hermes MCP logging early to prevent noisy Hermes debug logs when LOGGER_LEVEL=debug unless FNORD_DEBUG_MCP is enabled. MCP.HermesLogging.configure() # Skip MCP discovery for config commands to avoid premature connection attempts # Users should be able to configure OAuth without triggering server connections skip_discovery = command in [:config, "config"] servers = MCPSettings.effective_config(Settings.new()) if map_size(servers) > 0 && !skip_discovery do ensure_supervisor() Once.run(:mcp_discovery, fn -> discover_once(servers) end) end :ok end @doc """ Integration point for interactive flows (like `ask`) that want MCP tools available immediately. """ @spec ensure_started_and_discovered() :: :ok def ensure_started_and_discovered() do # Configure Hermes MCP logging early to prevent noisy Hermes debug logs when LOGGER_LEVEL=debug unless FNORD_DEBUG_MCP is enabled. MCP.HermesLogging.configure() servers = MCPSettings.effective_config(Settings.new()) if map_size(servers) == 0 do :ok else ensure_supervisor() discover_once(servers) :ok end end defp ensure_supervisor do case MCP.Client.start_supervisor() do :ok -> :ok {:error, reason} -> UI.debug("Failed to start MCP supervisor: #{inspect(reason)}") :ok end end defp discover_once(servers) when is_map(servers) do Enum.each(servers, fn {server, _cfg} -> case MCP.Client.list_tools(server) do {:ok, tools} -> Tools.register_server_tools(server, tools) {:error, _reason} -> # Silently skip failed servers during normal operation # Users should run 'fnord config mcp check' to diagnose issues :ok end end) end @spec test(keyword()) :: map() def test(opts \\ []) do settings = Settings.new() servers_cfg = MCPSettings.effective_config(settings) with_discovery = Keyword.get(opts, :with_discovery, false) servers = servers_cfg |> Enum.map(fn {server, cfg} -> {server, check_server_status(server, cfg, settings, with_discovery)} end) |> Enum.into(%{}) %{status: "ok", servers: servers} end @doc """ Checks the status of a single MCP server by name. Returns the same structure as a single server entry in test/1. """ @spec check_single_server(String.t()) :: {:ok, map()} | {:error, :not_found} def check_single_server(server_name) when is_binary(server_name) do settings = Settings.new() servers_cfg = MCPSettings.effective_config(settings) case Map.fetch(servers_cfg, server_name) do {:ok, cfg} -> server_data = check_server_status(server_name, cfg, settings, false) {:ok, %{status: "ok", servers: %{server_name => server_data}}} :error -> {:error, :not_found} end end defp check_server_status(server, cfg, settings, with_discovery) do info = case safe_get_info(server) do {:ok, server_info} -> %{status: "ok", server_info: server_info} {:error, reason} -> if with_discovery && should_attempt_discovery?(cfg) do attempt_discovery_for_server(server, cfg, settings) end %{status: "error", error: inspect(reason)} end capabilities = case MCP.Client.get_server_capabilities(server) do {:ok, caps} -> %{capabilities: caps} {:error, _} -> %{capabilities: %{}} end # Collect tools first so we can use the tools count as a proxy for auth when expiry is unknown {tools_count, tools_map} = case MCP.Client.list_tools(server) do {:ok, tools_list} -> count = length(tools_list) if Util.Env.mcp_debug_enabled?() do {count, %{tools: Enum.map(tools_list, &tool_blurb/1), tools_count: count}} else {count, %{tools_count: count, tools_hint: "Set FNORD_DEBUG_MCP=1 to show tool details"}} end {:error, _} -> {0, %{tools_count: 0}} end # Debugging output: show credential contents and time comparison only when FNORD_DEBUG_MCP=1 if Util.Env.mcp_debug_enabled?() do case MCP.OAuth2.CredentialsStore.read(server) do {:ok, creds} -> safe_creds = Map.put(creds, "access_token", "") UI.debug("MCP credentials (redacted)") UI.printf_debug(safe_creds) case Map.get(creds, "expires_at") do exp when is_integer(exp) -> UI.debug("expires_at=#{exp}, now=#{System.os_time(:second)}") _ -> UI.debug("expires_at: not present") end {:error, reason} -> UI.debug("Credentials read: #{inspect(reason)}") end end # Use tools_count as a proxy when expiry is unknown auth_info = check_auth_status(server, cfg, tools_count) info |> Map.merge(capabilities) |> Map.merge(tools_map) |> Map.merge(auth_info) end defp should_attempt_discovery?(cfg) do cfg["transport"] == "http" && !Map.has_key?(cfg, "mcp_path") end defp attempt_discovery_for_server(server, cfg, settings) do base_url = cfg["base_url"] case MCP.EndpointDiscovery.discover(base_url, ["/mcp", "/", "/api/mcp", "/mcp/v1"], server: server, config: cfg ) do {:ok, path} -> # Determine scope (global vs project) scope = determine_server_scope(server, settings) MCP.EndpointDiscovery.prompt_and_save(server, path, scope) {:error, :no_working_path} -> UI.error( "Could not find working MCP endpoint for '#{server}'", """ Base URL: #{base_url} Paths tried: /mcp, /, /api/mcp, /mcp/v1 To fix, manually set mcp_path in settings.json or use --mcp-path flag """ ) end end defp determine_server_scope(server, settings) do # Check if server is in project settings case Settings.get_selected_project() do {:ok, project_name} -> # Check if this server is defined in project scope case Settings.get_project_data(settings, project_name) do nil -> :global project_data -> project_servers = Map.get(project_data, "mcp_servers", %{}) if Map.has_key?(project_servers, server) do {:project, project_name} else :global end end {:error, _} -> :global end end # Retrieve server info; connectivity is the only signal available defp safe_get_info(server) do if MCP.Client.connected?(server) do {:ok, %{"name" => "#{server}-server", "status" => "running"}} else {:error, :not_started} end end @spec tool_blurb(tool :: map()) :: map() defp tool_blurb(tool) do %{"name" => tool["name"], "description" => Map.get(tool, "description", "")} end @spec check_auth_status(String.t(), map(), integer()) :: map() defp check_auth_status(server, cfg, tools_count) do has_oauth = Map.has_key?(cfg, "oauth") && is_map(cfg["oauth"]) if has_oauth do auth_status = case MCP.OAuth2.CredentialsStore.read(server) do {:ok, %{"expires_at" => exp} = creds} when is_integer(exp) -> now = System.os_time(:second) if Util.Env.mcp_debug_enabled?() do safe_creds = Map.put(creds, "access_token", "") UI.debug("[MCP Debug] credentials (redacted)") UI.printf_debug(safe_creds) UI.debug("[MCP Debug] expires_at=#{exp}, now=#{now}") end cond do exp > now -> :valid now - exp <= @expiry_grace_seconds -> :valid true -> :expired end {:ok, creds} -> if Util.Env.mcp_debug_enabled?() do safe_creds = Map.put(creds, "access_token", "") UI.debug("[MCP Debug] credentials (no expires_at) (redacted)") UI.printf_debug(safe_creds) end if tools_count > 0 do :valid else :missing end {:error, :not_found} -> if Util.Env.mcp_debug_enabled?() do UI.debug("[MCP Debug] No credentials found for #{server}") end :missing end %{has_oauth: true, auth_status: auth_status} else %{has_oauth: false} end end end