Packages
fnord
0.9.12
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/mcp/endpoint_discovery.ex
defmodule MCP.EndpointDiscovery do
@moduledoc """
Auto-discovers MCP endpoint paths when the default path returns 404.
"""
require Logger
@common_paths ["/mcp", "/", "/api/mcp", "/mcp/v1"]
@doc """
Attempts to discover the MCP endpoint path by trying common paths.
Returns {:ok, path} if a working path is found, {:error, reason} otherwise.
## Options
* `:server` - Server name for OAuth header lookup
* `:config` - Server configuration map (for OAuth)
"""
@spec discover(String.t(), list(String.t()), keyword()) :: {:ok, String.t()} | {:error, term()}
def discover(base_url, paths \\ @common_paths, opts \\ []) do
UI.info("MCP Discovery", "Endpoint not found, trying common paths...")
# Build OAuth headers if server config provided
headers = build_auth_headers(opts)
results =
Enum.map(paths, fn path ->
url = Path.join(base_url, path)
{path, check_endpoint(url, headers)}
end)
# Show results as we go
Enum.each(results, fn {path, result} ->
case result do
{:ok, _} ->
UI.info(" ✓ #{path}")
{:error, status} when is_integer(status) ->
UI.info(" ✗ #{path} (#{status})")
{:error, _reason} ->
UI.info(" ✗ #{path} (error)")
end
end)
# Find first successful path
case Enum.find(results, fn {_path, result} -> match?({:ok, _}, result) end) do
{path, {:ok, _}} ->
{:ok, path}
nil ->
{:error, :no_working_path}
end
end
@doc """
Prompts the user to save the discovered path to settings.
Returns :ok if saved (or user declined), {:error, reason} on failure.
"""
@spec prompt_and_save(String.t(), String.t(), :global | {:project, String.t()}) ::
:ok | {:error, term()}
def prompt_and_save(server_name, path, scope) do
UI.newline()
"Found MCP endpoint at #{path}. Save this to configuration? [Y/n] "
|> UI.prompt()
|> case do
{:error, :no_tty} ->
# Non-interactive mode, skip prompting
UI.info(
"Discovery",
"Found endpoint at #{path} but cannot prompt in non-interactive mode"
)
UI.info("", "Manually add \"mcp_path\": \"#{path}\" to settings.json")
:ok
response ->
response
|> String.trim()
|> String.downcase()
|> case do
"" ->
save_discovered_path(server_name, path, scope)
"y" ->
save_discovered_path(server_name, path, scope)
_ ->
UI.info(
"Discovery",
"Path not saved. You can manually add it to settings.json later."
)
:ok
end
end
end
defp save_discovered_path(server_name, path, scope) do
case save_to_settings(server_name, path, scope) do
:ok ->
UI.info("Discovery", "Saved mcp_path='#{path}' for server '#{server_name}'")
UI.info("", "Restart fnord to use the new endpoint")
:ok
{:error, reason} ->
UI.error("Failed to save MCP endpoint discovery", inspect(reason))
{:error, reason}
end
end
defp save_to_settings(server_name, path, scope) do
# Read current settings file directly
settings_path = Path.join(System.user_home!(), ".fnord/settings.json")
with {:ok, content} <- File.read(settings_path),
{:ok, settings_json} <- SafeJson.decode(content),
{:ok, updated_json} <- update_mcp_path(settings_json, server_name, path, scope),
{:ok, encoded} <- SafeJson.encode(updated_json, pretty: true),
:ok <- File.write(settings_path, encoded) do
:ok
else
error -> error
end
end
defp update_mcp_path(settings, server_name, path, :global) do
mcp_servers = Map.get(settings, "mcp_servers", %{})
case Map.get(mcp_servers, server_name) do
nil ->
{:error, :server_not_found}
server_cfg ->
updated_server = Map.put(server_cfg, "mcp_path", path)
updated_mcp = Map.put(mcp_servers, server_name, updated_server)
{:ok, Map.put(settings, "mcp_servers", updated_mcp)}
end
end
defp update_mcp_path(settings, server_name, path, {:project, project}) do
projects = Map.get(settings, "projects", %{})
project_cfg = Map.get(projects, project, %{})
mcp_servers = Map.get(project_cfg, "mcp_servers", %{})
case Map.get(mcp_servers, server_name) do
nil ->
{:error, :server_not_found}
server_cfg ->
updated_server = Map.put(server_cfg, "mcp_path", path)
updated_mcp = Map.put(mcp_servers, server_name, updated_server)
updated_project = Map.put(project_cfg, "mcp_servers", updated_mcp)
updated_projects = Map.put(projects, project, updated_project)
{:ok, Map.put(settings, "projects", updated_projects)}
end
end
# Build authorization headers for OAuth-protected endpoints
defp build_auth_headers(opts) do
case {Keyword.get(opts, :server), Keyword.get(opts, :config)} do
{nil, _} ->
[]
{_server, nil} ->
[]
{server, cfg} ->
case MCP.OAuth2.Bridge.authorization_header(server, cfg) do
{:ok, header_list} -> header_list
{:error, _} -> []
end
end
end
# Check if endpoint exists using HEAD request, fallback to GET
defp check_endpoint(url, headers) do
# Try HEAD first
case HTTPoison.head(url, headers, recv_timeout: 5_000, timeout: 5_000) do
{:ok, %{status_code: status}} when status in 200..299 ->
{:ok, status}
# 400 = Bad Request, likely because MCP endpoint expects JSON-RPC POST, not HEAD
# But it confirms the endpoint exists and auth worked (vs 401/403/404)
{:ok, %{status_code: 400}} ->
{:ok, 400}
{:ok, %{status_code: 405}} ->
# Method not allowed, try GET
check_endpoint_with_get(url, headers)
{:ok, %{status_code: status}} ->
{:error, status}
{:error, reason} ->
{:error, reason}
end
end
defp check_endpoint_with_get(url, headers) do
case HTTPoison.get(url, headers, recv_timeout: 5_000, timeout: 5_000) do
{:ok, %{status_code: status}} when status in 200..299 ->
{:ok, status}
# 400 = Bad Request, likely because MCP endpoint expects JSON-RPC POST
# But it confirms the endpoint exists and auth worked
{:ok, %{status_code: 400}} ->
{:ok, 400}
{:ok, %{status_code: status}} ->
{:error, status}
{:error, reason} ->
{:error, reason}
end
end
end