Packages

fnord

0.9.20

AI code archaeology

Current section

Files

Jump to
fnord lib services mcp.ex
Raw

lib/services/mcp.ex

defmodule Services.MCP do
@moduledoc false
alias MCP.Supervisor, as: MCPSup
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 Process.whereis(MCPSup) do
nil ->
start_supervisor_and_unlink()
_pid ->
:ok
end
:ok
end
defp start_supervisor_and_unlink do
case MCPSup.start_link([]) do
{:ok, pid} ->
Process.unlink(pid)
:ok
{:error, {:already_started, pid}} ->
Process.unlink(pid)
: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 safe_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
# Robustly list tools for a server, supporting both Hermes.Base and stubbed return shapes.
defp safe_list_tools(server) do
client = MCPSup.instance_name(server)
# Check if the Base GenServer is registered and alive
case Process.whereis(client) do
nil ->
{:error, :not_started}
pid when is_pid(pid) ->
if Process.alive?(pid) do
try do
# Give the server a moment to complete initialization and handshake
:timer.sleep(1000)
case Hermes.Client.Base.list_tools(client) do
{:ok, %Hermes.MCP.Response{result: %{"tools" => tools}}} -> {:ok, tools}
{:ok, _response} -> {:ok, []}
{:error, reason} -> {:error, reason}
end
rescue
error ->
{:error, {:rescue, error}}
catch
:exit, reason ->
{:error, {:exit, reason}}
end
else
{:error, :not_alive}
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 safe_get_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 safe_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", "<redacted>")
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; check if process is alive for now
defp safe_get_info(server) do
instance = MCPSup.instance_name(server)
# Check if the process exists and is alive
case Process.whereis(instance) do
nil ->
{:error, :not_started}
pid when is_pid(pid) ->
if Process.alive?(pid) do
{:ok, %{"name" => "#{server}-server", "status" => "running"}}
else
{:error, :not_alive}
end
end
end
# Retrieve server capabilities
defp safe_get_capabilities(server) do
client = MCPSup.instance_name(server)
case Process.whereis(client) do
nil ->
{:error, :not_started}
pid when is_pid(pid) ->
if Process.alive?(pid) do
try do
case Hermes.Client.Base.get_server_capabilities(client) do
caps when is_map(caps) -> {:ok, caps}
nil -> {:ok, %{}}
end
rescue
_ ->
{:ok, %{}}
catch
:exit, _ ->
{:ok, %{}}
end
else
{:error, :not_alive}
end
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", "<redacted>")
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", "<redacted>")
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