Repository: tidewave-ai/tidewave_phoenix (elixir)

Issue:
Add timeout for project_eval
I think we should evaluate inside a separate process to allow killing it after a timeout.

Pull request description:
evaluate in separate process
Closes #15.

Patch:
diff --git a/lib/tidewave/mcp/tools/eval.ex b/lib/tidewave/mcp/tools/eval.ex
index 0c0a286..e2e6c85 100644
--- a/lib/tidewave/mcp/tools/eval.ex
+++ b/lib/tidewave/mcp/tools/eval.ex
@@ -28,6 +28,13 @@ defmodule Tidewave.MCP.Tools.Eval do
             code: %{
               type: "string",
               description: "The Elixir code to evaluate."
+            },
+            timeout: %{
+              type: "integer",
+              description: """
+              Optional. A timeout in milliseconds after which the execution stops if it did not finish yet.
+              Defaults to 30000 (30 seconds).
+              """
             }
           }
         },
@@ -69,22 +76,56 @@ defmodule Tidewave.MCP.Tools.Eval do
   """
   def project_eval(args) do
     case args do
-      %{"code" => code} ->
-        result =
-          case capture_io(fn ->
-                 {result, _bindings} = Code.eval_string(code, [], env())
-                 result
-               end) do
-            # this is returned by IEx helpers
-            {:"do not show this result in output", io} -> io
-            {result, ""} -> result
-            {result, io} -> %{result: result, io: io}
-          end
-
-        {:ok, inspect(result, limit: :infinity, printable_limit: :infinity, pretty: true)}
+      %{"code" => code} -> eval_code(code, Map.get(args, "timeout", 30_000))
+      _ -> {:error, :invalid_arguments}
+    end
+  end
 
-      _ ->
-        {:error, :invalid_arguments}
+  defp eval_code(code, timeout) do
+    parent = self()
+
+    {pid, ref} =
+      spawn_monitor(fn ->
+        # we need to set the logger metadata again
+        Logger.metadata(tidewave_mcp: true)
+        send(parent, {:result, eval_with_captured_io(code)})
+      end)
+
+    receive do
+      {:result, result} ->
+        {:ok, result}
+
+      {:DOWN, ^ref, :process, ^pid, reason} ->
+        {:error,
+         "Failed to evaluate code. Process exited with reason: #{Exception.format_exit(reason)}"}
+    after
+      timeout ->
+        Process.demonitor(ref, [:flush])
+        Process.exit(pid, :brutal_kill)
+        {:error, "Evaluation timed out after #{timeout} milliseconds."}
+    end
+  end
+
+  defp eval_with_captured_io(code) do
+    result =
+      capture_io(fn ->
+        try do
+          {result, _bindings} = Code.eval_string(code, [], env())
+          result
+        catch
+          kind, reason -> Exception.format(kind, reason, __STACKTRACE__)
+        end
+      end)
+
+    inspect_all = fn i ->
+      inspect(i, limit: :infinity, printable_limit: :infinity, pretty: true)
+    end
+
+    case result do
+      # this is returned by IEx helpers
+      {:"do not show this result in output", io} -> io
+      {result, ""} -> inspect_all.(result)
+      {result, io} -> "IO:\n\n#{io}\n\nResult:\n\n#{inspect_all.(result)}"
     end
   end
 
diff --git a/mix.exs b/mix.exs
index 3d4b920..b7dbf51 100644
--- a/mix.exs
+++ b/mix.exs
@@ -8,6 +8,7 @@ defmodule Tidewave.MixProject do
       elixir: "~> 1.15",
       start_permanent: Mix.env() == :prod,
       deps: deps(),
+      package: package(),
 
       # Docs
       name: "Tidewave",
@@ -24,6 +25,15 @@ defmodule Tidewave.MixProject do
     ]
   end
 
+  defp package do
+    [
+      description: "Tidewave for Phoenix",
+      maintainers: ["Steffen Deusch"],
+      licenses: ["Apache-2.0"],
+      links: %{"Tidewave" => "https://tidewave.ai"}
+    ]
+  end
+
   defp deps do
     [
       {:plug, "~> 1.17"},


Test patch:
diff --git a/test/mcp/tools/eval_test.exs b/test/mcp/tools/eval_test.exs
index 46249dc..2d0980d 100644
--- a/test/mcp/tools/eval_test.exs
+++ b/test/mcp/tools/eval_test.exs
@@ -36,12 +36,12 @@ defmodule Tidewave.MCP.Tools.EvalTest do
       assert {:ok, "42"} = Eval.project_eval(%{"code" => code})
     end
 
-    test "raises for errors in Elixir code" do
+    test "returns formatted errors for exceptions" do
       code = "1 / 0"
 
-      assert_raise ArithmeticError, fn ->
-        Eval.project_eval(%{"code" => code})
-      end
+      assert {:ok, error} = Eval.project_eval(%{"code" => code})
+      assert error =~ "ArithmeticError"
+      assert error =~ "bad argument in arithmetic expression"
     end
 
     test "can use IEx helpers" do
@@ -51,6 +51,22 @@ defmodule Tidewave.MCP.Tools.EvalTest do
 
       assert docs =~ "Tidewave"
     end
+
+    test "catches exits" do
+      assert {:error, "Failed to evaluate code. Process exited with reason: :brutal_kill"} =
+               Eval.project_eval(%{"code" => "Process.exit(self(), :brutal_kill)"})
+    end
+
+    test "times out" do
+      assert {:error, "Evaluation timed out after 50 milliseconds."} =
+               Eval.project_eval(%{"code" => "Process.sleep(10_000)", "timeout" => 50})
+    end
+
+    test "returns IO up to exception" do
+      assert {:ok, result} = Eval.project_eval(%{"code" => ~s[IO.puts("Hello!"); 1 / 0]})
+      assert result =~ "Hello!"
+      assert result =~ "ArithmeticError"
+    end
   end
 
   describe "shell_eval/1" do