Current section

Files

Jump to
tidewave lib tidewave mcp tools fs.ex
Raw

lib/tidewave/mcp/tools/fs.ex

defmodule Tidewave.MCP.Tools.FS do
@moduledoc false
alias Tidewave.MCP
alias Tidewave.MCP.GitLS
alias Tidewave.MCP.Utils
def tools do
[
%{
name: "list_project_files",
description: """
Returns a list of files in the project.
By default, when no arguments are passed, it returns all files in the project that
are not ignored by .gitignore.
Optionally, a glob_pattern can be passed to filter this list.
""",
inputSchema: %{
type: "object",
properties: %{
glob_pattern: %{
type: "string",
description: "Optional: a glob pattern to filter the listed files."
},
include_ignored: %{
type: "boolean",
description:
"Optional: whether to include files that are ignored by .gitignore. Defaults to false. WARNING: Use with targeted glob patterns to avoid listing excessive files from dependencies or build directories."
}
},
required: []
},
callback: &list_project_files/1,
listable: &listable/1
},
%{
name: "read_project_file",
description: """
Returns the contents of the given file.
Supports an optional line_offset and count. To read the full file, only the path needs to be passed.
For security reasons, this tool only works for files that are relative to the project root: #{MCP.root()}.
""",
inputSchema: %{
type: "object",
required: ["path"],
properties: %{
path: %{
type: "string",
description: "The path to the file to read. It is relative to the project root."
},
line_offset: %{
type: "integer",
description: "Optional: the starting line offset from which to read. Defaults to 0."
},
count: %{
type: "integer",
description: "Optional: the number of lines to read. Defaults to all."
}
}
},
callback: &read_project_file/1,
listable: &listable/1
},
%{
name: "write_project_file",
description: """
Writes a file to the file system. If the file already exists, it will be overwritten.
Before writing to a file, ensure it was read using the `read_project_file` tool.
""",
inputSchema: %{
type: "object",
required: ["path", "content"],
properties: %{
path: %{
type: "string",
description: "The path to the file to write. It is relative to the project root."
},
content: %{
type: "string",
description: "The content to write to the file"
}
}
},
callback: &write_project_file/2,
listable: &listable/1
},
%{
name: "edit_project_file",
description: """
A tool for editing parts of a file. It can find and replace text inside a file.
For moving or deleting files, use the shell_eval tool with 'mv' or 'rm' instead.
For large edits, use the write_project_file tool instead and overwrite the entire file.
Before editing, ensure to read the source file using the read_project_file tool.
To use this tool, provide the path to the file, the old_string to search for, and the new_string to replace it with.
If the old_string is found multiple times, an error will be returned. To ensure uniqueness, include a couple of lines
before and after the edit. All whitespace must be preserved as in the original file.
This tool can only do a single edit at a time. If you need to make multiple edits, you can create a message with
multiple tool calls to this tool, ensuring that each one contains enough context to uniquely identify the edit.
""",
inputSchema: %{
type: "object",
required: ["path", "old_string", "new_string"],
properties: %{
path: %{
type: "string",
description: "The path to the file to edit. It is relative to the project root."
},
old_string: %{
type: "string",
description: "The string to search for"
},
new_string: %{
type: "string",
description: "The string to replace the old_string with"
}
}
},
callback: &edit_project_file/2,
listable: &listable/1
}
]
end
defp listable(connect_params) do
not is_nil(connect_params["include_fs_tools"])
end
def list_project_files(args) do
glob_pattern = Map.get(args, "glob_pattern")
include_ignored = Map.get(args, "include_ignored", false)
opts = [glob: glob_pattern, include_ignored: include_ignored]
git_ls_files(opts)
end
defp git_ls_files(opts) do
with {:ok, files} <- GitLS.list_files(opts) do
case files do
[] ->
{:ok, "No files found."}
files ->
{:ok, Enum.join(files, "\n")}
end
end
end
def read_project_file(args) do
case args do
%{"path" => path} ->
line_offset = Map.get(args, "line_offset", 0)
count = Map.get(args, "count")
with {:ok, content} <- get_file_content(path, line_offset, count, !args["raw"]) do
stat = File.stat!(path, time: :posix)
{:ok, content, %{mtime: stat.mtime}}
end
_ ->
{:error, :invalid_arguments}
end
end
defp safe_path(path) do
case Path.relative_to(path, MCP.root()) |> Path.safe_relative() do
{:ok, path} ->
{:ok, path}
:error ->
{:error,
"The path is invalid or not relative to the project root. " <>
"Files outside the root cannot be read for security reasons."}
end
end
defp check_stale(path, args, allow_not_found \\ false) do
case File.stat(path, time: :posix) do
{:error, :enoent} ->
if allow_not_found, do: :ok, else: {:error, "File does not exist"}
{:ok, stat} ->
case args do
%{"atime" => posix} when is_integer(posix) and stat.mtime > posix ->
{:error,
"File has been modified since last read. Use read_project_file first to read it again!"}
_ ->
:ok
end
end
end
def write_project_file(args, assigns) do
case args do
%{"path" => path, "content" => content} ->
with {:ok, path} <- safe_path(path),
:ok <- check_stale(path, args, true) do
do_write_file(path, content, assigns)
end
_ ->
{:error, :invalid_arguments}
end
end
def edit_project_file(args, assigns) do
case args do
%{"path" => path, "old_string" => old_string, "new_string" => new_string} ->
with {:ok, path} <- safe_path(path),
:ok <- check_stale(path, args),
old_content = File.read!(path),
:ok <- ensure_one_match(old_content, old_string) do
new_content = String.replace(old_content, old_string, new_string)
do_write_file(path, new_content, assigns)
end
_ ->
{:error, :invalid_arguments}
end
end
defp ensure_one_match(content, substring) do
case :binary.matches(content, substring) do
[_] ->
:ok
[] ->
{:error, "The original substring was not found in the file. No edits were made."}
[_ | _] = matches ->
{:error,
"The substring was found more than once (#{Enum.count(matches)} times) in the file. No edits were made. Ensure uniqueness by providing more context."}
end
end
defp do_write_file(path, content, assigns) do
content =
case Utils.detect_file_line_endings(path) || default_line_endings() do
:crlf -> String.replace(content, ["\r\n", "\n"], "\r\n")
:lf -> content
end
File.mkdir_p!(Path.dirname(path))
try do
content = maybe_autoformat(assigns, path, content)
File.write!(path, content)
stat = File.stat!(path, time: :posix)
{:ok, "Success!", %{mtime: stat.mtime}}
rescue
e -> {:error, "Failed to format file: #{Exception.format(:error, e, __STACKTRACE__)}"}
end
end
defp maybe_autoformat(assigns, path, content) do
if Map.get(assigns, :autoformat, true) do
{fun, _opts} = Mix.Tasks.Format.formatter_for_file(path, root: MCP.root())
fun.(content)
else
content
end
end
# Maximum file size for reading (256KB)
@max_file_size 262_144
defp get_file_content(path, offset, count, truncate?) do
with {:ok, path} <- safe_path(path) do
case File.stat(path) do
{:ok, %{size: size}} when size > @max_file_size ->
{:error,
"File is too large to read (#{size} bytes). Maximum size is #{@max_file_size} bytes."}
{:ok, %{type: :regular}} ->
content = File.read!(path)
if String.valid?(content) do
content = maybe_apply_offset_and_count(content, offset, count)
{:ok, if(truncate?, do: Utils.truncate_lines(content), else: content)}
else
{:error, "Cannot read file, because it contains invalid UTF-8 characters"}
end
{:ok, %{type: other}} ->
{:error, "Cannot read non-regular file: #{other}"}
{:error, :enoent} ->
{:error, "File does not exist"}
{:error, reason} ->
{:error, "Failed to get file stats: #{reason}"}
end
end
end
defp maybe_apply_offset_and_count(content, 0, nil), do: content
defp maybe_apply_offset_and_count(content, offset, count) do
content
|> lines(<<>>)
|> Enum.drop(offset)
|> take_all_or(count)
|> IO.iodata_to_binary()
end
# TODO: Use Code.lines/1 when we require Elixir v1.19+
defp lines(<<?\n, rest::binary>>, acc),
do: [<<acc::binary, ?\n>> | lines(rest, <<>>)]
defp lines(<<char, rest::binary>>, acc),
do: lines(rest, <<acc::binary, char>>)
defp lines(<<>>, acc),
do: [acc]
defp take_all_or(list, nil), do: list
defp take_all_or(list, count), do: Enum.take(list, count)
defp default_line_endings do
if line_endings = Application.get_env(:tidewave, :default_line_endings) do
line_endings
else
default =
case GitLS.detect_line_endings() do
{:ok, default_line_endings} -> default_line_endings
{:error, _} -> :lf
end
Application.put_env(:tidewave, :default_line_endings, default)
default
end
end
end