%%%------------------------------------------------------------------- %%% @author Benoit Chesneau %%% @copyright 2024-2026 Benoit Chesneau %%% @doc stdio transport for MCP protocol. %%% %%% This module implements the stdio transport for the Model Context Protocol, %%% enabling communication with MCP clients like Claude Desktop that use %%% stdin/stdout for message passing. %%% %%% == Usage Modes == %%% %%% %%% %%% == Protocol == %%% %%% The stdio transport uses newline-delimited JSON-RPC 2.0 messages: %%% %%% %%% %%% == Example: Blocking Mode == %%% %%% ``` %%% %% In your escript or application main function: %%% main(_Args) -> %%% application:ensure_all_started(barrel_mcp), %%% barrel_mcp_registry:wait_for_ready(), %%% %%% %% Register your tools %%% barrel_mcp:reg_tool(<<"my_tool">>, my_module, my_function, #{ %%% description => <<"My tool description">> %%% }), %%% %%% %% Start stdio server (blocks until stdin closes) %%% barrel_mcp_stdio:start(). %%% ''' %%% %%% == Example: Supervised Mode == %%% %%% ``` %%% %% In your supervisor init/1: %%% init(_Args) -> %%% Children = [ %%% #{id => mcp_stdio, %%% start => {barrel_mcp_stdio, start_link, []}, %%% restart => permanent, %%% type => worker} %%% ], %%% {ok, {#{strategy => one_for_one}, Children}}. %%% ''' %%% %%% == Claude Desktop Integration == %%% %%% To use with Claude Desktop, configure `claude_desktop_config.json': %%% %%% ``` %%% { %%% "mcpServers": { %%% "my-erlang-server": { %%% "command": "/path/to/your/escript", %%% "args": [] %%% } %%% } %%% } %%% ''' %%% %%% The config file is located at: %%% %%% %%% %%% @see barrel_mcp %%% @see barrel_mcp_protocol %%% @end %%%------------------------------------------------------------------- -module(barrel_mcp_stdio). %% API -export([ start/0, start_link/0 ]). %% gen_server callbacks (for supervised mode) -behaviour(gen_server). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). -record(state, {}). %%==================================================================== %% API %%==================================================================== %% @doc Start the stdio server in blocking mode. %% %% This function starts the MCP stdio server in the current process. %% It reads JSON-RPC messages from stdin, processes them through the %% MCP protocol handler, and writes responses to stdout. %% %% Important: This function blocks until stdin is closed (EOF). %% It is typically called as the last line of an escript main function %% or from a dedicated process. %% %% == Example == %% %% ``` %% -module(my_mcp_server). %% -export([main/1]). %% %% main(_Args) -> %% application:ensure_all_started(barrel_mcp), %% barrel_mcp_registry:wait_for_ready(), %% %% %% Register tools before starting %% barrel_mcp:reg_tool(<<"echo">>, ?MODULE, echo, #{ %% description => <<"Echo back the input">> %% }), %% %% %% This blocks until stdin closes %% barrel_mcp_stdio:start(). %% %% echo(Args) -> %% maps:get(<<"message">>, Args, <<>>). %% ''' %% %% @returns `ok' when stdin closes -spec start() -> ok. start() -> %% Set binary mode for stdin/stdout ok = io:setopts(standard_io, [binary, {encoding, latin1}]), loop(). %% @doc Start the stdio server as a supervised gen_server. %% %% This function starts the MCP stdio server as a gen_server process %% that can be supervised. Unlike {@link start/0}, this returns %% immediately after spawning the server process. %% %% The server registers locally as `barrel_mcp_stdio'. %% %% == Example == %% %% ``` %% %% In your supervisor: %% init([]) -> %% SupFlags = #{strategy => one_for_one, intensity => 5, period => 10}, %% Children = [ %% #{id => mcp_stdio, %% start => {barrel_mcp_stdio, start_link, []}, %% restart => permanent, %% shutdown => 5000, %% type => worker, %% modules => [barrel_mcp_stdio]} %% ], %% {ok, {SupFlags, Children}}. %% ''' %% %% @returns `{ok, Pid}' on success, or `{error, Reason}' on failure -spec start_link() -> {ok, pid()} | {error, term()}. start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). %%==================================================================== %% gen_server callbacks %%==================================================================== init([]) -> ok = io:setopts(standard_io, [binary, {encoding, latin1}]), %% Schedule first read self() ! read_line, {ok, #state{}}. handle_call(_Request, _From, State) -> {reply, ok, State}. handle_cast(_Msg, State) -> {noreply, State}. handle_info(read_line, State) -> case io:get_line(standard_io, '') of eof -> {stop, normal, State}; {error, _} -> {stop, normal, State}; Line -> handle_line(Line), self() ! read_line, {noreply, State} end; handle_info(_Info, State) -> {noreply, State}. terminate(_Reason, _State) -> ok. %%==================================================================== %% Internal Functions %%==================================================================== loop() -> case io:get_line(standard_io, '') of eof -> ok; {error, _} -> ok; Line -> handle_line(Line), loop() end. handle_line(Line) when is_binary(Line) -> %% Trim whitespace TrimmedLine = string:trim(Line), case TrimmedLine of <<>> -> ok; _ -> process_request(TrimmedLine) end; handle_line(Line) when is_list(Line) -> handle_line(list_to_binary(Line)). process_request(Line) -> case barrel_mcp_protocol:decode(Line) of {ok, Request} -> case barrel_mcp_protocol:handle(Request) of no_response -> %% Notification - no response needed ok; {async, Plan} -> %% tools/call returns an async plan; drive it %% synchronously here. stdio is single-threaded; %% the worker reports back via mailbox. Response = barrel_mcp_protocol:drive_async_plan( Plan, 60000), send_response(Response); Response -> send_response(Response) end; {error, parse_error} -> ErrorResponse = barrel_mcp_protocol:error_response( null, -32700, <<"Parse error">> ), send_response(ErrorResponse) end. send_response(Response) -> ResponseJson = barrel_mcp_protocol:encode(Response), io:format("~s~n", [ResponseJson]).