Packages

fnord

0.8.99

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
@spec start(String.t() | atom() | nil) :: :ok
def start(command \\ nil) do
# Configure Hermes MCP logging - only show debug output when FNORD_DEBUG_MCP=1
log_level =
if System.get_env("FNORD_DEBUG_MCP") == "1" do
:debug
else
:error
end
Application.put_env(:hermes_mcp, :logging,
client_events: log_level,
server_events: log_level,
transport_events: log_level,
protocol_messages: log_level
)
# 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
defp ensure_supervisor do
case Process.whereis(MCPSup) do
nil -> MCPSup.start_link([])
_ -> :ok
end
:ok
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
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
# Process is alive, attempt to discover tools via the client module
# Note: Tools may not be available immediately after handshake
try do
# Give the server a moment to complete initialization and handshake
:timer.sleep(1000)
# The instance is a supervisor, we need to find the client process
# Use a safe call that handles exits
case safe_supervisor_call(instance) do
{:ok, children} ->
# Find the Hermes.Client.Base child process
case List.keyfind(children, Hermes.Client.Base, 0) do
{Hermes.Client.Base, client_pid, _, _} when is_pid(client_pid) ->
result = Hermes.Client.Base.list_tools(client_pid)
case result do
{:ok, %Hermes.MCP.Response{result: %{"tools" => tools}}} -> {:ok, tools}
{:ok, _response} -> {:ok, []}
{:error, reason} -> {:error, reason}
end
_ ->
{:error, :client_not_found}
end
{:error, reason} ->
{:error, reason}
end
rescue
error ->
# Tools discovery failed, likely due to timing or client not ready
{:error, {:rescue, error}}
catch
:exit, reason ->
{:error, {:exit, reason}}
end
else
{:error, :not_alive}
end
end
end
defp safe_supervisor_call(instance) do
try do
children = Supervisor.which_children(instance)
{:ok, children}
catch
:exit, reason -> {:error, {:supervisor_call_failed, reason}}
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 discovery enabled and this is an HTTP server without mcp_path
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
tools =
case safe_list_tools(server) do
{:ok, tools} ->
%{tools: Enum.map(tools, &tool_blurb/1)}
{:error, reason} ->
%{error: inspect(reason)}
end
# Check authentication status
auth_info = check_auth_status(server, cfg)
info |> Map.merge(capabilities) |> Map.merge(tools) |> 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
instance = MCPSup.instance_name(server)
case Process.whereis(instance) do
nil ->
{:error, :not_started}
pid when is_pid(pid) ->
if Process.alive?(pid) do
# Try to get capabilities from the client state
try do
case safe_supervisor_call(instance) do
{:ok, children} ->
case List.keyfind(children, Hermes.Client.Base, 0) do
{Hermes.Client.Base, client_pid, _, _} when is_pid(client_pid) ->
# Try to get server capabilities from the client
case Hermes.Client.Base.get_server_capabilities(client_pid) do
caps when is_map(caps) -> {:ok, caps}
nil -> {:ok, %{}}
end
_ ->
{:ok, %{}}
end
{:error, _} ->
{: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()) :: map()
defp check_auth_status(server, cfg) 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}} when is_integer(exp) ->
now = System.os_time(:second)
if exp > now do
:valid
else
:expired
end
{:ok, _} ->
# Has credentials but no expires_at - consider valid
:valid
{:error, :not_found} ->
:missing
end
%{has_oauth: true, auth_status: auth_status}
else
%{has_oauth: false}
end
end
end