Current section

Files

Jump to
hermes_mcp lib mix interactive supervised_shell.ex
Raw

lib/mix/interactive/supervised_shell.ex

defmodule Mix.Interactive.SupervisedShell do
@moduledoc """
Supervised shell for interactive MCP sessions with automatic restart capability.
This module provides a robust shell that monitors both the transport and client
processes, automatically restarting them if they crash due to timeouts or other
errors.
"""
alias Hermes.Client
alias Mix.Interactive.CLI
alias Mix.Interactive.Commands
alias Mix.Interactive.UI
defstruct [
:transport_module,
:transport_opts,
:client_opts,
:transport_pid,
:client_pid,
:restart_count,
:max_restarts
]
@max_restarts 3
@restart_delay 1000
@doc """
Starts a supervised interactive shell session.
## Options
* `:transport_module` - The transport module (e.g., Hermes.Transport.StreamableHTTP)
* `:transport_opts` - Options for starting the transport
* `:client_opts` - Options for starting the client (excluding transport)
* `:max_restarts` - Maximum number of automatic restarts (default: 3)
"""
def start(opts) do
state = %__MODULE__{
transport_module: Keyword.fetch!(opts, :transport_module),
transport_opts: Keyword.fetch!(opts, :transport_opts),
client_opts: Keyword.fetch!(opts, :client_opts),
restart_count: 0,
max_restarts: Keyword.get(opts, :max_restarts, @max_restarts)
}
Process.flag(:trap_exit, true)
case start_processes(state) do
{:ok, state} ->
IO.puts("\nType #{UI.colors().command}help#{UI.colors().reset} for available commands\n")
supervised_loop(state)
{:error, reason} ->
IO.puts("#{UI.colors().error}Failed to start processes: #{inspect(reason)}#{UI.colors().reset}")
{:error, reason}
end
end
defp supervised_loop(%{client_pid: client} = state) do
if Process.alive?(client) do
IO.write("#{UI.colors().prompt}mcp> #{UI.colors().reset}")
parent = self()
input_pid =
spawn_link(fn ->
line = IO.gets("")
send(parent, {:input, line})
end)
receive do
{:EXIT, pid, reason} when pid == state.client_pid or pid == state.transport_pid ->
Process.exit(input_pid, :kill)
handle_process_exit(pid, reason, state)
{:EXIT, ^input_pid, _reason} ->
supervised_loop(state)
{:input, :eof} ->
:ok
{:input, line} ->
line
|> String.trim()
|> Commands.process_command(client, fn -> supervised_loop(state) end)
end
else
handle_process_exit(state.client_pid, :noproc, state)
end
end
defp handle_process_exit(pid, reason, state) do
cond do
pid == state.client_pid ->
IO.puts("\n#{UI.colors().error}✗ Client process crashed: #{format_exit_reason(reason)}#{UI.colors().reset}")
handle_restart(state)
pid == state.transport_pid ->
IO.puts("\n#{UI.colors().error}✗ Transport process crashed: #{format_exit_reason(reason)}#{UI.colors().reset}")
handle_restart(state)
true ->
supervised_loop(state)
end
end
defp format_exit_reason({:timeout, _}), do: "Request timeout"
defp format_exit_reason({:error, %{reason: reason}}), do: "Error: #{reason}"
defp format_exit_reason(:normal), do: "Normal termination"
defp format_exit_reason(:shutdown), do: "Shutdown"
defp format_exit_reason(reason), do: inspect(reason)
defp handle_restart(state) do
if state.restart_count < state.max_restarts do
IO.puts(
"#{UI.colors().info}→ Attempting automatic restart (#{state.restart_count + 1}/#{state.max_restarts})...#{UI.colors().reset}"
)
Process.sleep(@restart_delay)
cleanup_processes(state)
new_state = %{state | restart_count: state.restart_count + 1}
case start_processes(new_state) do
{:ok, restarted_state} ->
IO.puts("#{UI.colors().success}✓ Successfully restarted#{UI.colors().reset}")
IO.write("\n#{UI.colors().prompt}mcp> #{UI.colors().reset}")
supervised_loop(restarted_state)
{:error, reason} ->
IO.puts("#{UI.colors().error}✗ Restart failed: #{inspect(reason)}#{UI.colors().reset}")
offer_manual_restart(new_state)
end
else
IO.puts("#{UI.colors().error}✗ Maximum restart attempts reached#{UI.colors().reset}")
offer_manual_restart(state)
end
end
defp offer_manual_restart(state) do
IO.puts("\n#{UI.colors().info}Options:#{UI.colors().reset}")
IO.puts(" #{UI.colors().command}r#{UI.colors().reset} - Retry connection")
IO.puts(" #{UI.colors().command}q#{UI.colors().reset} - Quit")
IO.write("\n#{UI.colors().prompt}Choice: #{UI.colors().reset}")
case "" |> IO.gets() |> String.trim() |> String.downcase() do
"r" ->
IO.puts("#{UI.colors().info}→ Retrying connection...#{UI.colors().reset}")
cleanup_processes(state)
new_state = %{state | restart_count: 0}
case start_processes(new_state) do
{:ok, restarted_state} ->
IO.puts("#{UI.colors().success}✓ Successfully reconnected#{UI.colors().reset}")
supervised_loop(restarted_state)
{:error, reason} ->
IO.puts("#{UI.colors().error}✗ Retry failed: #{inspect(reason)}#{UI.colors().reset}")
offer_manual_restart(new_state)
end
"q" ->
IO.puts("#{UI.colors().info}Exiting...#{UI.colors().reset}")
cleanup_processes(state)
:ok
_ ->
offer_manual_restart(state)
end
end
defp start_processes(state) do
# Start client first - it will hibernate waiting for transport's :initialize message
with {:ok, client_pid} <- start_client(state),
{:ok, transport_pid} <- start_transport(state) do
Process.monitor(transport_pid)
Process.monitor(client_pid)
IO.puts("#{UI.colors().info}• Checking connection...#{UI.colors().reset}")
CLI.check_client_connection(client_pid)
{:ok, %{state | transport_pid: transport_pid, client_pid: client_pid}}
end
end
defp start_transport(%{transport_module: module, transport_opts: opts}) do
IO.puts("#{UI.colors().info}• Starting transport...#{UI.colors().reset}")
case module.start_link(opts) do
{:ok, pid} ->
IO.puts("#{UI.colors().success}✓ Transport started#{UI.colors().reset}")
{:ok, pid}
{:error, {:already_started, pid}} ->
IO.puts("#{UI.colors().info}• Transport already running#{UI.colors().reset}")
{:ok, pid}
error ->
error
end
end
defp start_client(%{client_opts: opts}) do
IO.puts("#{UI.colors().info}• Starting client...#{UI.colors().reset}")
case Client.start_link(opts) do
{:ok, pid} ->
IO.puts("#{UI.colors().success}✓ Client started#{UI.colors().reset}")
{:ok, pid}
{:error, {:already_started, pid}} ->
IO.puts("#{UI.colors().info}• Client already running#{UI.colors().reset}")
{:ok, pid}
error ->
error
end
end
defp cleanup_processes(state) do
if state.client_pid && Process.alive?(state.client_pid) do
Process.exit(state.client_pid, :shutdown)
end
if state.transport_pid && Process.alive?(state.transport_pid) do
Process.exit(state.transport_pid, :shutdown)
end
Process.sleep(100)
end
end