# SPDX-FileCopyrightText: 2026 James Harton # # SPDX-License-Identifier: Apache-2.0 if Code.ensure_loaded?(Igniter) do defmodule Mix.Tasks.BbMcp.Install do @shortdoc "Installs BB.MCP into an application" @moduledoc """ #{@shortdoc} Configures `:bb_mcp, :robots` from the supplied `--robot` module(s), then wires up an MCP transport: * If the host app uses Phoenix, the installer locates the application's router and adds the `BB.MCP.Router.bb_mcp/2` mount (defaulting to `/mcp`). * Otherwise it adds `BB.MCP.Server` as a Streamable HTTP child to the application's supervision tree, listening on `--port`. ## Example ```bash mix igniter.install bb_mcp mix igniter.install bb_mcp --robot MyApp.Robot mix igniter.install bb_mcp --robot MyApp.WX200 --robot MyApp.SO101 mix igniter.install bb_mcp --path /mcp --port 4001 ``` ## Options * `--robot` — Robot module to expose. May be supplied multiple times. Defaults to `{AppPrefix}.Robot`. * `--path` — URL path for the Phoenix mount (default `/mcp`). * `--port` — Streamable HTTP port for the non-Phoenix mount (default `4000`). """ use Igniter.Mix.Task alias Igniter.Libs.Phoenix alias Igniter.Project.Application, as: ProjectApplication alias Igniter.Project.Config, as: ProjectConfig alias Igniter.Project.Formatter, as: ProjectFormatter alias Igniter.Project.Module, as: ProjectModule @impl Igniter.Mix.Task def info(_argv, _parent) do %Igniter.Mix.Task.Info{ schema: [ robot: :keep, path: :string, port: :integer ], aliases: [r: :robot, p: :path] } end @impl Igniter.Mix.Task def igniter(igniter) do options = igniter.args.options path = Keyword.get(options, :path, "/mcp") port = Keyword.get(options, :port, 4000) robots = resolve_robots(igniter, options) igniter |> ProjectFormatter.import_dep(:bb_mcp) |> ProjectConfig.configure( "config.exs", :bb_mcp, [:robots], {:code, robots_list_ast(robots)} ) |> wire_transport(path, port) end defp wire_transport(igniter, path, port) do case Phoenix.select_router(igniter) do {igniter, nil} -> add_standalone_server(igniter, port) {igniter, router} -> mount_into_phoenix(igniter, router, path) end end defp mount_into_phoenix(igniter, router, path) do # Do not pass :arg2 — bb_mcp must live in an unaliased scope so # Phoenix doesn't rewrite the underlying Anubis Plug module. # BB.MCP.Server still needs to be running for the plug to find it, # so add it to the supervision tree. Phoenix's endpoint handles the # HTTP listener; Anubis just needs the server's transport process. igniter |> maybe_add_router_scope(router, path) |> ProjectApplication.add_new_child({BB.MCP.Server, transport: :streamable_http}) |> Igniter.add_notice(""" bb_mcp: mounted at #{inspect(path)} on #{inspect(router)}. The MCP Streamable HTTP endpoint will be served by your Phoenix endpoint; BB.MCP.Server has been added to the application supervision tree. """) end defp maybe_add_router_scope(igniter, router, path) do if router_already_mounts_bb_mcp?(igniter, router) do igniter else Phoenix.add_scope(igniter, "/", router_scope(path), router: router) end end defp router_already_mounts_bb_mcp?(igniter, router) do case ProjectModule.find_module(igniter, router) do {:ok, {_igniter, source, _zipper}} -> source |> Rewrite.Source.get(:content) |> String.contains?("BB.MCP.Router") _ -> false end end defp router_scope(path) do """ import BB.MCP.Router bb_mcp #{inspect(path)} """ end defp add_standalone_server(igniter, port) do igniter |> ProjectApplication.add_new_child( {BB.MCP.Server, transport: :streamable_http, streamable_http: [port: port]} ) |> Igniter.add_notice(""" bb_mcp: BB.MCP.Server added to the supervision tree, serving Streamable HTTP on http://localhost:#{port}/. No Phoenix dep detected — the server runs standalone via Anubis' built-in HTTP transport. """) end defp resolve_robots(igniter, options) do case options |> Keyword.get_values(:robot) |> List.flatten() do [] -> [ProjectModule.module_name(igniter, "Robot")] names -> Enum.map(names, &ProjectModule.parse/1) end end defp robots_list_ast(robots) do Enum.map(robots, &module_ast/1) end defp module_ast(module) do {:__aliases__, [alias: false], module |> Module.split() |> Enum.map(&String.to_atom/1)} end end else defmodule Mix.Tasks.BbMcp.Install do @shortdoc "Installs BB.MCP into an application" @moduledoc false use Mix.Task def run(_argv) do Mix.shell().error(""" The bb_mcp.install task requires igniter. mix igniter.install bb_mcp """) exit({:shutdown, 1}) end end end