stdio Transport

View Source

The stdio transport enables MCP communication over stdin/stdout, which is the transport used by Claude Desktop and other MCP clients that spawn server processes.

Overview

Unlike HTTP transport, stdio transport:

  • Uses newline-delimited JSON-RPC messages.
  • Runs as a child process spawned by the MCP client.
  • Is ideal for local integrations (no network overhead).
  • Is the primary transport for Claude Desktop.

The same registries (tools, resources, prompts, resource templates, completions, tasks) work over stdio. Tool handlers may be arity 1 or arity 2 ((Args, Ctx)) — the emit_progress and cancellation hooks in Ctx interleave on stdout in stdio just like they do on the SSE channel for HTTP. See the Tools guide for the handler shape.

Quick Start

1. Create an Escript

#!/usr/bin/env escript
%%! -pa _build/default/lib/*/ebin

-module(my_mcp_server).
-mode(compile).

main(_Args) ->
    %% Start the application
    application:ensure_all_started(barrel_mcp),
    barrel_mcp_registry:wait_for_ready(),

    %% Register your tools
    barrel_mcp:reg_tool(<<"hello">>, my_mcp_server, hello, #{
        description => <<"Say hello">>,
        input_schema => #{
            <<"type">> => <<"object">>,
            <<"properties">> => #{
                <<"name">> => #{
                    <<"type">> => <<"string">>,
                    <<"description">> => <<"Name to greet">>
                }
            }
        }
    }),

    %% Start stdio server (blocks until stdin closes)
    barrel_mcp:start_stdio().

hello(Args) ->
    Name = maps:get(<<"name">>, Args, <<"World">>),
    <<"Hello, ", Name/binary, "!">>.

2. Make it Executable

chmod +x my_mcp_server

3. Configure Claude Desktop

Edit your claude_desktop_config.json:

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json Linux: ~/.config/claude/claude_desktop_config.json

{
  "mcpServers": {
    "my-erlang-server": {
      "command": "/absolute/path/to/my_mcp_server",
      "args": []
    }
  }
}

4. Restart Claude Desktop

After saving the config, restart Claude Desktop. Your MCP server will be available.

Blocking vs Supervised Mode

Blocking Mode

Use barrel_mcp:start_stdio/0 when you want the server to run in the current process:

main(_Args) ->
    setup_tools(),
    barrel_mcp:start_stdio().  %% Blocks here

This is ideal for escripts and simple applications.

Supervised Mode

Use barrel_mcp:start_stdio_link/0 when you want the server supervised:

-module(my_app_sup).
-behaviour(supervisor).
-export([init/1]).

init([]) ->
    %% Ensure tools are registered first
    setup_tools(),

    Children = [
        #{id => mcp_stdio,
          start => {barrel_mcp, start_stdio_link, []},
          restart => permanent,
          type => worker}
    ],
    {ok, {#{strategy => one_for_one}, Children}}.

Protocol Details

Message Format

Each message is a single line of JSON (newline-delimited):

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}\n

Supported Methods

The stdio transport supports all MCP methods:

  • initialize / initialized - Connection lifecycle
  • tools/list / tools/call - Tool operations
  • resources/list / resources/read - Resource operations
  • prompts/list / prompts/get - Prompt operations
  • ping - Keep-alive

Notifications

MCP notifications (methods without id) don't receive responses:

{"jsonrpc":"2.0","method":"notifications/initialized"}\n

Building Releases

For production use, build an Erlang release instead of an escript.

Using rebar3 Release

  1. Add to rebar.config:
{relx, [
    {release, {my_mcp_server, "1.0.0"}, [my_app, barrel_mcp]},
    {mode, prod},
    {extended_start_script, true}
]}.
  1. Create your main module:
-module(my_mcp_main).
-export([start/0]).

start() ->
    %% Called when release starts
    setup_tools(),
    barrel_mcp:start_stdio().
  1. Configure your app to call this on start:
%% In your application module
start(_Type, _Args) ->
    %% Start your supervisor
    {ok, Sup} = my_app_sup:start_link(),

    %% If running in MCP mode, start stdio
    case application:get_env(my_app, mcp_mode, false) of
        true -> spawn(fun my_mcp_main:start/0);
        false -> ok
    end,

    {ok, Sup}.
  1. Build and run:
rebar3 release
_build/default/rel/my_mcp_server/bin/my_mcp_server foreground

Debugging

Testing Locally

You can test your stdio server manually:

# Start your server
./my_mcp_server

# Then type JSON-RPC messages (each on one line):
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"hello","arguments":{"name":"Erlang"}}}

Logging

Since stdout is used for MCP responses, use stderr for debugging:

debug(Msg) ->
    io:format(standard_error, "[DEBUG] ~s~n", [Msg]).

Or use Erlang's logger to a file:

%% Configure in your app startup
logger:add_handler(file_handler, logger_std_h, #{
    config => #{file => "/tmp/mcp_server.log"}
}).

Common Issues

Server not appearing in Claude Desktop:

  • Check config file path and JSON syntax
  • Use absolute path to executable
  • Restart Claude Desktop after config changes

"Command not found" errors:

  • Ensure the executable has the shebang line
  • Check file permissions (chmod +x)
  • Use absolute paths in config

No responses:

  • Ensure all tools are registered before start_stdio/0
  • Check stderr for errors

Environment Variables

Claude Desktop passes environment variables to your server:

%% Access them in your code
HomeDir = os:getenv("HOME"),
PathVar = os:getenv("PATH").

You can also configure environment in claude_desktop_config.json:

{
  "mcpServers": {
    "my-server": {
      "command": "/path/to/my_mcp_server",
      "args": [],
      "env": {
        "MY_CONFIG": "/path/to/config.json",
        "DEBUG": "true"
      }
    }
  }
}

Working Directory

The working directory is typically the user's home directory or where Claude Desktop was launched. To ensure consistent behavior:

%% Set a known working directory
file:set_cwd("/path/to/my/app"),

%% Or use absolute paths for all file operations
ConfigPath = filename:join([os:getenv("HOME"), ".config", "myapp"]).

See Also