Skip to main content

lib/noizu/mcp/transport/stdio.ex

defmodule Noizu.MCP.Transport.Stdio do
  @moduledoc """
  stdio server transport: newline-delimited JSON-RPC on stdin/stdout.

  Started automatically by the server supervisor when `transport: :stdio`.
  Creates the single implicit session, reads stdin line-by-line, and **owns
  stdout exclusively** for protocol traffic.

  > #### Logging and stdout {: .warning}
  >
  > Anything written to stdout that is not a JSON-RPC message corrupts the
  > stream and breaks the server in MCP clients. This transport reconfigures
  > the default `Logger` handler to write to **stderr** at startup. Avoid
  > `IO.puts/1` (and anything else that writes to stdout) in handler code; to
  > pin the behavior explicitly, configure:
  >
  >     config :logger, :default_handler, config: [type: :standard_error]
  """

  use GenServer
  require Logger

  @behaviour Noizu.MCP.Transport.Server

  # ── Transport.Server sink ─────────────────────────────────────────────────

  @impl Noizu.MCP.Transport.Server
  def send_message(:stdio, iodata, _routing) do
    # One binwrite is a single io-protocol request, so concurrent writers
    # cannot interleave within a message.
    IO.binwrite(:stdio, [iodata, ?\n])
  end

  @impl Noizu.MCP.Transport.Server
  def close_session(:stdio), do: :ok

  # ── Process ───────────────────────────────────────────────────────────────

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts)
  end

  def child_spec(opts) do
    # :transient — exiting :normal on stdin EOF must not trigger a restart.
    %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, restart: :transient}
  end

  @impl GenServer
  def init(opts) do
    server = Keyword.fetch!(opts, :server)
    divert_logger_to_stderr()

    {:ok, session} =
      Noizu.MCP.Server.Supervisor.start_session(server,
        sink: {__MODULE__, :stdio},
        transport: :stdio,
        session_id: "stdio"
      )

    parent = self()
    reader = spawn_link(fn -> read_loop(parent) end)

    {:ok,
     %{
       server: server,
       session: session,
       reader: reader,
       on_eof: Keyword.get(opts, :on_eof, :system_stop)
     }}
  end

  @impl GenServer
  def handle_info({:stdio_line, line}, state) do
    Noizu.MCP.Server.Session.deliver(state.session, line)
    {:noreply, state}
  end

  def handle_info(:stdio_eof, state) do
    Logger.info("MCP stdio transport: stdin closed, shutting down")

    case state.on_eof do
      :system_stop -> System.stop(0)
      :noop -> :ok
    end

    {:stop, :normal, state}
  end

  def handle_info({:stdio_error, reason}, state) do
    Logger.error("MCP stdio transport: read error #{inspect(reason)}")
    {:stop, {:shutdown, {:stdio_error, reason}}, state}
  end

  defp read_loop(parent) do
    case IO.binread(:stdio, :line) do
      :eof ->
        send(parent, :stdio_eof)

      {:error, reason} ->
        send(parent, {:stdio_error, reason})

      line when is_binary(line) ->
        case String.trim(line) do
          "" -> :ok
          trimmed -> send(parent, {:stdio_line, trimmed})
        end

        read_loop(parent)
    end
  end

  defp divert_logger_to_stderr do
    case :logger.get_handler_config(:default) do
      {:ok, %{config: %{type: type}} = handler_config} when type != :standard_error ->
        # logger_std_h does not allow changing :type on a live handler, so
        # replace the handler wholesale, keeping its other settings.
        module = Map.get(handler_config, :module, :logger_std_h)

        replacement =
          handler_config
          |> Map.drop([:id, :module])
          |> Map.update!(:config, fn config ->
            config
            |> Map.put(:type, :standard_error)
            # File/device state from the old handler must not leak in.
            |> Map.drop([:file, :modes])
          end)

        with :ok <- :logger.remove_handler(:default),
             :ok <- :logger.add_handler(:default, module, replacement) do
          Logger.warning(
            "MCP stdio transport diverted the default Logger handler to stderr " <>
              "(stdout is reserved for protocol traffic)"
          )
        else
          error ->
            IO.write(
              :stderr,
              "noizu_mcp: could not divert default Logger handler to stderr " <>
                "(#{inspect(error)}); configure it manually: " <>
                "config :logger, :default_handler, config: [type: :standard_error]\n"
            )
        end

      _ ->
        :ok
    end
  end
end