Packages

Erlang Model Context Protocol (MCP) SDK

Current section

Files

Jump to
erlmcp src erlmcp_transport_stdio.erl
Raw

src/erlmcp_transport_stdio.erl

-module(erlmcp_transport_stdio).
-behaviour(gen_server).
%% API exports
-export([send/2, start_link/1, close/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
owner :: pid(),
reader :: pid() | undefined,
buffer = <<>> :: binary(),
test_mode = false :: boolean()
}).
-type state() :: #state{}.
%%====================================================================
%% API Functions
%%====================================================================
-spec start_link(pid()) -> {ok, pid()} | {error, term()}.
start_link(Owner) when is_pid(Owner) ->
gen_server:start_link(?MODULE, [Owner], []).
-spec send(pid() | term(), iodata()) -> ok | {error, term()}.
send(_TransportState, Message) ->
try
% Message is already JSON-encoded binary/iolist
case is_binary(Message) of
true ->
io:format("~s~n", [Message]);
false ->
io:format("~s~n", [iolist_to_binary(Message)])
end,
ok
catch
error:Reason ->
{error, {io_error, Reason}}
end.
-spec close(pid() | term()) -> ok.
close(Pid) when is_pid(Pid) ->
gen_server:stop(Pid);
close(_) ->
ok.
%%====================================================================
%% gen_server callbacks
%%====================================================================
-spec init([pid()]) -> {ok, state()}.
init([Owner]) ->
process_flag(trap_exit, true),
% Check if we're in test mode by looking at the process dictionary
% or checking if stdin is available
TestMode = is_test_environment(),
State = #state{
owner = Owner,
test_mode = TestMode
},
% Only start the reader if we're not in test mode
case TestMode of
true ->
% In test mode, don't start a reader process
{ok, State};
false ->
ReaderPid = spawn_link(fun() -> read_loop(self(), Owner) end),
{ok, State#state{reader = ReaderPid}}
end.
-spec handle_call(term(), {pid(), term()}, state()) -> {reply, term(), state()}.
handle_call(get_state, _From, State) ->
{reply, {ok, State}, State};
handle_call({simulate_input, Line}, _From, #state{test_mode = true, owner = Owner} = State) ->
% Allow tests to simulate input
Owner ! {transport_message, Line},
{reply, ok, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
-spec handle_cast(term(), state()) -> {noreply, state()}.
handle_cast(_Msg, State) ->
{noreply, State}.
-spec handle_info(term(), state()) -> {noreply, state()} | {stop, term(), state()}.
handle_info({line, Line}, #state{owner = Owner} = State) ->
Owner ! {transport_message, Line},
{noreply, State};
handle_info({'EXIT', Pid, Reason}, #state{reader = Pid, test_mode = false} = State) ->
case Reason of
normal ->
% In normal mode, if reader dies normally, we should probably stop too
% but let's be more graceful about it
logger:info("Reader process finished normally"),
{noreply, State#state{reader = undefined}};
_ ->
logger:error("Reader process died: ~p", [Reason]),
{stop, {reader_died, Reason}, State}
end;
handle_info({'EXIT', Pid, Reason}, #state{owner = Pid} = State) ->
{stop, {owner_died, Reason}, State};
handle_info(_Info, State) ->
{noreply, State}.
-spec terminate(term(), state()) -> ok.
terminate(_Reason, #state{reader = Reader}) when is_pid(Reader) ->
exit(Reader, shutdown),
ok;
terminate(_Reason, _State) ->
ok.
-spec code_change(term(), state(), term()) -> {ok, state()}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%====================================================================
%% Internal Functions
%%====================================================================
-spec is_test_environment() -> boolean().
is_test_environment() ->
% Check various indicators that we're in a test environment
case get(test_mode) of
true -> true;
_ ->
% Check if EUnit is running
case whereis(eunit_proc) of
undefined ->
% Check if we can read from stdin without blocking
case stdin_available() of
true -> false;
false -> true % Assume test mode if stdin not available
end;
_ ->
true % EUnit is running
end
end.
-spec stdin_available() -> boolean().
stdin_available() ->
% Try to check if stdin is available without blocking
% This is a heuristic - in a real application you might want more sophisticated detection
case io:get_chars("", 0) of
eof -> false;
{error, _} -> false;
_ -> true
end.
-spec read_loop(pid(), pid()) -> no_return().
read_loop(Parent, Owner) ->
case io:get_line("") of
eof ->
logger:info("EOF received, stopping reader"),
exit(normal);
{error, Reason} ->
logger:error("Read error: ~p", [Reason]),
exit({read_error, Reason});
Line when is_list(Line) ->
BinaryLine = iolist_to_binary(Line),
CleanLine = trim_line(BinaryLine),
case byte_size(CleanLine) of
0 ->
ok; % Skip empty lines
_ ->
Parent ! {line, CleanLine}
end,
read_loop(Parent, Owner);
Line when is_binary(Line) ->
CleanLine = trim_line(Line),
case byte_size(CleanLine) of
0 ->
ok; % Skip empty lines
_ ->
Parent ! {line, CleanLine}
end,
read_loop(Parent, Owner)
end.
-spec trim_line(binary()) -> binary().
trim_line(Line) ->
% Remove trailing newline and carriage return
Size = byte_size(Line),
case Line of
<<Content:Size/binary>> when Size > 0 ->
trim_end(Content);
_ ->
<<>>
end.
-spec trim_end(binary()) -> binary().
trim_end(<<>>) ->
<<>>;
trim_end(Binary) ->
Size = byte_size(Binary),
case Binary of
<<Content:(Size-2)/binary, "\r\n">> ->
trim_end(Content);
<<Content:(Size-1)/binary, "\n">> ->
trim_end(Content);
<<Content:(Size-1)/binary, "\r">> ->
trim_end(Content);
_ ->
Binary
end.