Skip to main content

src/barrel_mcp_tool_format.erl

%%%-------------------------------------------------------------------
%%% @doc Translators between MCP tool shapes and LLM provider tool
%%% shapes (Anthropic Messages API, OpenAI Chat Completions / Responses).
%%%
%%% Use this module to bridge an MCP server's `tools/list' response
%%% into the tool definitions a provider expects, and to translate a
%%% provider's tool-call back into the `(Name, Arguments)' pair you
%%% feed to {@link barrel_mcp_client:call_tool/4}.
%%%
%%% The MCP shape (one entry from `tools/list') is:
%%% ```
%%% #{
%%%   <<"name">>        := binary(),
%%%   <<"description">> => binary(),
%%%   <<"inputSchema">> => map(),
%%%   <<"title">>       => binary(),     %% optional
%%%   <<"annotations">> => map(),        %% optional
%%%   ...                                 %% other MCP keys ignored
%%% }
%%% '''
%%%
%%% The Anthropic shape (Messages API `tools' array entry) is:
%%% ```
%%% #{
%%%   <<"name">>         := binary(),
%%%   <<"description">>  := binary(),
%%%   <<"input_schema">> := map()
%%% }
%%% '''
%%%
%%% The OpenAI shape (Chat Completions `tools' array entry) is:
%%% ```
%%% #{
%%%   <<"type">>     := <<"function">>,
%%%   <<"function">> := #{
%%%     <<"name">>        := binary(),
%%%     <<"description">> := binary(),
%%%     <<"parameters">>  := map()
%%%   }
%%% }
%%% '''
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_mcp_tool_format).

-export([
    to_anthropic/1,
    to_openai/1,
    from_anthropic_call/1,
    from_openai_call/1
]).

-type mcp_tool() :: map().
-type provider_tool() :: map().
-type tool_call() :: {Name :: binary(), Args :: map()}.

-export_type([mcp_tool/0, provider_tool/0, tool_call/0]).

%%====================================================================
%% MCP -> provider
%%====================================================================

%% @doc Translate one MCP tool, or a list of MCP tools, into the
%% Anthropic Messages API tool format. Unknown MCP keys are ignored;
%% missing `description' becomes an empty binary.
-spec to_anthropic(mcp_tool() | [mcp_tool()]) ->
    provider_tool() | [provider_tool()].
to_anthropic(Tools) when is_list(Tools) ->
    [to_anthropic(T) || T <- Tools];
to_anthropic(Tool) when is_map(Tool) ->
    Schema = maps:get(
        <<"inputSchema">>,
        Tool,
        #{<<"type">> => <<"object">>}
    ),
    #{
        <<"name">> => maps:get(<<"name">>, Tool),
        <<"description">> => maps:get(<<"description">>, Tool, <<>>),
        <<"input_schema">> => Schema
    }.

%% @doc Translate one MCP tool, or a list of MCP tools, into the OpenAI
%% Chat Completions tool format. Wraps each tool in the
%% `{type: "function", function: {...}}' envelope OpenAI requires.
-spec to_openai(mcp_tool() | [mcp_tool()]) ->
    provider_tool() | [provider_tool()].
to_openai(Tools) when is_list(Tools) ->
    [to_openai(T) || T <- Tools];
to_openai(Tool) when is_map(Tool) ->
    Schema = maps:get(
        <<"inputSchema">>,
        Tool,
        #{<<"type">> => <<"object">>}
    ),
    #{
        <<"type">> => <<"function">>,
        <<"function">> => #{
            <<"name">> => maps:get(<<"name">>, Tool),
            <<"description">> => maps:get(<<"description">>, Tool, <<>>),
            <<"parameters">> => Schema
        }
    }.

%%====================================================================
%% Provider -> MCP
%%====================================================================

%% @doc Translate a single Anthropic `tool_use' content block into the
%% `(Name, Arguments)' pair you can feed to
%% {@link barrel_mcp_client:call_tool/4}. Accepts both the canonical
%% binary-keyed map and the camelCase variant some clients emit.
-spec from_anthropic_call(map()) -> tool_call().
from_anthropic_call(#{<<"name">> := Name, <<"input">> := Input}) ->
    {Name, Input};
from_anthropic_call(#{<<"toolName">> := Name, <<"input">> := Input}) ->
    {Name, Input}.

%% @doc Translate a single OpenAI `tool_call' object into the
%% `(Name, Arguments)' pair. Accepts both the parsed-arguments shape
%% (`arguments' is already a map) and the wire shape (`arguments' is a
%% JSON string that needs decoding).
-spec from_openai_call(map()) -> tool_call().
from_openai_call(#{<<"function">> := Fn}) ->
    Name = maps:get(<<"name">>, Fn),
    Args = decode_args(maps:get(<<"arguments">>, Fn, <<"{}">>)),
    {Name, Args}.

decode_args(Args) when is_map(Args) -> Args;
decode_args(Args) when is_binary(Args) ->
    case json:decode(Args) of
        Map when is_map(Map) -> Map;
        _ -> #{}
    end.