Current section
Files
Jump to
Current section
Files
lib/codex/mcp/transport/stdio.ex
defmodule Codex.MCP.Transport.Stdio do
@moduledoc """
Runs MCP servers over stdio using a managed subprocess.
"""
use GenServer
require Logger
alias Codex.IO.Buffer
alias Codex.IO.Transport.Erlexec, as: IOTransportErlexec
alias Codex.MCP.Protocol
alias Codex.Runtime.Erlexec, as: RuntimeErlexec
defmodule State do
@moduledoc false
defstruct [
:transport_mod,
:transport,
:transport_ref,
:messages,
:waiters
]
end
@type t :: pid()
@doc "Starts a stdio transport process."
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) when is_list(opts) do
GenServer.start_link(__MODULE__, opts)
end
@doc "Sends a JSON-RPC message to the MCP server."
@spec send(t(), map()) :: :ok | {:error, term()}
def send(pid, message) when is_pid(pid) and is_map(message) do
GenServer.call(pid, {:send, message})
end
@doc "Receives the next JSON-RPC message from the MCP server."
@spec recv(t(), pos_integer()) :: {:ok, map()} | {:error, term()}
def recv(pid, timeout_ms) when is_pid(pid) and is_integer(timeout_ms) and timeout_ms > 0 do
GenServer.call(pid, {:recv, timeout_ms}, timeout_ms + 1_000)
end
@impl true
def init(opts) do
{transport_mod, transport_opts} = resolve_transport(opts)
transport_ref = make_ref()
with :ok <- maybe_ensure_erlexec(transport_mod),
{:ok, command} <- build_command(opts),
{:ok, transport} <-
transport_mod.start_link(
[
command: command,
cwd: Keyword.get(opts, :cwd),
env: build_env(opts),
subscriber: {self(), transport_ref}
] ++ transport_opts
) do
{:ok,
%State{
transport_mod: transport_mod,
transport: transport,
transport_ref: transport_ref,
messages: :queue.new(),
waiters: []
}}
else
{:error, _} = error -> error
other -> {:stop, other}
end
end
@impl true
def handle_call({:send, message}, _from, %State{} = state) do
encoded = Protocol.encode_message(message)
case state.transport_mod.send(state.transport, encoded) do
:ok -> {:reply, :ok, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call({:recv, timeout_ms}, from, %State{} = state) do
case pop_message(state) do
{:ok, message, next_state} ->
{:reply, {:ok, message}, next_state}
:empty ->
timer_ref = Process.send_after(self(), {:recv_timeout, from}, timeout_ms)
{:noreply, %{state | waiters: state.waiters ++ [{from, timer_ref}]}}
end
end
@impl true
def handle_info(
{:codex_io_transport, ref, {:message, line}},
%State{transport_ref: ref} = state
) do
state =
case Buffer.decode_line(line) do
{:ok, msg} ->
state
|> enqueue_messages([msg])
|> flush_waiters()
{:non_json, raw} ->
Logger.debug("Ignoring non-JSON MCP output: #{inspect(raw)}")
state
end
{:noreply, state}
end
def handle_info(
{:codex_io_transport, ref, {:stderr, chunk}},
%State{transport_ref: ref} = state
) do
text = IO.iodata_to_binary(chunk)
Logger.debug("MCP stderr: #{String.trim(text)}")
{:noreply, state}
end
def handle_info(
{:codex_io_transport, ref, {:error, reason}},
%State{transport_ref: ref} = state
) do
Logger.debug("MCP transport error: #{inspect(reason)}")
{:noreply, state}
end
def handle_info({:codex_io_transport, ref, {:exit, reason}}, %State{transport_ref: ref} = state) do
Logger.debug("MCP subprocess exited: #{inspect(reason)}")
state =
state
|> drain_waiters({:error, :closed})
{:stop, :normal, state}
end
def handle_info({:recv_timeout, from}, %State{} = state) do
case pop_waiter(state.waiters, from) do
{nil, _waiters} ->
{:noreply, state}
{timer_ref, waiters} ->
_ = Process.cancel_timer(timer_ref)
GenServer.reply(from, {:error, :timeout})
{:noreply, %{state | waiters: waiters}}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl true
def terminate(_reason, %State{} = state) do
_ = drain_waiters(state, {:error, :closed})
_ = state.transport_mod.force_close(state.transport)
:ok
end
defp pop_message(%State{} = state) do
case :queue.out(state.messages) do
{{:value, message}, messages} -> {:ok, message, %{state | messages: messages}}
{:empty, _} -> :empty
end
end
defp enqueue_messages(%State{} = state, messages) do
updated = Enum.reduce(messages, state.messages, &:queue.in/2)
%{state | messages: updated}
end
defp flush_waiters(%State{} = state) do
case {state.waiters, pop_message(state)} do
{[], _} ->
state
{[_ | _], :empty} ->
state
{[{from, timer_ref} | rest], {:ok, message, next_state}} ->
_ = Process.cancel_timer(timer_ref)
GenServer.reply(from, {:ok, message})
%{next_state | waiters: rest}
|> flush_waiters()
end
end
defp drain_waiters(%State{} = state, reply) do
Enum.each(state.waiters, fn {from, timer_ref} ->
_ = Process.cancel_timer(timer_ref)
GenServer.reply(from, reply)
end)
%{state | waiters: []}
end
defp pop_waiter(waiters, target) do
{match, rest} =
Enum.reduce(waiters, {nil, []}, fn {from, ref}, {found, acc} ->
if from == target do
{ref, acc}
else
{found, acc ++ [{from, ref}]}
end
end)
{match, rest}
end
defp build_command(opts) do
case Keyword.get(opts, :command) do
nil ->
{:error, :missing_command}
command when is_binary(command) ->
args = Keyword.get(opts, :args, [])
argv = [command | List.wrap(args)]
{:ok, Enum.map(argv, &to_charlist/1)}
argv when is_list(argv) ->
{:ok, Enum.map(argv, &to_charlist/1)}
end
end
defp resolve_transport(opts) do
case Keyword.fetch(opts, :transport) do
{:ok, {module, transport_opts}} when is_atom(module) and is_list(transport_opts) ->
{module, transport_opts}
{:ok, module} when is_atom(module) ->
{module, []}
{:ok, other} ->
raise ArgumentError, "invalid transport option: #{inspect(other)}"
:error ->
case Keyword.get(opts, :subprocess_mod) do
nil ->
{IOTransportErlexec, []}
module when is_atom(module) ->
{module, Keyword.get(opts, :subprocess_opts, [])}
other ->
raise ArgumentError, "invalid subprocess_mod option: #{inspect(other)}"
end
end
end
defp maybe_ensure_erlexec(IOTransportErlexec), do: RuntimeErlexec.ensure_started()
defp maybe_ensure_erlexec(_other), do: :ok
defp build_env(opts) do
env_vars = Keyword.get(opts, :env_vars, [])
extra_env = Keyword.get(opts, :env, %{})
default_env =
default_env_vars()
|> Enum.reduce(%{}, fn key, acc ->
case System.get_env(key) do
nil -> acc
value -> Map.put(acc, key, value)
end
end)
from_env_vars =
env_vars
|> Enum.reduce(%{}, fn key, acc ->
case System.get_env(key) do
nil -> acc
value -> Map.put(acc, key, value)
end
end)
extra_env =
case extra_env do
%{} = map -> map
list when is_list(list) -> Map.new(list)
_ -> %{}
end
default_env
|> Map.merge(from_env_vars)
|> Map.merge(extra_env)
|> Enum.map(fn {key, value} -> {key, value} end)
end
defp default_env_vars do
case :os.type() do
{:win32, _} ->
[
"PATH",
"PATHEXT",
"COMSPEC",
"SYSTEMROOT",
"SYSTEMDRIVE",
"USERNAME",
"USERDOMAIN",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PROGRAMDATA",
"LOCALAPPDATA",
"APPDATA",
"TEMP",
"TMP",
"POWERSHELL",
"PWSH"
]
_ ->
[
"HOME",
"LOGNAME",
"PATH",
"SHELL",
"USER",
"__CF_USER_TEXT_ENCODING",
"LANG",
"LC_ALL",
"TERM",
"TMPDIR",
"TZ"
]
end
end
end