Skip to main content

lib/mix/tasks/attesto_mcp.install.sessions.ex

# Module name is `Mix.Tasks.AttestoMcp.Install.Sessions` (not `AttestoMCP`)
# because Mix resolves `mix attesto_mcp.install.sessions` via
# `Mix.Utils.command_to_module_name/1`, which camelizes each underscore segment
# and yields `AttestoMcp`. Every reference to the library keeps `AttestoMCP`.
defmodule Mix.Tasks.AttestoMcp.Install.Sessions.Docs do
  @moduledoc false

  @spec short_doc() :: String.t()
  def short_doc, do: "Wires the Ecto-backed Anubis MCP session store into a host application"

  @spec example() :: String.t()
  def example, do: "mix attesto_mcp.install.sessions --repo MyApp.Repo --registry"

  @spec long_doc() :: String.t()
  def long_doc do
    """
    #{short_doc()}

    Configures `AttestoMCP.Anubis.SessionStore.Ecto` - a Postgres-backed
    `Anubis.Server.Session.Store` - so MCP sessions survive a deploy/node
    replacement and a client reconnects with its initialized state restored.

    What it changes (deterministic):

      * adds the `config :anubis_mcp, :session_store` block (enabled, the Ecto
        adapter, the repo, and a 30-minute TTL) to `config/config.exs`;
      * with `--registry`, adds the `horde` dependency for the clustered
        `AttestoMCP.Anubis.Registry.Horde`.

    What it leaves to you (printed as a notice, because the locations are
    app-specific and must not be blind-edited):

      * run `mix attesto_mcp.gen.session_migration --repo <Repo>` to create the
        `attesto_mcp_sessions` table;
      * with `--registry`, wire `registry: {AttestoMCP.Anubis.Registry.Horde, []}`
        into your Anubis server child spec, and connect the nodes (e.g.
        `libcluster`).

    Idempotent: re-running does not duplicate the config block or the dependency.

    ## Example

    ```sh
    #{example()}
    ```

    ## Options

    * `--repo` - the `Ecto.Repo` the session store uses. Defaults to the
      application's `<App>.Repo`.
    * `--registry` - also add the `horde` dependency for the clustered registry
      adapter (and print the wiring notice). Off by default.
    """
  end
end

if Code.ensure_loaded?(Igniter) do
  defmodule Mix.Tasks.AttestoMcp.Install.Sessions do
    @shortdoc "Wires the Ecto-backed Anubis MCP session store into a host application"

    @moduledoc Mix.Tasks.AttestoMcp.Install.Sessions.Docs.long_doc()

    use Igniter.Mix.Task

    alias Igniter.Mix.Task.Info
    alias Igniter.Project.Config
    alias Igniter.Project.Deps
    alias Mix.Tasks.AttestoMcp.Install.Sessions.Docs

    # 30 minutes in milliseconds — matches the Anubis Redis adapter default and
    # `AttestoMCP.Anubis.SessionStore.Ecto`'s own default.
    @default_ttl_ms 1_800_000
    @horde_requirement "~> 0.9"

    @impl Igniter.Mix.Task
    def info(_argv, _composing_task) do
      %Info{
        group: :attesto_mcp,
        example: Docs.example(),
        schema: [repo: :string, registry: :boolean],
        defaults: [registry: false]
      }
    end

    @impl Igniter.Mix.Task
    def igniter(igniter) do
      options = igniter.args.options
      repo = resolve_repo(igniter, options)
      registry? = options[:registry] == true

      igniter
      |> Config.configure(
        "config.exs",
        :anubis_mcp,
        [:session_store],
        enabled: true,
        adapter: AttestoMCP.Anubis.SessionStore.Ecto,
        repo: repo,
        ttl: @default_ttl_ms
      )
      |> maybe_add_horde(registry?)
      |> Igniter.add_notice(notice(repo, registry?))
    end

    defp maybe_add_horde(igniter, false), do: igniter

    defp maybe_add_horde(igniter, true) do
      Deps.add_dep(igniter, {:horde, @horde_requirement})
    end

    defp resolve_repo(igniter, options) do
      case options[:repo] do
        repo when is_binary(repo) and repo != "" -> Igniter.Project.Module.parse(repo)
        _ -> Module.concat(Igniter.Project.Module.module_name_prefix(igniter), Repo)
      end
    end

    defp notice(repo, registry?) do
      base = """
      AttestoMCP Ecto session store configured.

      `config :anubis_mcp, :session_store` now points at
      AttestoMCP.Anubis.SessionStore.Ecto (repo: #{inspect(repo)}).

      Next step — create the backing table:

          mix attesto_mcp.gen.session_migration --repo #{inspect(repo)}
      """

      if registry? do
        base <>
          """

          Registry — the `horde` dependency was added. Wire the clustered
          registry into your Anubis server child spec:

              {MyApp.MCP.Server,
               transport: :streamable_http,
               registry: {AttestoMCP.Anubis.Registry.Horde, []},
               # ...
              }

          and connect the nodes (e.g. libcluster) so Horde discovers peers.
          """
      else
        base
      end
    end
  end
else
  defmodule Mix.Tasks.AttestoMcp.Install.Sessions do
    @shortdoc "#{Mix.Tasks.AttestoMcp.Install.Sessions.Docs.short_doc()} | Install `igniter` to use"

    @moduledoc Mix.Tasks.AttestoMcp.Install.Sessions.Docs.long_doc()

    use Mix.Task

    @impl Mix.Task
    def run(_argv) do
      Mix.shell().error("""
      The task 'attesto_mcp.install.sessions' requires igniter.

      Add `{:igniter, "~> 0.5", only: [:dev, :test]}` to your deps and run
      `mix deps.get`, then re-run this task.
      """)

      exit({:shutdown, 1})
    end
  end
end