Current section
Files
Jump to
Current section
Files
lib/phiaui_design/mcp/tools.ex
defmodule PhiaUiDesign.Mcp.Tools do
@moduledoc """
Implements all 15 MCP tool handlers for the PhiaUI Design server.
Each tool function receives parsed arguments and the server state,
returning `{result_map, updated_state}`. The result map follows the
MCP content format: `%{"content" => [%{"type" => "text", "text" => ...}]}`.
"""
alias PhiaUiDesign.Canvas.Scene
alias PhiaUiDesign.Codegen.{HeexEmitter, LiveviewEmitter}
alias PhiaUiDesign.Templates.PageTemplates
alias PhiaUiDesign.Catalog.{CatalogServer, Introspector}
@doc """
Execute a tool by name. Returns `{result, state}`.
"""
def execute(tool_name, args, state) do
try do
do_execute(tool_name, args, state)
rescue
e ->
{text_result("Error: #{Exception.message(e)}"), state}
end
end
# ---------------------------------------------------------------------------
# Component tools
# ---------------------------------------------------------------------------
defp do_execute("insert_phia_component", args, state) do
component = safe_to_atom(args["component"])
attrs = atomize_map(args["attrs"] || %{})
slots = atomize_map(args["slots"] || %{})
parent_id = args["parent_id"]
name = args["name"]
opts =
[attrs: attrs, slots: slots]
|> maybe_add(:parent_id, parent_id)
|> maybe_add(:name, name)
{:ok, node} = Scene.insert_component(state.scene, component, opts)
{text_result("Inserted <.#{component}> with id: #{node.id}"), state}
end
defp do_execute("set_phia_attr", %{"node_id" => node_id, "attrs" => attrs}, state) do
atomized = atomize_map(attrs)
case Scene.update_attrs(state.scene, node_id, atomized) do
{:ok, node} ->
{text_result("Updated attrs on #{node.name} (#{node_id})"), state}
{:error, reason} ->
{text_result("Error: #{inspect(reason)}"), state}
end
end
defp do_execute("set_phia_slot", args, state) do
node_id = args["node_id"]
slot_name = safe_to_atom(args["slot_name"])
content = args["content"]
case Scene.get_node(state.scene, node_id) do
{:ok, node} ->
new_slots = Map.put(node.slots, slot_name, content)
Scene.update_node(state.scene, node_id, %{slots: new_slots})
{text_result("Set slot :#{slot_name} on #{node.name}"), state}
{:error, _} ->
{text_result("Error: node #{node_id} not found"), state}
end
end
defp do_execute("delete_phia_node", %{"node_id" => node_id}, state) do
case Scene.delete_node(state.scene, node_id) do
:ok ->
{text_result("Deleted node #{node_id} and its children"), state}
{:error, _} ->
{text_result("Error: node #{node_id} not found"), state}
end
end
defp do_execute("move_phia_node", args, state) do
node_id = args["node_id"]
new_parent_id = args["new_parent_id"]
index = args["index"] || -1
case Scene.move_node(state.scene, node_id, new_parent_id, index) do
{:ok, _node} ->
parent_desc = if new_parent_id, do: "under #{new_parent_id}", else: "to root"
{text_result("Moved #{node_id} #{parent_desc} at index #{index}"), state}
{:error, reason} ->
{text_result("Error: #{inspect(reason)}"), state}
end
end
# ---------------------------------------------------------------------------
# Catalog tools
# ---------------------------------------------------------------------------
defp do_execute("get_phia_component_info", %{"component" => name}, state) do
key = safe_to_atom(name)
case Introspector.get_component_info(key) do
nil ->
{text_result("Component '#{name}' not found in registry"), state}
info ->
formatted = format_component_info(info)
{text_result(formatted), state}
end
end
defp do_execute("get_phia_catalog", args, state) do
tier_filter = args["tier"]
components =
if tier_filter do
CatalogServer.all()
|> Enum.filter(&(to_string(&1.tier) == tier_filter))
else
CatalogServer.all()
end
grouped =
components
|> Enum.group_by(& &1.tier)
|> Enum.sort_by(fn {tier, _} -> to_string(tier) end)
|> Enum.map(fn {tier, comps} ->
names = Enum.map(comps, & &1.name) |> Enum.sort()
"## #{tier} (#{length(comps)})\n#{Enum.join(names, ", ")}"
end)
|> Enum.join("\n\n")
total = length(components)
{text_result("PhiaUI Catalog — #{total} components\n\n#{grouped}"), state}
end
defp do_execute("get_phia_families", _args, state) do
families = CatalogServer.families()
formatted =
families
|> Enum.map(fn fam ->
children = Enum.map(fam.children, &to_string/1) |> Enum.join(", ")
"#{fam.root_name}: #{to_string(fam.root)} + [#{children}]"
end)
|> Enum.join("\n")
{text_result("Component families (#{length(families)}):\n\n#{formatted}"), state}
end
# ---------------------------------------------------------------------------
# Layout / template tools
# ---------------------------------------------------------------------------
defp do_execute("insert_phia_page", %{"template" => template_name}, state) do
key = safe_to_atom(template_name)
case PageTemplates.apply_template(state.scene, key) do
{:ok, _scene} ->
count = Scene.count(state.scene)
{text_result("Applied '#{template_name}' template. Scene now has #{count} nodes."), state}
{:error, :unknown_template} ->
valid = PageTemplates.keys() |> Enum.map(&to_string/1) |> Enum.join(", ")
{text_result("Unknown template '#{template_name}'. Valid: #{valid}"), state}
end
end
# ---------------------------------------------------------------------------
# Theme tools
# ---------------------------------------------------------------------------
defp do_execute("set_phia_theme", %{"theme" => theme_name}, state) do
theme = safe_to_atom(theme_name)
{text_result("Theme set to :#{theme}"), %{state | theme: theme}}
end
defp do_execute("get_phia_theme", _args, state) do
{text_result("Current theme: :#{state.theme}"), state}
end
# ---------------------------------------------------------------------------
# Export tools
# ---------------------------------------------------------------------------
defp do_execute("export_heex", _args, state) do
heex = HeexEmitter.emit(state.scene)
if heex == "" do
{text_result("Scene is empty. Insert components first."), state}
else
{text_result(heex), state}
end
end
defp do_execute("export_liveview", args, state) do
module_name = args["module_name"] || "MyAppWeb.DesignedLive"
web_module = args["web_module"] || "MyAppWeb"
code = LiveviewEmitter.emit(state.scene, module_name, web_module: web_module)
hooks = Scene.hooks_needed(state.scene)
commands = LiveviewEmitter.required_add_commands(Scene.components_used(state.scene))
footer =
if hooks != [] or commands != [] do
parts = []
parts =
if hooks != [] do
parts ++ ["\n# Required JS hooks: #{Enum.join(hooks, ", ")}"]
else
parts
end
parts =
if commands != [] do
parts ++ ["\n# Install commands:\n# " <> Enum.join(commands, "\n# ")]
else
parts
end
Enum.join(parts)
else
""
end
{text_result(code <> footer), state}
end
# ---------------------------------------------------------------------------
# Scene tools
# ---------------------------------------------------------------------------
defp do_execute("get_scene_tree", _args, state) do
tree = Scene.to_tree(state.scene)
if tree == [] do
{text_result("Scene is empty."), state}
else
json_tree = Enum.map(tree, &tree_node_to_map/1)
{text_result(Jason.encode!(json_tree, pretty: true)), state}
end
end
defp do_execute("clear_scene", _args, state) do
old_count = Scene.count(state.scene)
Scene.destroy(state.scene)
new_scene = Scene.new()
{text_result("Cleared #{old_count} nodes."), %{state | scene: new_scene}}
end
# Unknown tool
defp do_execute(tool_name, _args, state) do
{text_result("Unknown tool: #{tool_name}"), state}
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp text_result(text) do
%{"content" => [%{"type" => "text", "text" => text}]}
end
defp safe_to_atom(str) when is_binary(str) do
String.to_existing_atom(str)
rescue
ArgumentError -> String.to_atom(str)
end
defp atomize_map(map) when is_map(map) do
Map.new(map, fn {k, v} ->
key =
if is_binary(k) do
safe_to_atom(k)
else
k
end
value = coerce_value(v)
{key, value}
end)
end
# Coerce string values to appropriate Elixir types
defp coerce_value("true"), do: true
defp coerce_value("false"), do: false
defp coerce_value(":" <> rest) do
safe_to_atom(rest)
end
defp coerce_value(v) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> int
_ ->
case Float.parse(v) do
{f, ""} -> f
_ -> v
end
end
end
defp coerce_value(v) when is_integer(v), do: v
defp coerce_value(v) when is_float(v), do: v
defp coerce_value(true), do: true
defp coerce_value(false), do: false
defp coerce_value(nil), do: nil
defp coerce_value(v) when is_list(v), do: Enum.map(v, &coerce_value/1)
defp coerce_value(v) when is_map(v), do: atomize_map(v)
defp coerce_value(v), do: v
defp maybe_add(opts, _key, nil), do: opts
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
defp format_component_info(info) do
attrs_text =
info.attrs
|> Enum.map(fn a ->
req = if a.required, do: " (required)", else: ""
vals = if a.values, do: " values: #{inspect(a.values)}", else: ""
default = if a[:default], do: " default: #{inspect(a[:default])}", else: ""
" #{a.name}: #{a.type}#{req}#{vals}#{default}"
end)
|> Enum.join("\n")
slots_text =
info.slots
|> Enum.map(fn s ->
req = if s.required, do: " (required)", else: ""
" #{s.name}#{req}"
end)
|> Enum.join("\n")
hooks = info.js_hooks |> Enum.join(", ")
hooks_line = if hooks != "", do: "\nJS Hooks: #{hooks}", else: ""
"""
Component: #{info.name}
Module: #{inspect(info.module)}
Tier: #{info.tier}#{hooks_line}
Attrs:
#{attrs_text}
Slots:
#{slots_text}
"""
|> String.trim()
end
defp tree_node_to_map(nil), do: nil
defp tree_node_to_map(%{node: node, children: children}) do
base = %{
"id" => node.id,
"type" => to_string(node.type),
"name" => node.name
}
base =
case node.type do
:phia_component ->
base
|> Map.put("component", to_string(node.component))
|> Map.put("attrs", stringify_map(node.attrs))
|> Map.put("slots", stringify_map(node.slots))
:html_element ->
base
|> Map.put("tag", node.tag)
|> Map.put("classes", node.classes)
:text ->
Map.put(base, "text_content", node.text_content)
:frame ->
Map.put(base, "classes", node.classes)
_ ->
base
end
if children != [] do
Map.put(base, "children", Enum.map(children, &tree_node_to_map/1))
else
base
end
end
defp stringify_map(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_string(k), stringify_value(v)} end)
end
defp stringify_map(other), do: other
defp stringify_value(v) when is_atom(v) and not is_nil(v) and not is_boolean(v),
do: ":#{v}"
defp stringify_value(v), do: v
end