defmodule Phantom.Stdio do @moduledoc """ 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 `:default` Logger handler at runtime. Defaults to `:stderr`. Set to a file path string to log to a file, or `false` to manage Logger configuration yourself (see below). > #### Logger and stdout {: .warning} > > 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: > > ```elixir > # 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 end Add to your `mix.exs`: def project do [ # ... escript: [main_module: MyApp.CLI, app: nil] ] end Build: mix escript.build # produces ./my_app > #### PATH must include Erlang and Elixir {: .warning} > > 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` ```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`: ```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` """ alias Phantom.Cache alias Phantom.Session def child_spec(opts) do %{ id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, restart: :transient } end def start_link(opts) do :proc_lib.start_link(__MODULE__, :init_stdio, [opts]) end @doc false def init_stdio(opts) do router = Keyword.fetch!(opts, :router) input = Keyword.get(opts, :input, :stdio) output = Keyword.get(opts, :output, :stdio) timeout = Keyword.get(opts, :session_timeout, :infinity) configure_logger(Keyword.get(opts, :log, :stderr)) if not Cache.initialized?(router), do: Cache.register(router) Phantom.Tracker.update_session_meta(nil, %{stdio_output: output}) session = Session.new(nil, router: router, pid: self(), close_after_complete: false ) case router.connect(session, %{headers: [], params: %{}}) do {:ok, session} -> :telemetry.execute( [:phantom, :stdio, :connect], %{}, %{session: session, router: router} ) :proc_lib.init_ack({:ok, self()}) Session.start_loop( session: session, timeout: timeout, stream_fun: stream_fun(output), continue_fun: continue_fun(input) ) {:error, reason} -> :proc_lib.init_ack({:error, reason}) exit(:normal) {status, _} when status in [:unauthorized, 401, :forbidden, 403] -> :proc_lib.init_ack({:error, :unauthorized}) exit(:normal) end end defp continue_fun(input) do fn state -> parent = self() spawn_link(fn -> Process.set_label({__MODULE__, :reader}) read_loop(input, parent) end) state end end defp read_loop(input, parent) do case IO.read(input, :line) do :eof -> send(parent, {:phantom_reader_closed, :eof}) {:error, reason} -> send(parent, {:phantom_reader_closed, reason}) line when is_binary(line) -> line = String.trim(line) if line != "" do case JSON.decode(line) do {:ok, request} when is_list(request) -> send(parent, {:phantom_dispatch, request}) {:ok, request} when is_map(request) -> send(parent, {:phantom_dispatch, [request]}) {:error, _} -> send(parent, {:phantom_dispatch_error, :parse_error}) end end read_loop(input, parent) end end defp configure_logger(false), do: :ok defp configure_logger(:stderr) do redirect_default_handler({:device, :stderr}) end defp configure_logger(path) when is_binary(path) do redirect_default_handler({:file, String.to_charlist(path)}) end defp redirect_default_handler(type) do with {:ok, config} <- :logger.get_handler_config(:default) do :logger.remove_handler(:default) :logger.add_handler(:default, :logger_std_h, %{ config: Map.put(config.config, :type, type), formatter: config.formatter }) end end defp stream_fun(output) do fn state, _id, "closed", _payload -> state state, _id, _event, payload when is_map(payload) and map_size(payload) == 0 -> IO.write(output, JSON.encode!(%{jsonrpc: "2.0", result: %{}}) <> "\n") state state, _id, _event, %{} = payload -> IO.write(output, JSON.encode!(payload) <> "\n") state state, _id, _event, _payload -> state end end end