Packages
fnord
0.8.65
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/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