Skip to content

Commit 3aed4c6

Browse files
committed
Add write_file native tool
1 parent 2d0c3ec commit 3aed4c6

3 files changed

Lines changed: 90 additions & 59 deletions

File tree

docs/capabilities.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ Some native tools like `filesystem` have MCP alternatives, but ECA having them b
1111
Provides access to filesystem under workspace root, listing and reading files and directories a subset of [official MCP filesystem](https://mcpserverhub.com/servers/filesystem), important for agentic operations, without the need to support NPM or other tools.
1212

1313
- `read_file`: read a file content.
14+
- `write_file`: write content to file.
15+
- `move_file`: move/rename a file.
1416
- `list_directory`: list a directory.
1517
- `search_files`: search in a path for files matching a pattern.
1618
- `grep`: ripgrep/grep for paths with specified content.
1719
- `replace_in_file`: replace a text with another one in file.
18-
- `move_file`: move/rename a file.
1920

2021
### TODO - Shell
2122

src/eca/features/tools/filesystem.clj

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@
5656
(string/join "\n")))]
5757
(single-text-content content))))
5858

59+
(defn ^:private write-file [arguments db]
60+
(or (invalid-arguments arguments [["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
61+
(let [path (get arguments "path")
62+
content (get arguments "content")]
63+
(fs/create-dirs path)
64+
(spit path content)
65+
(single-text-content (format "Successfully wrote to %s" path)))))
66+
5967
(defn ^:private search-files [arguments db]
6068
(or (invalid-arguments arguments (concat (path-validations db)
6169
[["pattern" #(not (string/blank? %)) "Invalid glob pattern '$pattern'"]]))
@@ -222,6 +230,31 @@
222230
:description "If provided, returns only the last N lines of the file"}}
223231
:required ["path"]}
224232
:handler #'read-file}
233+
"write_file"
234+
{:description (str "Create a new file or completely overwrite an existing file with new content. " +
235+
"Use with caution as it will overwrite existing files without warning. " +
236+
"Handles text content with proper encoding. "
237+
"**Only works within the directories: $workspaceRoots.**")
238+
:parameters {:type "object"
239+
:properties {"path" {:type "string"
240+
:description "The absolute path to the new file"}
241+
"content" {:type "string"
242+
:description "The content of the new file"}}
243+
:required ["path" "content"]}
244+
:handler #'write-file}
245+
"move_file"
246+
{:description (str "Move or rename files and directories. Can move files between directories "
247+
"and rename them in a single operation. If the destination exists, the "
248+
"operation will fail. Works across different directories and can be used "
249+
"for simple renaming within the same directory. "
250+
"Both source and destination must be within the directories: $workspaceRoots.")
251+
:parameters {:type "object"
252+
:properties {"source" {:type "string"
253+
:description "The absolute origin file path to move."}
254+
"destination" {:type "string"
255+
:description "The new absolute file path to move to."}}
256+
:required ["source" "destination"]}
257+
:handler #'move-file}
225258
"search_files"
226259
{:description (str "Recursively search for files and directories matching a pattern. "
227260
"Searches through all subdirectories from the starting path. The search "
@@ -270,20 +303,4 @@
270303
"all_occurrences" {:type "boolean"
271304
:description "Whether to replace all occurences of the file or just the first one (default)"}}
272305
:required ["path" "original_content" "new_content"]}
273-
:handler #'replace-in-file}
274-
"move_file"
275-
{:description (str "Move or rename files and directories. Can move files between directories "
276-
"and rename them in a single operation. If the destination exists, the "
277-
"operation will fail. Works across different directories and can be used "
278-
"for simple renaming within the same directory. "
279-
"Both source and destination must be within the directories: $workspaceRoots.")
280-
:parameters {:type "object"
281-
:properties {"source" {:type "string"
282-
:description "The absolute origin file path to move."}
283-
"destination" {:type "string"
284-
:description "The new absolute file path to move to."}}
285-
:required ["source" "destination"]}
286-
:handler #'move-file}
287-
;; TODO write-file
288-
;; TODO delete-files
289-
})
306+
:handler #'replace-in-file}})

test/eca/features/tools/filesystem_test.clj

Lines changed: 54 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -103,56 +103,69 @@
103103
"tail" 2}
104104
{:workspace-folders [{:uri (h/file-uri "file:///foo/bar/baz") :name "foo"}]}))))))
105105

106-
(deftest search-files-test
107-
(testing "invalid pattern"
106+
(deftest write-file-test
107+
(testing "Not allowed path"
108108
(is (match?
109109
{:contents [{:type :text
110110
:error true
111-
:content "Invalid glob pattern ' '"}]}
112-
(with-redefs [fs/exists? (constantly true)]
113-
((get-in f.tools.filesystem/definitions ["search_files" :handler])
114-
{"path" (h/file-path "/project/foo")
115-
"pattern" " "}
116-
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
111+
:content (format "Access denied - path %s outside allowed directories: %s"
112+
(h/file-path "/foo/qux/new_file.clj")
113+
(h/file-path "/foo/bar"))}]}
114+
(with-redefs [f.tools.filesystem/allowed-path? (constantly false)]
115+
((get-in f.tools.filesystem/definitions ["write_file" :handler])
116+
{"path" (h/file-path "/foo/qux/new_file.clj")}
117+
{:workspace-folders [{:uri (h/file-uri "file:///foo/bar") :name "bar"}]}))))))
118+
119+
(deftest search-files-test
120+
(testing "invalid pattern"
121+
(is (match?
122+
{:contents [{:type :text
123+
:error true
124+
:content "Invalid glob pattern ' '"}]}
125+
(with-redefs [fs/exists? (constantly true)]
126+
((get-in f.tools.filesystem/definitions ["search_files" :handler])
127+
{"path" (h/file-path "/project/foo")
128+
"pattern" " "}
129+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
117130
(testing "no matches"
118131
(is (match?
119-
{:contents [{:type :text
120-
:error false
121-
:content "No matches found"}]}
122-
(with-redefs [fs/exists? (constantly true)
123-
fs/glob (constantly [])]
124-
((get-in f.tools.filesystem/definitions ["search_files" :handler])
125-
{"path" (h/file-path "/project/foo")
126-
"pattern" "foo"}
127-
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
132+
{:contents [{:type :text
133+
:error false
134+
:content "No matches found"}]}
135+
(with-redefs [fs/exists? (constantly true)
136+
fs/glob (constantly [])]
137+
((get-in f.tools.filesystem/definitions ["search_files" :handler])
138+
{"path" (h/file-path "/project/foo")
139+
"pattern" "foo"}
140+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
128141
(testing "matches with wildcard"
129142
(is (match?
130-
{:contents [{:type :text
131-
:error false
132-
:content (str (h/file-path "/project/foo/bar/baz.txt") "\n"
133-
(h/file-path "/project/foo/qux.txt") "\n"
134-
(h/file-path "/project/foo/qux.clj"))}]}
135-
(with-redefs [fs/exists? (constantly true)
136-
fs/glob (constantly [(fs/path (h/file-path "/project/foo/bar/baz.txt"))
137-
(fs/path (h/file-path "/project/foo/qux.txt"))
138-
(fs/path (h/file-path "/project/foo/qux.clj"))])]
139-
((get-in f.tools.filesystem/definitions ["search_files" :handler])
140-
{"path" (h/file-path "/project/foo")
141-
"pattern" "**"}
142-
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
143+
{:contents [{:type :text
144+
:error false
145+
:content (str (h/file-path "/project/foo/bar/baz.txt") "\n"
146+
(h/file-path "/project/foo/qux.txt") "\n"
147+
(h/file-path "/project/foo/qux.clj"))}]}
148+
(with-redefs [fs/exists? (constantly true)
149+
fs/glob (constantly [(fs/path (h/file-path "/project/foo/bar/baz.txt"))
150+
(fs/path (h/file-path "/project/foo/qux.txt"))
151+
(fs/path (h/file-path "/project/foo/qux.clj"))])]
152+
((get-in f.tools.filesystem/definitions ["search_files" :handler])
153+
{"path" (h/file-path "/project/foo")
154+
"pattern" "**"}
155+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]})))))
143156
(testing "matches without wildcard"
144157
(is (match?
145-
{:contents [{:type :text
146-
:error false
147-
:content (str (h/file-path "/project/foo/bar/baz.txt") "\n"
148-
(h/file-path "/project/foo/qux.txt"))}]}
149-
(with-redefs [fs/exists? (constantly true)
150-
fs/glob (constantly [(fs/path (h/file-path "/project/foo/bar/baz.txt"))
151-
(fs/path (h/file-path "/project/foo/qux.txt"))])]
152-
((get-in f.tools.filesystem/definitions ["search_files" :handler])
153-
{"path" (h/file-path "/project/foo")
154-
"pattern" ".txt"}
155-
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}))))))
158+
{:contents [{:type :text
159+
:error false
160+
:content (str (h/file-path "/project/foo/bar/baz.txt") "\n"
161+
(h/file-path "/project/foo/qux.txt"))}]}
162+
(with-redefs [fs/exists? (constantly true)
163+
fs/glob (constantly [(fs/path (h/file-path "/project/foo/bar/baz.txt"))
164+
(fs/path (h/file-path "/project/foo/qux.txt"))])]
165+
((get-in f.tools.filesystem/definitions ["search_files" :handler])
166+
{"path" (h/file-path "/project/foo")
167+
"pattern" ".txt"}
168+
{:workspace-folders [{:uri (h/file-uri "file:///project/foo") :name "foo"}]}))))))
156169

157170
(deftest grep-test
158171
(testing "invalid pattern"

0 commit comments

Comments
 (0)