Current section
Files
Jump to
Current section
Files
examples/calculator_client.erl
-module(calculator_client).
-behaviour(gen_statem).
-include("erlmcp.hrl").
%% API
-export([
start_link/0,
start_link/1,
connect/2,
calculate/2,
get_history/1,
clear_history/1,
stop/1
]).
%% gen_statem callbacks
-export([
init/1,
callback_mode/0,
terminate/3,
code_change/4
]).
%% State callbacks
-export([
disconnected/3,
connecting/3,
connected/3,
ready/3
]).
-define(SERVER, ?MODULE).
-define(RECONNECT_DELAY, 5000).
%% State data
-record(data, {
mcp_client :: pid() | undefined,
server_info :: map() | undefined,
capabilities :: #mcp_server_capabilities{} | undefined,
history = [] :: [calculation()],
pending_requests = queue:new() :: queue:queue(),
reconnect_timer :: reference() | undefined,
connection_opts :: map()
}).
-type calculation() :: #{
expression := binary(),
result := term(),
timestamp := erlang:timestamp()
}.
%%====================================================================
%% API
%%====================================================================
start_link() ->
start_link(#{}).
start_link(Options) ->
gen_statem:start_link(?MODULE, Options, []).
connect(Client, ServerAddress) ->
gen_statem:call(Client, {connect, ServerAddress}).
calculate(Client, Expression) ->
gen_statem:call(Client, {calculate, Expression}, 10000).
get_history(Client) ->
gen_statem:call(Client, get_history).
clear_history(Client) ->
gen_statem:call(Client, clear_history).
stop(Client) ->
gen_statem:stop(Client).
%%====================================================================
%% gen_statem callbacks
%%====================================================================
init(Options) ->
process_flag(trap_exit, true),
Data = #data{
connection_opts = Options
},
{ok, disconnected, Data}.
callback_mode() ->
[state_functions, state_enter].
%%====================================================================
%% State: disconnected
%%====================================================================
disconnected(enter, _OldState, Data) ->
logger:info("Calculator client disconnected"),
%% Cancel any existing reconnect timer
cancel_reconnect_timer(Data),
{keep_state_and_data, []};
disconnected({call, From}, {connect, ServerAddress}, Data) ->
logger:info("Connecting to calculator server at ~p", [ServerAddress]),
{next_state, connecting, Data#data{connection_opts = ServerAddress},
[{reply, From, ok}]};
disconnected({call, From}, {calculate, _}, _Data) ->
{keep_state_and_data, [{reply, From, {error, not_connected}}]};
disconnected({call, From}, get_history, Data) ->
{keep_state_and_data, [{reply, From, {ok, Data#data.history}}]};
disconnected({call, From}, clear_history, Data) ->
{next_state, disconnected, Data#data{history = []},
[{reply, From, ok}]}.
%%====================================================================
%% State: connecting
%%====================================================================
connecting(enter, _OldState, Data) ->
%% Start MCP client
TransportOpts = build_transport_opts(Data#data.connection_opts),
ClientOpts = #{
strict_mode => false,
timeout => 5000
},
case erlmcp_client:start_link(TransportOpts, ClientOpts) of
{ok, McpClient} ->
%% Set up handlers
setup_handlers(McpClient),
%% Initialize connection
Capabilities = #mcp_client_capabilities{
roots = #mcp_capability{enabled = true},
sampling = #mcp_capability{enabled = false}
},
case erlmcp_client:initialize(McpClient, Capabilities) of
{ok, InitResult} ->
ServerInfo = maps:get(<<"serverInfo">>, InitResult, #{}),
Capabilities = extract_capabilities(InitResult),
logger:info("Connected to server: ~p", [ServerInfo]),
NewData = Data#data{
mcp_client = McpClient,
server_info = ServerInfo,
capabilities = Capabilities
},
{next_state, connected, NewData};
{error, Reason} ->
logger:error("Failed to initialize MCP connection: ~p", [Reason]),
erlmcp_client:stop(McpClient),
schedule_reconnect(Data)
end;
{error, Reason} ->
logger:error("Failed to start MCP client: ~p", [Reason]),
schedule_reconnect(Data)
end;
connecting({call, From}, {calculate, Expression}, Data) ->
%% Queue the request
NewQueue = queue:in({calculate, Expression, From}, Data#data.pending_requests),
{keep_state, Data#data{pending_requests = NewQueue}, []};
connecting({call, From}, _, _Data) ->
{keep_state_and_data, [{reply, From, {error, connecting}}]}.
%%====================================================================
%% State: connected
%%====================================================================
connected(enter, _OldState, Data) ->
%% Verify server capabilities
case verify_calculator_capabilities(Data#data.mcp_client) of
ok ->
%% Process any pending requests
process_pending_requests(Data),
{next_state, ready, Data};
{error, Reason} ->
logger:error("Server doesn't support calculator capabilities: ~p", [Reason]),
disconnect_and_retry(Data)
end;
connected({call, From}, _, _Data) ->
{keep_state_and_data, [{reply, From, {error, initializing}}]}.
%%====================================================================
%% State: ready
%%====================================================================
ready(enter, _OldState, _Data) ->
logger:info("Calculator client ready"),
keep_state_and_data;
ready({call, From}, {calculate, Expression}, Data) ->
case perform_calculation(Expression, Data) of
{ok, Result} ->
%% Store in history
Calculation = #{
expression => Expression,
result => Result,
timestamp => erlang:timestamp()
},
NewHistory = [Calculation | Data#data.history],
NewData = Data#data{history = NewHistory},
{keep_state, NewData, [{reply, From, {ok, Result}}]};
{error, Reason} = Error ->
logger:error("Calculation failed: ~p", [Reason]),
{keep_state_and_data, [{reply, From, Error}]}
end;
ready({call, From}, get_history, Data) ->
{keep_state_and_data, [{reply, From, {ok, Data#data.history}}]};
ready({call, From}, clear_history, Data) ->
{keep_state, Data#data{history = []}, [{reply, From, ok}]};
ready(info, {'EXIT', Pid, Reason}, #data{mcp_client = Pid} = Data) ->
logger:error("MCP client died: ~p", [Reason]),
schedule_reconnect(Data#data{mcp_client = undefined});
ready(info, {resource_updated, Uri, Metadata}, Data) ->
logger:info("Resource updated: ~s, metadata: ~p", [Uri, Metadata]),
{keep_state, Data}.
%%====================================================================
%% Common state callbacks
%%====================================================================
terminate(_Reason, _State, #data{mcp_client = McpClient}) when is_pid(McpClient) ->
erlmcp_client:stop(McpClient),
ok;
terminate(_Reason, _State, _Data) ->
ok.
code_change(_OldVsn, State, Data, _Extra) ->
{ok, State, Data}.
%%====================================================================
%% Internal functions - Connection Management
%%====================================================================
build_transport_opts(#{type := tcp, host := Host, port := Port}) ->
{tcp, #{host => Host, port => Port}};
build_transport_opts(#{type := http, url := Url}) ->
{http, #{url => Url}};
build_transport_opts(_) ->
{stdio, []}.
setup_handlers(McpClient) ->
%% Set up notification handlers
erlmcp_client:set_notification_handler(
McpClient,
<<"resources/updated">>,
fun(Method, Params) ->
self() ! {notification, Method, Params}
end
),
erlmcp_client:set_notification_handler(
McpClient,
<<"tools/updated">>,
fun(Method, Params) ->
logger:info("Tools updated: ~p", [Params])
end
),
ok.
extract_capabilities(InitResult) ->
case maps:get(<<"capabilities">>, InitResult, #{}) of
Caps when is_map(Caps) ->
#mcp_server_capabilities{
tools = case maps:is_key(<<"tools">>, Caps) of
true -> #mcp_capability{enabled = true};
false -> undefined
end,
prompts = case maps:is_key(<<"prompts">>, Caps) of
true -> #mcp_capability{enabled = true};
false -> undefined
end,
resources = case maps:is_key(<<"resources">>, Caps) of
true -> #mcp_capability{enabled = true};
false -> undefined
end
};
_ ->
undefined
end.
verify_calculator_capabilities(McpClient) ->
%% Check if calculator tools are available
case erlmcp_client:list_tools(McpClient) of
{ok, #{<<"tools">> := Tools}} ->
RequiredTools = [<<"calculate">>, <<"evaluate">>, <<"solve">>],
AvailableTools = [maps:get(<<"name">>, Tool, <<>>) || Tool <- Tools],
case lists:any(fun(Tool) -> lists:member(Tool, AvailableTools) end, RequiredTools) of
true -> ok;
false -> {error, no_calculator_tools}
end;
{error, Reason} ->
{error, {list_tools_failed, Reason}}
end.
schedule_reconnect(Data) ->
Timer = erlang:send_after(?RECONNECT_DELAY, self(), reconnect),
{next_state, disconnected, Data#data{reconnect_timer = Timer}}.
cancel_reconnect_timer(#data{reconnect_timer = undefined}) ->
ok;
cancel_reconnect_timer(#data{reconnect_timer = Timer}) ->
erlang:cancel_timer(Timer),
ok.
disconnect_and_retry(#data{mcp_client = McpClient} = Data) when is_pid(McpClient) ->
erlmcp_client:stop(McpClient),
schedule_reconnect(Data#data{mcp_client = undefined});
disconnect_and_retry(Data) ->
schedule_reconnect(Data).
process_pending_requests(#data{pending_requests = Queue} = Data) ->
case queue:is_empty(Queue) of
true -> ok;
false ->
lists:foreach(fun({calculate, Expression, From}) ->
gen_statem:reply(From, {error, reconnected_try_again})
end, queue:to_list(Queue))
end.
%%====================================================================
%% Internal functions - Calculation
%%====================================================================
perform_calculation(Expression, #data{mcp_client = McpClient}) ->
%% Try different calculation methods based on available tools
case try_calculation_tool(McpClient, Expression) of
{ok, _} = Result ->
Result;
{error, tool_not_found} ->
%% Fallback to prompt if tools aren't available
try_calculation_prompt(McpClient, Expression);
Error ->
Error
end.
try_calculation_tool(McpClient, Expression) ->
%% First, try the 'calculate' tool
case erlmcp_client:call_tool(McpClient, <<"calculate">>,
#{<<"expression">> => Expression}) of
{ok, #{<<"content">> := [Content | _]}} ->
parse_calculation_result(Content);
{error, {error_response, #{<<"code">> := -32602}}} ->
%% Tool not found, try 'evaluate' tool
case erlmcp_client:call_tool(McpClient, <<"evaluate">>,
#{<<"expression">> => Expression}) of
{ok, #{<<"content">> := [Content | _]}} ->
parse_calculation_result(Content);
_ ->
{error, tool_not_found}
end;
{error, Reason} ->
{error, Reason}
end.
try_calculation_prompt(McpClient, Expression) ->
%% Use a calculation prompt as fallback
case erlmcp_client:get_prompt(McpClient, <<"calculate">>,
#{<<"expression">> => Expression}) of
{ok, #{<<"messages">> := Messages}} ->
extract_result_from_messages(Messages);
{error, {error_response, #{<<"code">> := -32602}}} ->
%% No calculation prompt, try a general math prompt
try_general_calculation(McpClient, Expression);
{error, Reason} ->
{error, Reason}
end.
try_general_calculation(McpClient, Expression) ->
%% Last resort: try to find any math-related tool or prompt
case erlmcp_client:list_tools(McpClient) of
{ok, #{<<"tools">> := Tools}} ->
MathTools = find_math_tools(Tools),
try_math_tools(McpClient, Expression, MathTools);
_ ->
{error, no_calculation_capability}
end.
find_math_tools(Tools) ->
MathKeywords = [<<"math">>, <<"calc">>, <<"compute">>, <<"eval">>],
lists:filter(fun(Tool) ->
Name = maps:get(<<"name">>, Tool, <<>>),
Desc = maps:get(<<"description">>, Tool, <<>>),
lists:any(fun(Keyword) ->
binary:match(Name, Keyword) =/= nomatch orelse
binary:match(Desc, Keyword) =/= nomatch
end, MathKeywords)
end, Tools).
try_math_tools(_McpClient, _Expression, []) ->
{error, no_math_tools};
try_math_tools(McpClient, Expression, [Tool | Rest]) ->
ToolName = maps:get(<<"name">>, Tool),
case erlmcp_client:call_tool(McpClient, ToolName,
#{<<"input">> => Expression}) of
{ok, #{<<"content">> := [Content | _]}} ->
parse_calculation_result(Content);
_ ->
try_math_tools(McpClient, Expression, Rest)
end.
parse_calculation_result(#{<<"type">> := <<"text">>, <<"text">> := Text}) ->
%% Try to extract numeric result from text
case parse_numeric_result(Text) of
{ok, Number} -> {ok, Number};
error -> {ok, Text} % Return text if we can't parse a number
end;
parse_calculation_result(#{<<"data">> := Data}) ->
{ok, Data};
parse_calculation_result(_) ->
{error, invalid_result_format}.
parse_numeric_result(Text) ->
%% Simple numeric parser - in real implementation would be more robust
TextStr = binary_to_list(Text),
case string:tokens(TextStr, "=") of
[_, Result] ->
case string:to_float(string:strip(Result)) of
{Float, []} -> {ok, Float};
_ ->
case string:to_integer(string:strip(Result)) of
{Int, []} -> {ok, Int};
_ -> error
end
end;
_ ->
%% Try to parse as a single number
case string:to_float(TextStr) of
{Float, []} -> {ok, Float};
_ ->
case string:to_integer(TextStr) of
{Int, []} -> {ok, Int};
_ -> error
end
end
end.
extract_result_from_messages([#{<<"content">> := Content} | _]) when is_binary(Content) ->
parse_numeric_result(Content);
extract_result_from_messages([#{<<"content">> := #{<<"text">> := Text}} | _]) ->
parse_numeric_result(Text);
extract_result_from_messages([_ | Rest]) ->
extract_result_from_messages(Rest);
extract_result_from_messages([]) ->
{error, no_result_in_messages}.