Phantom.Stdio (phantom_mcp v0.4.4)

Copy Markdown View Source

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

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
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

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

Summary

Functions

child_spec(opts)

start_link(opts)