Current section

Files

Jump to
ragex lib ragex mcp client.ex
Raw

lib/ragex/mcp/client.ex

defmodule Ragex.MCP.Client do
@moduledoc """
Client for communicating with a running Ragex MCP server via Unix socket.
Allows mix tasks and other callers to delegate work to an already-running
Ragex instance instead of starting a new BEAM VM (which would try to
allocate GPU memory for Bumblebee a second time).
## Usage
if Ragex.MCP.Client.server_running?() do
{:ok, conn} = Ragex.MCP.Client.connect()
{:ok, result} = Ragex.MCP.Client.call_tool(conn, "analyze_directory", %{"path" => "."})
Ragex.MCP.Client.disconnect(conn)
end
"""
@socket_path ~c"/tmp/ragex_mcp.sock"
@connect_timeout 3_000
@recv_timeout 300_000
defstruct [:socket, request_id: 1]
@type t :: %__MODULE__{socket: :gen_tcp.socket(), request_id: non_neg_integer()}
@doc """
Returns true if a Ragex MCP server is reachable on the Unix socket.
Sends a `ping` request and checks for a valid response.
"""
@spec server_running?() :: boolean()
def server_running? do
case connect() do
{:ok, conn} ->
result = ping(conn)
disconnect(conn)
match?({:ok, _}, result)
{:error, _} ->
false
end
end
@doc """
Opens a connection to the Ragex MCP socket server.
Returns `{:ok, conn}` or `{:error, reason}`.
"""
@spec connect() :: {:ok, t()} | {:error, term()}
def connect do
# NOTE: {:ip, {:local, path}} is for listen (server), NOT connect (client).
# The destination is passed as the first arg to :gen_tcp.connect.
opts = [:binary, {:active, false}]
case :gen_tcp.connect({:local, @socket_path}, 0, opts, @connect_timeout) do
{:ok, socket} ->
conn = %__MODULE__{socket: socket}
# Send initialize handshake
case initialize(conn) do
{:ok, conn} ->
{:ok, conn}
{:error, reason} ->
:gen_tcp.close(socket)
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Calls an MCP tool on the remote server.
Returns `{:ok, result}` where result is the decoded JSON response,
or `{:error, reason}`.
"""
@spec call_tool(t(), String.t(), map()) :: {:ok, term()} | {:error, term()}
def call_tool(%__MODULE__{} = conn, tool_name, arguments \\ %{}) do
request = %{
"jsonrpc" => "2.0",
"id" => conn.request_id,
"method" => "tools/call",
"params" => %{
"name" => tool_name,
"arguments" => arguments
}
}
case send_and_receive(conn, request) do
{:ok, %{"result" => result}} ->
# MCP wraps tool results in content array
parsed = extract_tool_result(result)
{:ok, parsed}
{:ok, %{"error" => error}} ->
{:error, error}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Sends a ping to the server. Returns `{:ok, response}` or `{:error, reason}`.
"""
@spec ping(t()) :: {:ok, map()} | {:error, term()}
def ping(%__MODULE__{} = conn) do
request = %{
"jsonrpc" => "2.0",
"id" => conn.request_id,
"method" => "ping",
"params" => %{}
}
send_and_receive(conn, request)
end
@doc """
Closes the connection to the server.
"""
@spec disconnect(t()) :: :ok
def disconnect(%__MODULE__{socket: socket}) do
:gen_tcp.close(socket)
:ok
end
# Private functions
defp initialize(conn) do
request = %{
"jsonrpc" => "2.0",
"id" => conn.request_id,
"method" => "initialize",
"params" => %{
"protocolVersion" => "2024-11-05",
"clientInfo" => %{
"name" => "ragex-client",
"version" => "1.0.0"
},
"capabilities" => %{}
}
}
case send_and_receive(conn, request) do
{:ok, %{"result" => _}} ->
{:ok, %{conn | request_id: conn.request_id + 1}}
{:ok, %{"error" => error}} ->
{:error, {:initialize_failed, error}}
{:error, reason} ->
{:error, reason}
end
end
defp send_and_receive(conn, request) do
json = :json.encode(request) |> IO.iodata_to_binary()
case :gen_tcp.send(conn.socket, json <> "\n") do
:ok ->
receive_response(conn)
{:error, reason} ->
{:error, reason}
end
end
defp receive_response(conn) do
# Read until we get a complete JSON line
receive_line(conn, <<>>)
end
defp receive_line(conn, buffer) do
case :gen_tcp.recv(conn.socket, 0, @recv_timeout) do
{:ok, data} ->
combined = buffer <> data
# Split on newlines - the response is a single JSON line
case String.split(combined, "\n", parts: 2) do
[line, _rest] when line != "" ->
decode_response(line)
_ ->
# Haven't received a complete line yet, keep reading
receive_line(conn, combined)
end
{:error, reason} ->
{:error, reason}
end
end
defp decode_response(line) do
case Jason.decode(line) do
{:ok, response} -> {:ok, response}
{:error, reason} -> {:error, {:decode_error, reason}}
end
end
defp extract_tool_result(%{"content" => [%{"type" => "text", "text" => text} | _]}) do
case Jason.decode(text) do
{:ok, parsed} -> parsed
{:error, _} -> text
end
end
defp extract_tool_result(other), do: other
end