Packages

Erlang Model Context Protocol (MCP) SDK

Current section

Files

Jump to
erlmcp examples weather_server.erl
Raw

examples/weather_server.erl

-module(weather_server).
-behaviour(gen_server).
-include("erlmcp.hrl").
%% API
-export([
start_link/0,
start_link/1,
stop/0,
update_temperature/2,
add_alert_subscriber/2,
remove_alert_subscriber/1
]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
-define(DEFAULT_PORT, 8080).
%% State record
-record(state, {
mcp_server :: pid(),
weather_data = #{} :: #{binary() => weather_reading()},
alert_subscribers = #{} :: #{binary() => alert_config()},
update_timer :: reference() | undefined
}).
-type weather_reading() :: #{
temperature := float(),
humidity := float(),
pressure := float(),
timestamp := erlang:timestamp(),
conditions := binary()
}.
-type alert_config() :: #{
email := binary(),
thresholds := #{atom() => {min, float()} | {max, float()}}
}.
%%====================================================================
%% API
%%====================================================================
start_link() ->
start_link(#{}).
start_link(Options) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Options, []).
stop() ->
gen_server:stop(?SERVER).
update_temperature(City, Temperature) ->
gen_server:cast(?SERVER, {update_temperature, City, Temperature}).
add_alert_subscriber(Email, Thresholds) ->
gen_server:call(?SERVER, {add_alert_subscriber, Email, Thresholds}).
remove_alert_subscriber(Email) ->
gen_server:call(?SERVER, {remove_alert_subscriber, Email}).
%%====================================================================
%% gen_server callbacks
%%====================================================================
init(Options) ->
process_flag(trap_exit, true),
%% Initialize MCP server
TransportOpts = maps:get(transport, Options, {stdio, []}),
Capabilities = #mcp_server_capabilities{
resources = #mcp_capability{name = <<"resources">>, enabled = true},
tools = #mcp_capability{name = <<"tools">>, enabled = true},
prompts = #mcp_capability{name = <<"prompts">>, enabled = true}
},
{ok, McpServer} = erlmcp_server:start_link(TransportOpts, Capabilities),
%% Register resources, tools, and prompts
ok = setup_mcp_resources(McpServer),
ok = setup_mcp_tools(McpServer),
ok = setup_mcp_prompts(McpServer),
%% Initialize with some demo data
WeatherData = initialize_demo_data(),
%% Start periodic updates
Timer = erlang:send_after(60000, self(), update_weather),
State = #state{
mcp_server = McpServer,
weather_data = WeatherData,
update_timer = Timer
},
logger:info("Weather server started with MCP interface"),
{ok, State}.
handle_call({add_alert_subscriber, Email, Thresholds}, _From, State) ->
AlertConfig = #{email => Email, thresholds => Thresholds},
NewSubscribers = maps:put(Email, AlertConfig, State#state.alert_subscribers),
{reply, ok, State#state{alert_subscribers = NewSubscribers}};
handle_call({remove_alert_subscriber, Email}, _From, State) ->
NewSubscribers = maps:remove(Email, State#state.alert_subscribers),
{reply, ok, State#state{alert_subscribers = NewSubscribers}};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({update_temperature, City, Temperature}, State) ->
NewState = update_city_temperature(City, Temperature, State),
check_alerts(City, NewState),
%% Notify MCP subscribers
erlmcp_server:notify_resource_updated(
State#state.mcp_server,
<<"weather://", City/binary>>,
#{<<"temperature">> => Temperature}
),
{noreply, NewState};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(update_weather, State) ->
%% Simulate weather updates
NewState = simulate_weather_changes(State),
Timer = erlang:send_after(60000, self(), update_weather),
{noreply, NewState#state{update_timer = Timer}};
handle_info({'EXIT', Pid, Reason}, #state{mcp_server = Pid} = State) ->
logger:error("MCP server died: ~p", [Reason]),
{stop, {mcp_server_died, Reason}, State};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{update_timer = Timer}) ->
case Timer of
undefined -> ok;
_ -> erlang:cancel_timer(Timer)
end,
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%====================================================================
%% Internal functions - MCP Setup
%%====================================================================
setup_mcp_resources(McpServer) ->
%% Add city weather resources
Cities = [<<"new_york">>, <<"london">>, <<"tokyo">>, <<"sydney">>],
lists:foreach(fun(City) ->
Uri = <<"weather://", City/binary>>,
erlmcp_server:add_resource(McpServer, Uri,
fun(_) -> get_city_weather_handler(City) end)
end, Cities),
%% Add resource template for any city
erlmcp_server:add_resource_template(
McpServer,
<<"weather://{city}">>,
<<"Weather data for any city">>,
fun(Uri) -> get_dynamic_city_weather_handler(Uri) end
),
%% Add aggregated resource
erlmcp_server:add_resource(
McpServer,
<<"weather://summary">>,
fun(_) -> get_weather_summary_handler() end
),
ok.
setup_mcp_tools(McpServer) ->
%% Weather query tool
QuerySchema = #{
<<"type">> => <<"object">>,
<<"properties">> => #{
<<"city">> => #{<<"type">> => <<"string">>},
<<"metric">> => #{
<<"type">> => <<"string">>,
<<"enum">> => [<<"temperature">>, <<"humidity">>, <<"pressure">>]
}
},
<<"required">> => [<<"city">>, <<"metric">>]
},
erlmcp_server:add_tool_with_schema(
McpServer,
<<"query_weather">>,
fun(Args) -> query_weather_tool(Args) end,
QuerySchema
),
%% Weather comparison tool
CompareSchema = #{
<<"type">> => <<"object">>,
<<"properties">> => #{
<<"cities">> => #{
<<"type">> => <<"array">>,
<<"items">> => #{<<"type">> => <<"string">>},
<<"minItems">> => 2
}
},
<<"required">> => [<<"cities">>]
},
erlmcp_server:add_tool_with_schema(
McpServer,
<<"compare_weather">>,
fun(Args) -> compare_weather_tool(Args) end,
CompareSchema
),
%% Alert configuration tool
AlertSchema = #{
<<"type">> => <<"object">>,
<<"properties">> => #{
<<"email">> => #{<<"type">> => <<"string">>, <<"format">> => <<"email">>},
<<"city">> => #{<<"type">> => <<"string">>},
<<"temperature_max">> => #{<<"type">> => <<"number">>},
<<"temperature_min">> => #{<<"type">> => <<"number">>}
},
<<"required">> => [<<"email">>, <<"city">>]
},
erlmcp_server:add_tool_with_schema(
McpServer,
<<"configure_alert">>,
fun(Args) -> configure_alert_tool(Args) end,
AlertSchema
),
ok.
setup_mcp_prompts(McpServer) ->
%% Weather report prompt
ReportArgs = [
#mcp_prompt_argument{
name = <<"city">>,
description = <<"City name for weather report">>,
required = true
},
#mcp_prompt_argument{
name = <<"format">>,
description = <<"Report format: brief or detailed">>,
required = false
}
],
erlmcp_server:add_prompt_with_args(
McpServer,
<<"weather_report">>,
fun(Args) -> weather_report_prompt(Args) end,
ReportArgs
),
%% Travel advice prompt
TravelArgs = [
#mcp_prompt_argument{
name = <<"destination">>,
description = <<"Travel destination city">>,
required = true
},
#mcp_prompt_argument{
name = <<"duration">>,
description = <<"Trip duration in days">>,
required = true
}
],
erlmcp_server:add_prompt_with_args(
McpServer,
<<"travel_weather_advice">>,
fun(Args) -> travel_advice_prompt(Args) end,
TravelArgs
),
ok.
%%====================================================================
%% Internal functions - Weather Logic
%%====================================================================
initialize_demo_data() ->
Cities = [
{<<"new_york">>, 22.5, 65.0, 1013.25, <<"partly_cloudy">>},
{<<"london">>, 18.2, 72.0, 1009.5, <<"rainy">>},
{<<"tokyo">>, 26.8, 78.0, 1011.0, <<"sunny">>},
{<<"sydney">>, 19.5, 68.0, 1015.8, <<"clear">>}
],
maps:from_list([
{City, #{
temperature => Temp,
humidity => Humidity,
pressure => Pressure,
timestamp => erlang:timestamp(),
conditions => Conditions
}} || {City, Temp, Humidity, Pressure, Conditions} <- Cities
]).
update_city_temperature(City, Temperature, State) ->
WeatherData = State#state.weather_data,
Reading = maps:get(City, WeatherData, #{
temperature => 20.0,
humidity => 50.0,
pressure => 1013.0,
conditions => <<"unknown">>
}),
UpdatedReading = Reading#{
temperature := Temperature,
timestamp := erlang:timestamp()
},
NewWeatherData = maps:put(City, UpdatedReading, WeatherData),
State#state{weather_data = NewWeatherData}.
simulate_weather_changes(State) ->
WeatherData = maps:map(fun(_City, Reading) ->
%% Random walk for temperature
OldTemp = maps:get(temperature, Reading),
TempChange = (rand:uniform() - 0.5) * 2.0,
NewTemp = max(-20.0, min(45.0, OldTemp + TempChange)),
%% Random walk for humidity
OldHumidity = maps:get(humidity, Reading),
HumidityChange = (rand:uniform() - 0.5) * 5.0,
NewHumidity = max(0.0, min(100.0, OldHumidity + HumidityChange)),
Reading#{
temperature := NewTemp,
humidity := NewHumidity,
timestamp := erlang:timestamp()
}
end, State#state.weather_data),
State#state{weather_data = WeatherData}.
check_alerts(City, State) ->
case maps:get(City, State#state.weather_data, undefined) of
undefined -> ok;
Reading ->
Temperature = maps:get(temperature, Reading),
maps:foreach(fun(Email, Config) ->
check_thresholds(Email, City, Temperature, Config)
end, State#state.alert_subscribers)
end.
check_thresholds(Email, City, Temperature, Config) ->
Thresholds = maps:get(thresholds, Config, #{}),
case maps:get(temperature_max, Thresholds, undefined) of
undefined -> ok;
MaxTemp when Temperature > MaxTemp ->
logger:warning("Alert: ~s temperature (~.1f) exceeds max threshold (~.1f) for ~s",
[City, Temperature, MaxTemp, Email]);
_ -> ok
end,
case maps:get(temperature_min, Thresholds, undefined) of
undefined -> ok;
MinTemp when Temperature < MinTemp ->
logger:warning("Alert: ~s temperature (~.1f) below min threshold (~.1f) for ~s",
[City, Temperature, MinTemp, Email]);
_ -> ok
end.
%%====================================================================
%% Internal functions - MCP Handlers
%%====================================================================
get_city_weather_handler(City) ->
case gen_server:call(?SERVER, {get_weather, City}, 5000) of
{ok, Reading} ->
format_weather_content(City, Reading);
{error, _} ->
#mcp_content{
type = <<"text">>,
text = <<"Weather data unavailable for ", City/binary>>,
mime_type = <<"text/plain">>
}
end.
get_dynamic_city_weather_handler(Uri) ->
case binary:split(Uri, <<"://">>) of
[<<"weather">>, City] ->
get_city_weather_handler(City);
_ ->
#mcp_content{
type = <<"text">>,
text = <<"Invalid weather URI format">>,
mime_type = <<"text/plain">>
}
end.
get_weather_summary_handler() ->
{ok, State} = gen_server:call(?SERVER, get_state),
WeatherData = State#state.weather_data,
Summary = maps:fold(fun(City, Reading, Acc) ->
Temp = maps:get(temperature, Reading),
[io_lib:format("~s: ~.1f°C~n", [City, Temp]) | Acc]
end, [], WeatherData),
#mcp_content{
type = <<"text">>,
text = iolist_to_binary(["Weather Summary:\n" | lists:reverse(Summary)]),
mime_type = <<"text/plain">>
}.
query_weather_tool(#{<<"city">> := City, <<"metric">> := Metric}) ->
{ok, State} = gen_server:call(?SERVER, get_state),
case maps:get(City, State#state.weather_data, undefined) of
undefined ->
#mcp_content{
type = <<"text">>,
text = <<"No data available for ", City/binary>>,
mime_type = <<"text/plain">>
};
Reading ->
Value = case Metric of
<<"temperature">> -> maps:get(temperature, Reading);
<<"humidity">> -> maps:get(humidity, Reading);
<<"pressure">> -> maps:get(pressure, Reading)
end,
#mcp_content{
type = <<"text">>,
text = iolist_to_binary(io_lib:format("~s in ~s: ~.2f",
[Metric, City, Value])),
mime_type = <<"text/plain">>
}
end.
compare_weather_tool(#{<<"cities">> := Cities}) ->
{ok, State} = gen_server:call(?SERVER, get_state),
Comparisons = lists:map(fun(City) ->
case maps:get(City, State#state.weather_data, undefined) of
undefined ->
{City, undefined};
Reading ->
{City, maps:get(temperature, Reading)}
end
end, Cities),
ValidComparisons = [{C, T} || {C, T} <- Comparisons, T =/= undefined],
case ValidComparisons of
[] ->
#mcp_content{
type = <<"text">>,
text = <<"No valid weather data for comparison">>,
mime_type = <<"text/plain">>
};
_ ->
Sorted = lists:sort(fun({_, T1}, {_, T2}) -> T1 > T2 end, ValidComparisons),
ComparisonText = format_comparison(Sorted),
#mcp_content{
type = <<"text">>,
text = ComparisonText,
mime_type = <<"text/plain">>
}
end.
configure_alert_tool(Args) ->
Email = maps:get(<<"email">>, Args),
Thresholds = #{
temperature_max => maps:get(<<"temperature_max">>, Args, undefined),
temperature_min => maps:get(<<"temperature_min">>, Args, undefined)
},
ok = add_alert_subscriber(Email, Thresholds),
#mcp_content{
type = <<"text">>,
text = <<"Alert configured for ", Email/binary>>,
mime_type = <<"text/plain">>
}.
weather_report_prompt(#{<<"city">> := City} = Args) ->
Format = maps:get(<<"format">>, Args, <<"brief">>),
{ok, State} = gen_server:call(?SERVER, get_state),
case maps:get(City, State#state.weather_data, undefined) of
undefined ->
[#{
<<"role">> => <<"assistant">>,
<<"content">> => <<"I don't have current weather data for ", City/binary,
". Please check if the city name is correct.">>
}];
Reading ->
ReportText = case Format of
<<"detailed">> -> format_detailed_report(City, Reading);
_ -> format_brief_report(City, Reading)
end,
[#{
<<"role">> => <<"assistant">>,
<<"content">> => ReportText
}]
end.
travel_advice_prompt(#{<<"destination">> := Destination, <<"duration">> := Duration}) ->
{ok, State} = gen_server:call(?SERVER, get_state),
case maps:get(Destination, State#state.weather_data, undefined) of
undefined ->
[#{
<<"role">> => <<"assistant">>,
<<"content">> => <<"I don't have weather data for ", Destination/binary,
". Please check the destination name.">>
}];
Reading ->
Advice = generate_travel_advice(Destination, Duration, Reading),
[#{
<<"role">> => <<"assistant">>,
<<"content">> => Advice
}]
end.
%%====================================================================
%% Internal functions - Formatting
%%====================================================================
format_weather_content(City, Reading) ->
Text = io_lib:format(
"Weather in ~s:\n"
"Temperature: ~.1f°C\n"
"Humidity: ~.0f%\n"
"Pressure: ~.1f hPa\n"
"Conditions: ~s\n",
[City,
maps:get(temperature, Reading),
maps:get(humidity, Reading),
maps:get(pressure, Reading),
maps:get(conditions, Reading)]
),
#mcp_content{
type = <<"text">>,
text = iolist_to_binary(Text),
mime_type = <<"text/plain">>
}.
format_comparison(Sorted) ->
Lines = lists:map(fun({City, Temp}) ->
io_lib:format("~s: ~.1f°C", [City, Temp])
end, Sorted),
iolist_to_binary(string:join(Lines, "\n")).
format_brief_report(City, Reading) ->
iolist_to_binary(io_lib:format(
"Current weather in ~s: ~.1f°C, ~s",
[City, maps:get(temperature, Reading), maps:get(conditions, Reading)]
)).
format_detailed_report(City, Reading) ->
iolist_to_binary(io_lib:format(
"Detailed Weather Report for ~s:\n"
"=====================================\n"
"Temperature: ~.1f°C\n"
"Humidity: ~.0f%\n"
"Atmospheric Pressure: ~.1f hPa\n"
"Current Conditions: ~s\n"
"Last Updated: ~s\n",
[City,
maps:get(temperature, Reading),
maps:get(humidity, Reading),
maps:get(pressure, Reading),
maps:get(conditions, Reading),
format_timestamp(maps:get(timestamp, Reading))]
)).
generate_travel_advice(Destination, Duration, Reading) ->
Temp = maps:get(temperature, Reading),
Conditions = maps:get(conditions, Reading),
ClothingAdvice = case Temp of
T when T < 10.0 -> "Pack warm clothes including jacket and layers.";
T when T < 20.0 -> "Bring light jacket and long sleeves.";
T when T < 30.0 -> "Light clothing recommended, bring sunscreen.";
_ -> "Very hot weather, pack light breathable clothes and sun protection."
end,
ConditionsAdvice = case Conditions of
<<"rainy">> -> " Don't forget an umbrella or raincoat.";
<<"sunny">> -> " Sunglasses and hat recommended.";
_ -> ""
end,
iolist_to_binary(io_lib:format(
"Travel advice for ~s (~s days):\n"
"Current temperature: ~.1f°C with ~s conditions.\n"
"~s~s",
[Destination, Duration, Temp, Conditions, ClothingAdvice, ConditionsAdvice]
)).
format_timestamp({MegaSecs, Secs, _MicroSecs}) ->
DateTime = calendar:now_to_datetime({MegaSecs, Secs, 0}),
{{Y, M, D}, {H, Mi, S}} = DateTime,
io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B",
[Y, M, D, H, Mi, S]).
%%====================================================================
%% gen_server API for handlers
%%====================================================================
handle_call({get_weather, City}, _From, State) ->
case maps:get(City, State#state.weather_data, undefined) of
undefined -> {reply, {error, not_found}, State};
Reading -> {reply, {ok, Reading}, State}
end;
handle_call(get_state, _From, State) ->
{reply, {ok, State}, State}.