Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 85 additions & 27 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"},
Expand All @@ -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
},
Expand Down
103 changes: 103 additions & 0 deletions test/cleat/mcp/tools_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading