MCP server transport over stdin/stdout.
This module implements the MCP stdio transport, allowing applications to expose an MCP server over stdin/stdout for local clients like Claude Desktop without needing an HTTP server.
Messages are newline-delimited JSON, one JSON-RPC message per line.
Usage
Add to your supervision tree:
children = [
{Phantom.Stdio, router: MyApp.MCP.Router}
]Options
:router- The MCP router module (required):input- Input IO device (default::stdio):output- Output IO device (default::stdio):session_timeout- Session inactivity timeout (default::infinity):log- Where to redirect the:defaultLogger handler at runtime. Defaults to:stderr. Set to a file path string to log to a file, orfalseto manage Logger configuration yourself (see below).
Logger and stdout
Elixir's default Logger handler writes to stdout, which would corrupt
the JSON-RPC stream. Phantom.Stdio automatically redirects it to
stderr at runtime.
This only affects the :default handler. If you have added custom
Logger handlers that write to stdout, you must redirect those yourself.
If you prefer to configure Logger through application config instead,
set log: false and redirect the default handler in your config:
# config/runtime.exs
config :logger, :default_handler,
config: [type: {:device, :standard_error}]To send logs to the MCP client, use Phantom.ClientLogger — it sends
notifications/message notifications and works identically across
stdio and HTTP transports.
Building an escript
For clients with short startup timeouts (e.g. Codex), an escript is recommended. Escripts are pre-compiled binaries that start instantly, avoiding compilation delays that can cause the client to kill the server.
Create an entry point module:
defmodule MyApp.CLI do
def main(_args) do
# Redirect Logger to stderr BEFORE anything else.
# The escript starts before OTP applications are loaded,
# so use Erlang's logger API and formatter directly.
:logger.remove_handler(:default)
:logger.add_handler(:default, :logger_std_h, %{
config: %{type: {:device, :standard_error}},
formatter:
{:logger_formatter, %{template: [:time, " [", :level, "] ", :msg, "\n"]}}
})
Application.ensure_all_started(:telemetry)
{:ok, _} =
Supervisor.start_link(
[{Phantom.Stdio, router: MyApp.MCP.Router, log: false}],
strategy: :one_for_one
)
Process.sleep(:infinity)
end
endAdd to your mix.exs:
def project do
[
# ...
escript: [main_module: MyApp.CLI, app: nil]
]
endBuild:
mix escript.build
# produces ./my_appPATH must include Erlang and Elixir
Escripts are compiled BEAM bytecode and require the Erlang runtime
to execute. The PATH environment variable must include the
directories for both erl and elixir. If you use a version
manager like mise or asdf, ensure the shims or install paths
are included.
Client configuration
Claude Desktop
Find your claude_desktop_config.json:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"my_app": {
"command": "/path/to/my_app",
"env": {
"PATH": "/path/to/elixir/bin:/path/to/erlang/bin:/usr/local/bin:/usr/bin:/bin"
}
}
}
}Codex
Add to ~/.codex/config.toml:
[mcp_servers.my-app]
command = "/path/to/my_app"
env.PATH = "/path/to/elixir/bin:/path/to/erlang/bin:/usr/local/bin:/usr/bin:/bin"Cursor
Configure in Cursor's MCP settings with the same command as above.
Telemetry
Telemetry is provided with these events:
[:phantom, :stdio, :connect]with meta:~w[session router]a[:phantom, :stdio, :terminate]with meta:~w[session router reason]a[:phantom, :stdio, :exception]with meta:~w[session router exception stacktrace request]a