Repository: tidewave-ai/tidewave_phoenix
Pull request #72 (2025-05-12 16:32:35)

return mtime in fs result _meta and accept hidden atime
Closes https://github.com/tidewave-ai/tidewave_phoenix/issues/67.

As we are still stateful for now, I kept the old behavior as in. It should be fine to only remove once we support the streamable transport. Wdyt? @josevalim 


Patch:
diff --git a/lib/tidewave/mcp/connection.ex b/lib/tidewave/mcp/connection.ex
index 779fe95..e01e292 100644
--- a/lib/tidewave/mcp/connection.ex
+++ b/lib/tidewave/mcp/connection.ex
@@ -185,6 +185,9 @@ defmodule Tidewave.MCP.Connection do
         {:ok, result, new_assigns} ->
           {:reply, {:ok, result}, %{state | assigns: new_assigns}}
 
+        {:ok, result, new_assigns, metadata} ->
+          {:reply, {:ok, result, metadata}, %{state | assigns: new_assigns}}
+
         {:error, reason, new_assigns} ->
           {:reply, {:error, reason}, %{state | assigns: new_assigns}}
 
diff --git a/lib/tidewave/mcp/server.ex b/lib/tidewave/mcp/server.ex
index 70a0f84..4f81f5b 100644
--- a/lib/tidewave/mcp/server.ex
+++ b/lib/tidewave/mcp/server.ex
@@ -59,6 +59,8 @@ defmodule Tidewave.MCP.Server do
   #
   #   * `{:ok, result}` if the callback does not receive state
   #   * `{:ok, result, new_state}` if the callback receives state (i.e. if it is of arity 2)
+  #   * `{:ok, result, metadata}` if the callback is of arity 1 and returns metadata (returned as `_meta`)
+  #   * `{:ok, result, new_state, metadata}` if the callback is of arity 2 and returns metadata (returned as `_meta`)
   #   * `{:error, reason}` for any error
   #   * `{:error, reason, new_state}` for any error that should also update the state
   #
@@ -129,6 +131,11 @@ defmodule Tidewave.MCP.Server do
     result_or_error(request_id, dispatch(name, args, state_pid))
   end
 
+  defp result_or_error(request_id, {:ok, text, metadata})
+       when is_binary(text) and is_map(metadata) do
+    result_or_error(request_id, {:ok, %{content: [%{type: "text", text: text}], _meta: metadata}})
+  end
+
   defp result_or_error(request_id, {:ok, text}) when is_binary(text) do
     result_or_error(request_id, {:ok, %{content: [%{type: "text", text: text}]}})
   end
diff --git a/lib/tidewave/mcp/tools/fs.ex b/lib/tidewave/mcp/tools/fs.ex
index e9054e5..c02faf7 100644
--- a/lib/tidewave/mcp/tools/fs.ex
+++ b/lib/tidewave/mcp/tools/fs.ex
@@ -69,7 +69,7 @@ defmodule Tidewave.MCP.Tools.FS do
         description: """
         Writes a file to the file system. If the file already exists, it will be overwritten.
 
-        Note that this tool will fail if the file wasn't previously read with the `read_project_file` tool.
+        Before writing to a file, ensure it was read using the `read_project_file` tool.
         """,
         inputSchema: %{
           type: "object",
@@ -192,7 +192,7 @@ defmodule Tidewave.MCP.Tools.FS do
         count = Map.get(args, "count")
 
         with {:ok, content} <- get_file_content(path, line_offset, count, !args["raw"]) do
-          stat = File.stat!(path)
+          stat = File.stat!(path, time: :posix)
 
           assigns =
             Map.update(
@@ -202,7 +202,7 @@ defmodule Tidewave.MCP.Tools.FS do
               &Map.put(&1, path, stat.mtime)
             )
 
-          {:ok, content, assigns}
+          {:ok, content, assigns, %{mtime: stat.mtime}}
         end
 
       _ ->
@@ -223,7 +223,7 @@ defmodule Tidewave.MCP.Tools.FS do
   end
 
   defp check_stale(path, read_timestamps, allow_not_found \\ false) do
-    case File.stat(path) do
+    case File.stat(path, time: :posix) do
       {:error, :enoent} ->
         if allow_not_found, do: :ok, else: {:error, "File does not exist"}
 
@@ -246,7 +246,7 @@ defmodule Tidewave.MCP.Tools.FS do
   def write_project_file(args, assigns) do
     case args do
       %{"path" => path, "content" => content} ->
-        read_timestamps = Map.get(assigns, :read_timestamps, %{})
+        read_timestamps = timestamps_from_args_or_assigns(args, assigns)
 
         with {:ok, path} <- safe_path(path),
              :ok <- check_stale(path, read_timestamps, true) do
@@ -264,7 +264,7 @@ defmodule Tidewave.MCP.Tools.FS do
       ) do
     case args do
       %{"path" => path, "old_string" => old_string, "new_string" => new_string} ->
-        read_timestamps = Map.get(assigns, :read_timestamps, %{})
+        read_timestamps = timestamps_from_args_or_assigns(args, assigns)
 
         with {:ok, path} <- safe_path(path),
              :ok <- check_stale(path, read_timestamps),
@@ -279,6 +279,18 @@ defmodule Tidewave.MCP.Tools.FS do
     end
   end
 
+  defp timestamps_from_args_or_assigns(args, assigns) do
+    read_timestamps = Map.get(assigns, :read_timestamps, %{})
+
+    case args do
+      %{"atime" => posix} when is_integer(posix) ->
+        Map.put(read_timestamps, args["path"], posix)
+
+      _ ->
+        read_timestamps
+    end
+  end
+
   defp ensure_one_match(content, substring) do
     case :binary.matches(content, substring) do
       [_] ->
@@ -307,7 +319,7 @@ defmodule Tidewave.MCP.Tools.FS do
     try do
       content = maybe_autoformat(assigns, path, content)
       File.write!(path, content)
-      stat = File.stat!(path)
+      stat = File.stat!(path, time: :posix)
 
       assigns =
         Map.update(
@@ -317,7 +329,7 @@ defmodule Tidewave.MCP.Tools.FS do
           &Map.put(&1, path, stat.mtime)
         )
 
-      {:ok, "Success!", assigns}
+      {:ok, "Success!", assigns, %{mtime: stat.mtime}}
     rescue
       e -> {:error, "Failed to format file: #{Exception.format(:error, e, __STACKTRACE__)}"}
     end


Test patch:
diff --git a/test/mcp/tools/fs_test.exs b/test/mcp/tools/fs_test.exs
index fc24cad..b464a90 100644
--- a/test/mcp/tools/fs_test.exs
+++ b/test/mcp/tools/fs_test.exs
@@ -48,10 +48,11 @@ defmodule Tidewave.MCP.Tools.FSTest do
     end
 
     test "successfully reads an existing file", %{path: path, content: content} do
-      assert {:ok, ^content, new_state} =
+      assert {:ok, ^content, new_state, metadata} =
                FS.read_project_file(%{"path" => path}, %{})
 
       assert %{read_timestamps: %{^path => _}} = new_state
+      assert %{mtime: _} = metadata
     end
 
     test "returns error for non-existent file" do
@@ -75,7 +76,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
 
       on_exit(fn -> File.rm(file_path) end)
 
-      assert {:ok, truncated_content, _} =
+      assert {:ok, truncated_content, _state, _metadata} =
                FS.read_project_file(%{"path" => file_path}, %{})
 
       assert truncated_content =~ "a [100 characters truncated] ..."
@@ -89,7 +90,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
 
       on_exit(fn -> File.rm(file_path) end)
 
-      assert {:ok, read_content, _} =
+      assert {:ok, read_content, _state, _metadata} =
                FS.read_project_file(%{"path" => file_path, "raw" => true}, %{})
 
       refute read_content =~ "a [100 characters truncated] ..."
@@ -102,16 +103,17 @@ defmodule Tidewave.MCP.Tools.FSTest do
       content = Enum.map_join(1..1000, "\n", &to_string/1) <> "\nmore content\n"
       File.write!(file_path, content)
 
-      assert {:ok, "1\n2\n3\n4\n5\n6", _state} =
+      assert {:ok, "1\n2\n3\n4\n5\n6", _state, _metadata} =
                FS.read_project_file(%{"path" => file_path, "count" => 6}, %{})
 
-      assert {:ok, "501\n502\n503\n504\n505", _state} =
+      assert {:ok, "501\n502\n503\n504\n505", _state, _metadata} =
                FS.read_project_file(
                  %{"path" => file_path, "line_offset" => 500, "count" => 5},
                  %{}
                )
 
-      assert {:ok, "991\n992\n993\n994\n995\n996\n997\n998\n999\n1000\nmore content\n", _state} =
+      assert {:ok, "991\n992\n993\n994\n995\n996\n997\n998\n999\n1000\nmore content\n", _state,
+              _metadata} =
                FS.read_project_file(%{"path" => file_path, "line_offset" => 990}, %{})
     end
   end
@@ -130,7 +132,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
     end
 
     test "successfully writes to a file", %{path: path, content: content} do
-      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}, %{mtime: _}} =
                FS.write_project_file(%{"path" => path, "content" => content}, %{})
 
       assert File.read!(path) == content
@@ -142,12 +144,26 @@ defmodule Tidewave.MCP.Tools.FSTest do
       assert {:error, message} =
                FS.write_project_file(
                  %{"path" => path, "content" => content},
-                 %{read_timestamps: %{path => {{2025, 01, 01}, {0, 0, 0}}}}
+                 %{read_timestamps: %{path => 0}}
                )
 
       assert message =~ "File has been modified since last read"
     end
 
+    test "prefers given atime", %{path: path, content: content} do
+      File.write!(path, "Modified content")
+      stat = File.stat!(path, time: :posix)
+
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}, %{mtime: new_mtime}} =
+               FS.write_project_file(
+                 %{"path" => path, "content" => content, "atime" => stat.mtime},
+                 %{}
+               )
+
+      stat = File.stat!(path, time: :posix)
+      assert new_mtime == stat.mtime
+    end
+
     test "fails when file was not read yet", %{path: path, content: content} do
       File.write!(path, "Modified content")
 
@@ -169,7 +185,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
 
       path = Path.relative_to_cwd(file_path)
 
-      assert {:ok, _result, %{read_timestamps: %{^path => _}}} =
+      assert {:ok, _result, %{read_timestamps: %{^path => _}}, _metadata} =
                FS.write_project_file(%{"path" => file_path, "content" => content}, %{})
 
       assert File.exists?(file_path)
@@ -197,7 +213,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
       end
       """
 
-      assert {:ok, "Success!", %{read_timestamps: %{^file_path => _}}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^file_path => _}}, %{mtime: _}} =
                FS.write_project_file(%{"path" => file_path, "content" => content}, %{})
 
       assert File.read!(file_path) == """
@@ -228,7 +244,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
       end
       """
 
-      assert {:ok, "Success!", %{read_timestamps: %{^file_path => _}}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^file_path => _}}, %{mtime: _}} =
                FS.write_project_file(%{"path" => file_path, "content" => content}, %{
                  autoformat: false
                })
@@ -239,7 +255,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
     test "writes with crlf line endings if default line endings are crlf", %{path: path} do
       content = "Hello\nWorld"
 
-      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}, %{mtime: _}} =
                FS.write_project_file(%{"path" => path, "content" => content}, %{
                  default_line_endings: :crlf
                })
@@ -253,7 +269,8 @@ defmodule Tidewave.MCP.Tools.FSTest do
       content = "Hello\nWorld"
 
       # this repo uses LF
-      assert {:ok, "Success!", %{read_timestamps: %{^path => _}, default_line_endings: :lf}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}, default_line_endings: :lf},
+              _metadata} =
                FS.write_project_file(%{"path" => path, "content" => content}, %{})
 
       assert File.read!(path) == content
@@ -291,7 +308,7 @@ defmodule Tidewave.MCP.Tools.FSTest do
         end
       """
 
-      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}} =
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}, %{mtime: _}} =
                FS.edit_project_file(
                  %{"path" => path, "old_string" => old_string, "new_string" => new_string},
                  %{read_timestamps: %{path => File.stat!(path).mtime}}
@@ -306,12 +323,31 @@ defmodule Tidewave.MCP.Tools.FSTest do
       assert {:error, message} =
                FS.edit_project_file(
                  %{"path" => path, "old_string" => "foo", "new_string" => "bar"},
-                 %{read_timestamps: %{path => {{2025, 01, 01}, {0, 0, 0}}}}
+                 %{read_timestamps: %{path => 0}}
                )
 
       assert message =~ "File has been modified since last read"
     end
 
+    test "prefers given atime", %{path: path} do
+      File.write!(path, "Modified content")
+      stat = File.stat!(path, time: :posix)
+
+      assert {:ok, "Success!", %{read_timestamps: %{^path => _}}, %{mtime: new_mtime}} =
+               FS.edit_project_file(
+                 %{
+                   "path" => path,
+                   "old_string" => "Modified",
+                   "new_string" => "New",
+                   "atime" => stat.mtime
+                 },
+                 %{read_timestamps: %{}}
+               )
+
+      stat = File.stat!(path, time: :posix)
+      assert new_mtime == stat.mtime
+    end
+
     test "fails when patch cannot be applied", %{path: path} do
       test_content = """
       defmodule Foo do
diff --git a/test/mcp_integration_test.exs b/test/mcp_integration_test.exs
index dc18562..86655aa 100644
--- a/test/mcp_integration_test.exs
+++ b/test/mcp_integration_test.exs
@@ -115,6 +115,49 @@ defmodule Tidewave.MCPIntegrationTest do
     assert "Hello, world! again" = File.read!("test.txt")
   end
 
+  test "fs tools return mtime as metadata", %{request: request} do
+    assert %{resp: resp, endpoint_url: endpoint_url} = request
+
+    on_exit(fn ->
+      File.rm("test.txt")
+    end)
+
+    File.write!("test.txt", "Hello, world!")
+
+    write_id = System.unique_integer([:positive])
+
+    send_message(endpoint_url, %{
+      "jsonrpc" => "2.0",
+      "id" => write_id,
+      "method" => "tools/call",
+      "params" => %{
+        "name" => "write_project_file",
+        "arguments" => %{
+          "path" => "test.txt",
+          "content" => "Hello, world! again",
+          "atime" => File.stat!("test.txt", time: :posix).mtime
+        }
+      }
+    })
+
+    assert %{
+             data: %{
+               "id" => ^write_id,
+               "result" => %{
+                 "content" => [
+                   %{"text" => "Success!", "type" => "text"}
+                 ],
+                 "_meta" => %{
+                   "mtime" => mtime
+                 }
+               }
+             }
+           } = receive_sse_message(resp)
+
+    assert "Hello, world! again" = File.read!("test.txt")
+    assert mtime == File.stat!("test.txt", time: :posix).mtime
+  end
+
   test "stores logs but ignores logs from MCP itself", %{request: request} do
     assert %{resp: resp, endpoint_url: endpoint_url, tools: tools} = request