Skip to main content

src/barrel_mcp_clients.erl

%%%-------------------------------------------------------------------
%%% @doc Federation registry for connected MCP clients.
%%%
%%% Lets a host application keep one supervised `barrel_mcp_client'
%%% per remote MCP server, looked up by an opaque `ServerId' the host
%%% chooses (typically a binary name like `<<"github">>'). Tool-name
%%% namespacing across servers is the host's policy and is not
%%% enforced here.
%%%
%%% This module is a tiny `gen_server' whose only job is to own the
%%% lookup ETS table (so the table outlives any single caller) and to
%%% serialize registration so two callers cannot race on the same
%%% `ServerId'. Lookups go directly to ETS without crossing the
%%% process boundary.
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_mcp_clients).

-behaviour(gen_server).

-export([
    start_link/0,
    start_client/2,
    stop_client/1,
    whereis_client/1,
    list_clients/0
]).

-export([
    init/1,
    handle_call/3,
    handle_cast/2,
    handle_info/2,
    terminate/2,
    code_change/3
]).

-define(TABLE, ?MODULE).

%%====================================================================
%% Public API
%%====================================================================

%% @doc Start the registry. Called by `barrel_mcp_sup'; you don't
%% normally call this directly.
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

%% @doc Start a supervised `barrel_mcp_client' worker registered as
%% `ServerId'. Fails with `{already_registered, Pid}' if a worker
%% already holds that id.
%%
%% Example:
%% ```
%% {ok, _} = barrel_mcp_clients:start_client(<<"github">>, #{
%%     transport => {http, <<"https://mcp.github.example/">>},
%%     auth => {bearer, GhToken}
%% }).
%% '''
-spec start_client(term(), barrel_mcp_client:connect_spec()) ->
    {ok, pid()} | {error, term()}.
start_client(ServerId, Spec) ->
    gen_server:call(?MODULE, {start_client, ServerId, Spec}).

%% @doc Stop the client worker registered as `ServerId'. Returns
%% `{error, not_found}' if no worker holds that id.
-spec stop_client(term()) -> ok | {error, not_found}.
stop_client(ServerId) ->
    gen_server:call(?MODULE, {stop_client, ServerId}).

%% @doc Look up a worker pid by its `ServerId'. Returns `undefined' if
%% none is registered. ETS-backed; safe to call from any process.
-spec whereis_client(term()) -> pid() | undefined.
whereis_client(ServerId) ->
    case ets:lookup(?TABLE, ServerId) of
        [{_, Pid, _Ref}] -> Pid;
        [] -> undefined
    end.

%% @doc Snapshot the registry as `[{ServerId, Pid}]'. ETS-backed.
-spec list_clients() -> [{term(), pid()}].
list_clients() ->
    ets:foldl(fun({Id, Pid, _}, Acc) -> [{Id, Pid} | Acc] end, [], ?TABLE).

%%====================================================================
%% gen_server
%%====================================================================

init([]) ->
    _ = ets:new(?TABLE, [set, named_table, protected, {read_concurrency, true}]),
    {ok, #{}}.

handle_call({start_client, ServerId, Spec}, _From, State) ->
    case ets:lookup(?TABLE, ServerId) of
        [{_, Pid, _}] when is_pid(Pid) ->
            {reply, {error, {already_registered, Pid}}, State};
        [] ->
            case barrel_mcp_client_sup:start_child(ServerId, Spec) of
                {ok, Pid} = Ok ->
                    Ref = erlang:monitor(process, Pid),
                    true = ets:insert(?TABLE, {ServerId, Pid, Ref}),
                    {reply, Ok, State};
                Err ->
                    {reply, Err, State}
            end
    end;
handle_call({stop_client, ServerId}, _From, State) ->
    case ets:lookup(?TABLE, ServerId) of
        [{_, Pid, Ref}] ->
            erlang:demonitor(Ref, [flush]),
            true = ets:delete(?TABLE, ServerId),
            barrel_mcp_client:close(Pid),
            {reply, ok, State};
        [] ->
            {reply, {error, not_found}, State}
    end;
handle_call(_Msg, _From, State) ->
    {reply, {error, badcall}, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
    %% Client crashed or shut down — remove its registration.
    Match = ets:match_object(?TABLE, {'_', Pid, '_'}),
    [ets:delete(?TABLE, Id) || {Id, _, _} <- Match],
    {noreply, State};
handle_info(_Msg, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.