Skip to main content

lib/mix/tasks/mcp.client.ex

defmodule Mix.Tasks.Mcp.Client do
  @shortdoc "Launch the interactive MCP inspector web client"

  @moduledoc """
  Start `Noizu.MCP.Inspector` — a rich interactive HTML client for exploring
  and exercising MCP servers (tools, resources, prompts, sampling,
  elicitation, raw JSON-RPC history) — and open it in your browser.

      # no target: pick/change the target inside the app
      mix mcp.client

      # in-process server module
      mix mcp.client MyApp.MCP

      # spawn an external stdio server
      mix mcp.client --stdio "npx -y @modelcontextprotocol/server-everything"

      # connect to a remote Streamable HTTP server
      mix mcp.client --url http://localhost:4040/mcp --bearer TOKEN

  ## Options

    * `--port PORT` — HTTP port (default 6274; `0` picks a random free port)
    * `--no-open` — don't auto-open the browser
    * `--stdio CMD` — spawn `CMD` (shell-split) as a stdio MCP server
    * `--cd DIR` / `--env K=V` (repeatable) — stdio subprocess options
    * `--url URL` / `--bearer TOKEN` — Streamable HTTP target
    * `--name NAME` / `--version VSN` — advertised client info

  Requires the optional `:bandit` and `:plug` dependencies (and `:req` for
  `--url` targets).
  """

  use Mix.Task

  @switches [
    port: :integer,
    open: :boolean,
    stdio: :string,
    cd: :string,
    env: :keep,
    url: :string,
    bearer: :string,
    name: :string,
    version: :string
  ]

  @impl Mix.Task
  def run(argv) do
    {target, opts} = parse_args!(argv)
    ensure_deps!()

    Mix.Task.run("app.start")

    target = validate_target!(target)
    token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)

    client_info =
      if opts[:name] || opts[:version] do
        %{name: opts[:name] || "noizu-mcp-inspector", version: opts[:version] || "0.0.0"}
      end

    {:ok, _pid} =
      Noizu.MCP.Inspector.start_link(
        target: target,
        token: token,
        port: Keyword.get(opts, :port, 6274),
        client_info: client_info
      )

    url = "#{Noizu.MCP.Inspector.url()}?token=#{token}"

    Mix.shell().info("""

    MCP inspector running at:

        #{url}

    Target: #{describe_target(target)}
    Press Ctrl-C to stop.
    """)

    if Keyword.get(opts, :open, true), do: open_browser(url)

    Process.sleep(:infinity)
  end

  @doc false
  def parse_args!(argv) do
    {opts, positional, invalid} = OptionParser.parse(argv, strict: @switches)

    if invalid != [] do
      Mix.raise("Invalid options: #{inspect(invalid)}")
    end

    env =
      opts
      |> Keyword.get_values(:env)
      |> Map.new(fn pair ->
        case String.split(pair, "=", parts: 2) do
          [key, value] -> {key, value}
          _ -> Mix.raise("--env expects K=V, got: #{pair}")
        end
      end)

    target =
      case {positional, opts[:stdio], opts[:url]} do
        {[module], nil, nil} ->
          {:module, Module.concat([module])}

        {[], command, nil} when is_binary(command) ->
          case OptionParser.split(command) do
            [executable | args] ->
              {:stdio, executable, args: args, env: map_or_nil(env), cd: opts[:cd]}

            [] ->
              Mix.raise("--stdio expects a command")
          end

        {[], nil, url} when is_binary(url) ->
          {:url, url, bearer: opts[:bearer]}

        {[], nil, nil} ->
          # No target: the user picks one in the browser.
          nil

        _ ->
          Mix.raise("Give exactly one of: a server module, --stdio, or --url")
      end

    {target, opts}
  end

  defp map_or_nil(map) when map_size(map) == 0, do: nil
  defp map_or_nil(map), do: map

  defp ensure_deps! do
    unless Code.ensure_loaded?(Bandit) and Code.ensure_loaded?(Plug.Conn) do
      Mix.raise("""
      mix mcp.client needs the optional :bandit and :plug dependencies. Add to mix.exs:

          {:bandit, "~> 1.5", only: :dev},
          {:plug, "~> 1.16", only: :dev}
      """)
    end
  end

  defp validate_target!({:module, module} = target) do
    unless Code.ensure_loaded?(module) and function_exported?(module, :__mcp__, 1) do
      Mix.raise(
        "#{inspect(module)} is not a Noizu.MCP.Server module (missing __mcp__/1). " <>
          "Did you mean a fully-qualified module name?"
      )
    end

    Noizu.MCP.Test.ensure_server_started(module)
    target
  end

  defp validate_target!({:url, _url, _opts} = target) do
    unless Code.ensure_loaded?(Noizu.MCP.Transport.StreamableHTTP.Client) do
      Mix.raise("--url targets need the optional :req dependency: {:req, \"~> 0.5\"}")
    end

    target
  end

  defp validate_target!(target), do: target

  defp describe_target(nil), do: "none — choose one in the browser"
  defp describe_target({:module, module}), do: "in-process #{inspect(module)}"
  defp describe_target({:stdio, command, _opts}), do: "stdio: #{command}"
  defp describe_target({:url, url, _opts}), do: url

  defp open_browser(url) do
    {command, args} =
      case :os.type() do
        {:unix, :darwin} -> {"open", [url]}
        {:unix, _} -> {"xdg-open", [url]}
        {:win32, _} -> {"cmd", ["/c", "start", "", url]}
      end

    System.cmd(command, args, stderr_to_stdout: true)
  rescue
    _ -> :ok
  end
end