diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index 3cd0c9d..2ba9ec2 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -204,16 +204,69 @@ defmodule Cleat.MCP.Tools do end defp register_static_app(client, slug, host, server) do - attrs = %{ - "name" => slug, - "slug" => slug, - "host" => host, - "server_id" => server, - "runtime" => "static" - } - - with {:ok, body} <- Client.create_app(client, attrs) do - {:ok, Commands.data(body)["slug"]} + with {:ok, server_id} <- resolve_server_id(client, server) do + attrs = %{ + "name" => slug, + "slug" => slug, + "host" => host, + "server_id" => server_id, + "runtime" => "static" + } + + with {:ok, body} <- Client.create_app(client, attrs) do + {:ok, Commands.data(body)["slug"]} + end + end + end + + defp resolve_server_id(_client, server) when not is_binary(server) or server == "", + do: {:error, "server is required"} + + defp resolve_server_id(client, server) do + trimmed = String.trim(server) + + cond do + trimmed == "" -> + {:error, "server is required"} + + numeric_server_id?(trimmed) -> + {:ok, trimmed} + + true -> + with {:ok, body} <- Client.list_servers(client) do + match_server(List.wrap(Commands.data(body)), trimmed) + end + end + end + + defp numeric_server_id?(value), do: Regex.match?(~r/^\d+$/, value) + + defp match_server(servers, query) do + needle = String.downcase(query) + + found = + Enum.find(servers, fn server -> + id = server |> Map.get("id") |> to_string() |> String.downcase() + name = server |> Map.get("name") |> to_string() |> String.downcase() + slug = server |> Map.get("slug") |> to_string() |> String.downcase() + + id == needle or name == needle or (slug != "" and slug == needle) + end) + + case found do + nil -> + listing = + servers + |> Enum.map(fn server -> "#{Map.get(server, "id")} (#{Map.get(server, "name")})" end) + |> Enum.join(", ") + + listing = if listing == "", do: "none", else: listing + + {:error, + ~s|unknown server "#{query}". Use a servers_list id (e.g. 5) — servers: #{listing}|} + + server -> + {:ok, server |> Map.get("id") |> to_string()} end end @@ -363,14 +416,17 @@ defmodule Cleat.MCP.Tools do %{ "name" => "apps_create", "description" => - "Create an app. runtime_apt_packages installs apt deps on the VM; alternatively commit .cleat_deploy/runtime-packages", + "Create an app. server accepts a servers_list id, name or slug. runtime_apt_packages installs apt deps on the VM; alternatively commit .cleat_deploy/runtime-packages", "inputSchema" => %{ "type" => "object", "properties" => %{ "name" => %{"type" => "string"}, "repo" => %{"type" => "string", "description" => "owner/repo"}, "host" => %{"type" => "string"}, - "server" => %{"type" => "string"}, + "server" => %{ + "type" => "string", + "description" => "Server id, name or slug from servers_list" + }, "runtime" => %{"type" => "string"}, "slug" => %{"type" => "string"}, "branch" => %{"type" => "string"}, @@ -382,22 +438,24 @@ defmodule Cleat.MCP.Tools do "required" => ["name", "repo", "host", "server"] }, "handler" => fn args -> - attrs = - %{ - "name" => args["name"], - "github_repo" => args["repo"], - "host" => args["host"], - "server_id" => args["server"], - "slug" => args["slug"], - "branch" => args["branch"], - "port" => args["port"], - "runtime" => args["runtime"], - "runtime_apt_packages" => args["runtime_apt_packages"] - } - |> Map.reject(fn {_k, v} -> is_nil(v) or v == "" end) - with_client(args, fn client -> - with {:ok, body} <- Client.create_app(client, attrs), do: {:ok, data_text(body)} + with {:ok, server_id} <- resolve_server_id(client, args["server"]) do + attrs = + %{ + "name" => args["name"], + "github_repo" => args["repo"], + "host" => args["host"], + "server_id" => server_id, + "slug" => args["slug"], + "branch" => args["branch"], + "port" => args["port"], + "runtime" => args["runtime"], + "runtime_apt_packages" => args["runtime_apt_packages"] + } + |> Map.reject(fn {_k, v} -> is_nil(v) or v == "" end) + + with {:ok, body} <- Client.create_app(client, attrs), do: {:ok, data_text(body)} + end end) end }, diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index e8ef788..a826a2d 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -243,6 +243,109 @@ defmodule Cleat.MCP.ToolsTest do }) end + test "apps_create resolves a server name via servers_list before posting" do + Req.Test.stub(__MODULE__, fn conn -> + case {conn.method, conn.request_path} do + {"GET", "/api/v1/servers"} -> + Req.Test.json(conn, %{ + "data" => [ + %{"id" => 5, "name" => "gestaobem-cx33"}, + %{"id" => 6, "name" => "chatwoot-e2e"} + ] + }) + + {"POST", "/api/v1/apps"} -> + body = Jason.decode!(Req.Test.raw_body(conn)) + assert body["server_id"] == "5" + + Req.Test.json(conn, %{"data" => %{"id" => 1, "slug" => "cotacao-passagens"}}) + end + end) + + assert {:ok, text} = + Tools.call("apps_create", %{ + "name" => "Cotação de Passagens", + "repo" => "gestao-bem/cotacao-passagens", + "host" => "cotacao.gestaobem.com", + "server" => "gestaobem-cx33", + "runtime" => "node", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert Jason.decode!(text)["slug"] == "cotacao-passagens" + end + + test "apps_create lists known servers when the name does not match" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "GET" + assert conn.request_path == "/api/v1/servers" + + Req.Test.json(conn, %{ + "data" => [ + %{"id" => 5, "name" => "gestaobem-cx33"}, + %{"id" => 6, "name" => "chatwoot-e2e"} + ] + }) + end) + + assert {:error, message} = + Tools.call("apps_create", %{ + "name" => "landing", + "repo" => "owner/site", + "host" => "landing.example.com", + "server" => "production", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert message =~ "production" + assert message =~ "5 (gestaobem-cx33)" + assert message =~ "6 (chatwoot-e2e)" + end + + test "drop resolves a server name when registering a static app" do + dir = temp_drop_dir() + slug = dir |> Path.basename() |> slugify() + drops_path = "/api/v1/apps/#{slug}/drops" + + Req.Test.stub(__MODULE__, fn conn -> + case {conn.method, conn.request_path} do + {"GET", "/api/v1/apps"} -> + Req.Test.json(conn, %{"data" => []}) + + {"GET", "/api/v1/servers"} -> + Req.Test.json(conn, %{ + "data" => [%{"id" => 5, "name" => "gestaobem-cx33"}] + }) + + {"POST", "/api/v1/apps"} -> + body = Jason.decode!(Req.Test.raw_body(conn)) + assert body["server_id"] == "5" + + conn + |> Plug.Conn.put_status(201) + |> Req.Test.json(%{"data" => %{"id" => 5, "slug" => slug}}) + + {"POST", ^drops_path} -> + conn + |> Plug.Conn.put_status(201) + |> Req.Test.json(%{"data" => %{"id" => 9, "status" => "queued"}}) + end + end) + + assert {:ok, text} = + Tools.call("drop", %{ + "path" => dir, + "server" => "gestaobem-cx33", + "host" => "new.example.com", + "panel" => "https://panel.test", + "token" => "tok" + }) + + assert Jason.decode!(text)["id"] == 9 + end + test "apps_create posts runtime_apt_packages" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "POST"