Packages

fnord

0.9.38

AI code archaeology

Current section

Files

Jump to
fnord lib mcp endpoint_discovery.ex
Raw

lib/mcp/endpoint_discovery.ex

defmodule MCP.EndpointDiscovery do
@moduledoc """
Auto-discovers MCP endpoint paths when the default path returns 404.
"""
@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
# Surgical JSON edit: patch the settings file in place rather than going
# through Settings' typed accessors. Settings.settings_file/0 is the
# canonical path - it honors the test home override, which a raw
# System.user_home! would bypass.
settings_path = Settings.settings_file()
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 Http.Client.impl().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 Http.Client.impl().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