# SPDX-FileCopyrightText: 2026 James Harton # # SPDX-License-Identifier: Apache-2.0 defmodule BB.MCP.Server do @moduledoc """ MCP server that exposes BB robots to AI agents. The set of robots this server makes available is read from application config at runtime: config :bb_mcp, robots: [MyApp.WX200, MyApp.SO101] ## Supervision Add the server to your supervision tree alongside your robots: children = [ MyApp.WX200, MyApp.SO101, {BB.MCP.Server, transport: :streamable_http, streamable_http: [port: 4000]} ] ## Phoenix mount Mount over Streamable HTTP inside a Phoenix router using `BB.MCP.Router.bb_mcp/2`: import BB.MCP.Router scope "/" do bb_mcp "/mcp" end ## Tools Static cross-cutting tools (`list_robots`, `get_state`, `force_disarm`, `list_commands`, `list_parameters`, `get_parameter`, `set_parameter`, `send_joint_positions`) take a `robot` string argument identifying the target robot. Per-command tools are registered dynamically on session initialisation, one per `{robot, command}` pair declared in each robot's Spark DSL. Each is named `{robot}.{command}` (e.g. `wx200.home`) and carries the command's typed argument schema. They are dispatched through `handle_tool_call/3` to `BB.Robot.Runtime.execute/3` + `BB.Command.await/2`. ## Resources Resources are URI-templated by robot name. See `BB.MCP.Resources.*`. """ use Anubis.Server, name: "bb_mcp", version: "0.1.0", capabilities: [:tools, :resources] alias Anubis.MCP.Error alias Anubis.Server.Frame alias Anubis.Server.Response alias BB.Command alias BB.Dsl.Info alias BB.MCP.EventBuffer alias BB.MCP.PeriSchema alias BB.MCP.Robots alias BB.MCP.Tools alias BB.Message alias BB.PubSub alias BB.Robot.Runtime component(BB.MCP.Tools.ForceDisarm) component(BB.MCP.Tools.GetParameter) component(BB.MCP.Tools.GetState) component(BB.MCP.Tools.ListCommands) component(BB.MCP.Tools.ListParameters) component(BB.MCP.Tools.ListRobots) component(BB.MCP.Tools.QueryEvents) component(BB.MCP.Tools.SendJointPositions) component(BB.MCP.Tools.SetParameter) component(BB.MCP.Resources.RobotCommands) component(BB.MCP.Resources.RobotIndex) component(BB.MCP.Resources.RobotJoints) component(BB.MCP.Resources.RobotParameters) component(BB.MCP.Resources.RobotState) component(BB.MCP.Resources.RobotTopology) @await_default 30_000 @impl Anubis.Server def init(_client_info, frame) do robots = Tools.robots() |> Robots.to_list() frame = frame |> Frame.assign(:event_buffer, subscribe_to_events(robots)) |> then(&Enum.reduce(robots, &1, fn robot, acc -> register_robot_tools(robot, acc) end)) {:ok, frame} end @impl Anubis.Server def handle_info({:bb, path, %Message{} = message}, frame) do case Map.fetch(frame.assigns, :event_buffer) do {:ok, buffer} -> robot_name = robot_name_for(buffer, message) || "unknown" {:noreply, Frame.assign(frame, :event_buffer, EventBuffer.push(buffer, robot_name, path, message))} :error -> {:noreply, frame} end end def handle_info(_other, frame), do: {:noreply, frame} @impl Anubis.Server def terminate(_reason, frame) do case Map.get(frame.assigns, :event_buffer) do %{subscriptions: subs} -> Enum.each(subs, fn {_name, mod} -> safe_unsubscribe(mod) end) _ -> :ok end :ok end defp subscribe_to_events(robots) do capacity = EventBuffer.configured_capacity() Enum.reduce(robots, EventBuffer.new(capacity), fn {robot_name, robot_module}, buffer -> case safe_subscribe(robot_module) do :ok -> EventBuffer.record_subscription(buffer, robot_name, robot_module) :error -> buffer end end) end defp safe_subscribe(robot_module) do case PubSub.subscribe(robot_module, []) do {:ok, _pid} -> :ok _ -> :error end rescue _ -> :error end defp safe_unsubscribe(robot_module) do PubSub.unsubscribe(robot_module, []) rescue _ -> :ok end # BB.PubSub delivers `{:bb, path, message}` with no robot id. With one # subscription that's unambiguous; with several we attribute to the first # — the path still identifies which subtree the event came from. defp robot_name_for(%{subscriptions: []}, _msg), do: nil defp robot_name_for(%{subscriptions: [{name, _} | _]}, _msg), do: name @impl Anubis.Server def handle_tool_call(name, params, frame) do case parse_tool_name(name) do {:ok, robot_name, command_name} -> dispatch_command(robot_name, command_name, params, frame) :error -> {:error, Error.protocol(:method_not_found, %{message: "unknown tool: #{inspect(name)}"}), frame} end end @impl Anubis.Server def server_instructions do """ This server exposes one or more BB-framework robots over MCP. Start by calling `list_robots` to see configured robots. Each robot's declared commands appear as their own tools, named `{robot}.{command}` — e.g. `wx200.home`, `wx200.arm`. Their input schemas come straight from the robot's Spark DSL. Cross-cutting tools (`get_state`, `force_disarm`, `list_parameters`, `get_parameter`, `set_parameter`, `send_joint_positions`, `list_commands`) take a `robot` argument selecting which robot to operate on. `query_events` returns recent pubsub events captured since this MCP session connected — useful for inspecting command outcomes, joint-state changes, and motion lifecycle without polling state. Resources are URI-templated by robot name, e.g. `bb://robots/{robot}/topology` or `bb://robots/{robot}/joints`. Robots must be armed before motion commands will run; arm using the `{robot}.arm` tool (or `{robot}.disarm` / `force_disarm` to stop). """ end defp register_robot_tools({robot_name, robot_module}, frame) do robot_module |> Info.commands() |> Enum.reduce(frame, fn command, acc -> register_command_tool(acc, robot_name, robot_module, command) end) end defp register_command_tool(frame, robot_name, _robot_module, command) do tool_name = "#{robot_name}.#{command.name}" schema = PeriSchema.for_command(command) frame |> Frame.register_tool(tool_name, description: tool_description(robot_name, command), input_schema: schema ) |> wrap_tool_validator(tool_name, command) end # Some MCP clients reinterpret flat dotted property names (e.g. `"target.x"`) # as nested-object paths and send `{"target": {"x": ...}}`. We register the # flat shape on purpose (broader client compatibility) so we flatten any # nested values back to the dotted form before Peri validates them. defp wrap_tool_validator(frame, tool_name, command) do case Map.fetch(frame.tools, tool_name) do {:ok, %{validate_input: original} = tool} when is_function(original, 1) -> wrapped = fn params -> original.(PeriSchema.flatten_nested_params(command, params)) end %{frame | tools: Map.put(frame.tools, tool_name, %{tool | validate_input: wrapped})} _ -> frame end end defp tool_description(robot_name, command) do states = Enum.map_join(command.allowed_states, ", ", &Atom.to_string/1) cat = if command.category, do: " (category: #{command.category})", else: "" "Run the `#{command.name}` command on robot `#{robot_name}`#{cat}. " <> "Allowed when robot is in state: #{states}." end defp parse_tool_name(name) when is_binary(name) do case String.split(name, ".", parts: 2) do [robot, command] when robot != "" and command != "" -> {:ok, robot, command} _ -> :error end end defp dispatch_command(robot_name, command_name, params, frame) do with {:ok, robot_module} <- resolve_robot(robot_name), command_atom = String.to_atom(command_name), {:ok, command} <- fetch_command(robot_module, command_atom), goal = PeriSchema.to_goal(command, params), {:ok, pid} <- Runtime.execute(robot_module, command_atom, goal), {:ok, result} <- await_command(pid) do payload = %{"status" => "ok", "result" => inspect(result)} {:reply, Response.json(Response.tool(), payload), frame} else {:error, reason} -> {:error, Tools.to_anubis_error(reason), frame} end end defp await_command(pid) do case Command.await(pid, @await_default) do {:ok, result} -> {:ok, result} {:ok, result, _opts} -> {:ok, result} {:error, reason} -> {:error, reason} end end defp resolve_robot(name) do case Robots.fetch(Tools.robots(), name) do {:ok, module} -> {:ok, module} {:error, :unknown_robot} -> {:error, Error.protocol(:invalid_request, %{message: "unknown robot: #{inspect(name)}"})} end end defp fetch_command(robot_module, command_name) do robot_module |> Info.commands() |> Enum.find(&(&1.name == command_name)) |> case do nil -> {:error, Error.protocol(:invalid_request, %{message: "unknown command: #{inspect(command_name)}"})} command -> {:ok, command} end end end