Current section

Files

Jump to
ragex lib ragex mcp delegate.ex
Raw

lib/ragex/mcp/delegate.ex

defmodule Ragex.MCP.Delegate do
@moduledoc """
Helper for Mix tasks to delegate work to a running Ragex MCP server.
When a Ragex server is already running (with Bumblebee loaded on the GPU),
Mix tasks can use this module to delegate heavy work to the server via
Unix socket instead of starting a second BEAM VM.
## Usage
alias Ragex.MCP.Delegate
case Delegate.with_server(fn conn ->
{:ok, result} = Delegate.call(conn, "analyze_directory", %{"path" => "."})
result
end) do
{:ok, result} -> handle_result(result)
{:error, :not_running} -> fallback_to_local()
end
"""
alias Ragex.MCP.Client
@doc """
Checks if a running Ragex server is available for delegation.
"""
@spec server_available?() :: boolean()
def server_available?, do: Client.server_running?()
@doc """
Connects to the running server, runs the callback, and disconnects.
Returns `{:ok, callback_result}` on success, or `{:error, reason}` if
the server is not reachable or the callback fails.
"""
@spec with_server((Client.t() -> term())) :: {:ok, term()} | {:error, term()}
def with_server(fun) when is_function(fun, 1) do
case Client.connect() do
{:ok, conn} ->
try do
result = fun.(conn)
{:ok, result}
rescue
e -> {:error, {:callback_error, Exception.message(e)}}
after
Client.disconnect(conn)
end
{:error, _reason} ->
{:error, :not_running}
end
end
@doc """
Calls an MCP tool on the connected server.
Thin wrapper around `Client.call_tool/3` that normalises the result
with `atomize_keys/1` on success.
"""
@spec call(Client.t(), String.t(), map()) :: {:ok, term()} | {:error, term()}
def call(conn, tool_name, arguments \\ %{}) do
case Client.call_tool(conn, tool_name, arguments) do
{:ok, result} -> {:ok, atomize_keys(result)}
{:error, _} = err -> err
end
end
@doc """
Calls an MCP tool and returns the result directly (raises on error).
"""
@spec call!(Client.t(), String.t(), map()) :: term()
def call!(conn, tool_name, arguments \\ %{}) do
case call(conn, tool_name, arguments) do
{:ok, result} -> result
{:error, reason} -> raise "MCP tool #{tool_name} failed: #{inspect(reason)}"
end
end
# ---------------------------------------------------------------------------
# Result normalisation
# ---------------------------------------------------------------------------
@doc """
Recursively converts string-keyed maps to atom-keyed maps.
MCP tool results are JSON-decoded and arrive with string keys. The
existing Mix task formatters expect atom keys.
## Examples
iex> Ragex.MCP.Delegate.atomize_keys(%{"foo" => 1, "bar" => %{"baz" => 2}})
%{foo: 1, bar: %{baz: 2}}
iex> Ragex.MCP.Delegate.atomize_keys([%{"a" => 1}, %{"b" => 2}])
[%{a: 1}, %{b: 2}]
"""
@spec atomize_keys(term()) :: term()
def atomize_keys(map) when is_map(map) do
Map.new(map, fn
{key, value} when is_binary(key) ->
{safe_to_atom(key), atomize_keys(value)}
{key, value} ->
{key, atomize_keys(value)}
end)
end
def atomize_keys(list) when is_list(list) do
Enum.map(list, &atomize_keys/1)
end
def atomize_keys(other), do: other
@doc """
Converts known string values back to atoms for fields that require them.
The `fields` argument lists which map keys should have their values atomized.
## Examples
iex> Ragex.MCP.Delegate.atomize_values(%{severity: "high", count: 3}, [:severity])
%{severity: :high, count: 3}
"""
@spec atomize_values(map(), [atom()]) :: map()
def atomize_values(map, fields) when is_map(map) and is_list(fields) do
Enum.reduce(fields, map, fn field, acc ->
case Map.get(acc, field) do
value when is_binary(value) -> Map.put(acc, field, safe_to_atom(value))
_ -> acc
end
end)
end
@doc """
Converts module name strings like "Elixir.Foo.Bar" or "Foo.Bar" back to
atoms so they render correctly with `inspect/1`.
"""
@spec to_module_atom(String.t() | atom()) :: atom()
def to_module_atom(name) when is_atom(name), do: name
def to_module_atom("Elixir." <> _ = name), do: String.to_atom(name)
def to_module_atom(name) when is_binary(name) do
if String.starts_with?(name, "Elixir.") or String.match?(name, ~r/^[A-Z]/) do
String.to_atom("Elixir." <> name)
else
String.to_atom(name)
end
end
# Private helpers
defp safe_to_atom(str) when is_binary(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> String.to_atom(str)
end
end