From 09010c4922cf7e4b4570718e5f0ca59807f20eaf Mon Sep 17 00:00:00 2001 From: Bruce Hauman Date: Wed, 22 Oct 2025 17:00:36 -0600 Subject: [PATCH 1/4] Add dry_run parameter to file_write tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added optional dry_run parameter to file_write tool with two modes: - dry_run="diff": Returns unified diff without writing to file - dry_run="new-source": Returns complete file content without writing Changes: - Updated tool schema to include dry_run parameter - Modified validate-inputs to extract and pass through dry_run - Updated execute-tool to pass dry_run and skip timestamp update in dry_run mode - Modified format-results to handle new-source output - Updated core.clj write-file, write-clojure-file, and write-text-file functions - Fixed all test calls to pass nil for dry_run parameter All 365 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/clojure_mcp/tools/file_write/core.clj | 74 ++++++++++++------- src/clojure_mcp/tools/file_write/tool.clj | 40 ++++++---- .../tools/file_write/core_test.clj | 28 ++++--- 3 files changed, 90 insertions(+), 52 deletions(-) diff --git a/src/clojure_mcp/tools/file_write/core.clj b/src/clojure_mcp/tools/file_write/core.clj index 292df45f..0e7cf37d 100644 --- a/src/clojure_mcp/tools/file_write/core.clj +++ b/src/clojure_mcp/tools/file_write/core.clj @@ -2,22 +2,22 @@ "Core implementation for the file-write tool. This namespace contains the pure functionality without any MCP-specific code." (:require - [clojure.java.io :as io] - [clojure-mcp.tools.form-edit.pipeline :as pipeline] - [clojure-mcp.utils.diff :as diff-utils] - [clojure-mcp.linting :as linting] - [clojure-mcp.utils.valid-paths :as valid-paths] - [rewrite-clj.zip :as z])) + [clojure.java.io :as io] + [clojure-mcp.tools.form-edit.pipeline :as pipeline] + [clojure-mcp.utils.diff :as diff-utils] + [clojure-mcp.linting :as linting] + [clojure-mcp.utils.valid-paths :as valid-paths] + [rewrite-clj.zip :as z])) - (defn is-clojure-file? - "Check if a file is a Clojure-related file based on its extension or Babashka shebang. +(defn is-clojure-file? + "Check if a file is a Clojure-related file based on its extension or Babashka shebang. Parameters: - file-path: Path to the file to check Returns true for Clojure extensions (.clj, .cljs, .cljc, .edn, .bb) or files with a `bb` shebang." - [file-path] - (boolean (valid-paths/clojure-file? file-path))) + [file-path] + (boolean (valid-paths/clojure-file? file-path))) (defn write-clojure-file "Write content to a Clojure file, with linting, formatting, and diffing. @@ -25,10 +25,11 @@ Parameters: - file-path: Validated path to the file to write - content: Content to write to the file + - dry_run: Optional preview mode ('diff' or 'new-source') Returns: - A map with :error, :type, :file-path, and :diff keys" - [nrepl-client-atom file-path content] + [nrepl-client-atom file-path content dry_run] (let [file (io/file file-path) file-exists? (.exists file) old-content (if file-exists? (slurp file) "") @@ -50,16 +51,28 @@ pipeline/format-source ;; Format the content pipeline/generate-diff ;; Generate diff between old and new content pipeline/determine-file-type ;; Determine if creating or updating - pipeline/save-file)] ;; Save the file and get offsets + ;; Conditionally skip file save if dry_run is set + (fn [ctx] + (if dry_run + ctx + (pipeline/save-file ctx))))] ;; Format the result for tool consumption (if (::pipeline/error result) {:error true :message (::pipeline/message result)} - {:error false - :type (::pipeline/type result) - :file-path (::pipeline/file-path result) - :diff (::pipeline/diff result)}))) + (cond + ;; Return new-source for dry_run="new-source" + (= dry_run "new-source") + {:error false + :new-source (::pipeline/output-source result)} + + ;; Return diff for dry_run="diff" or normal operation + :else + {:error false + :type (::pipeline/type result) + :file-path (::pipeline/file-path result) + :diff (::pipeline/diff result)})))) (defn write-text-file "Write content to a non-Clojure text file, with diffing but no linting or formatting. @@ -67,10 +80,11 @@ Parameters: - file-path: Validated path to the file to write - content: Content to write to the file + - dry_run: Optional preview mode ('diff' or 'new-source') Returns: - A map with :error, :type, :file-path, and :diff keys" - [file-path content] + [file-path content dry_run] (try (let [file (io/file file-path) file-exists? (.exists file) @@ -82,13 +96,20 @@ (diff-utils/generate-unified-diff old-content content)) "")] - ;; Write the content directly - (spit file content) + ;; Return new-source if dry_run="new-source" + (if (= dry_run "new-source") + {:error false + :new-source content} + + (do + ;; Write the content only if not in dry_run mode + (when-not dry_run + (spit file content)) - {:error false - :type (if file-exists? "update" "create") - :file-path file-path - :diff diff}) + {:error false + :type (if file-exists? "update" "create") + :file-path file-path + :diff diff}))) (catch Exception e {:error true :message (str "Error writing file: " (.getMessage e))}))) @@ -101,10 +122,11 @@ Parameters: - file-path: Validated path to the file to write - content: Content to write to the file + - dry_run: Optional preview mode ('diff' or 'new-source') Returns: - A map with :error, :type, :file-path, and :diff keys" - [nrepl-client-atom file-path content] + [nrepl-client-atom file-path content dry_run] (if (is-clojure-file? file-path) - (write-clojure-file nrepl-client-atom file-path content) - (write-text-file file-path content))) + (write-clojure-file nrepl-client-atom file-path content dry_run) + (write-text-file file-path content dry_run))) diff --git a/src/clojure_mcp/tools/file_write/tool.clj b/src/clojure_mcp/tools/file_write/tool.clj index b630ab7d..5444cda8 100644 --- a/src/clojure_mcp/tools/file_write/tool.clj +++ b/src/clojure_mcp/tools/file_write/tool.clj @@ -59,11 +59,14 @@ Before using this tool: :properties {:file_path {:type :string :description "The absolute path to the file to write (must be absolute, not relative)"} :content {:type :string - :description "The content to write to the file"}} + :description "The content to write to the file"} + :dry_run {:type :string + :enum ["diff" "new-source"] + :description "Optional: Preview mode. 'diff' returns unified diff without writing, 'new-source' returns complete file content without writing"}} :required [:file_path :content]}) (defmethod tool-system/validate-inputs :file-write [{:keys [nrepl-client-atom]} inputs] - (let [{:keys [file_path content]} inputs + (let [{:keys [file_path content dry_run]} inputs nrepl-client (and nrepl-client-atom @nrepl-client-atom)] (when-not file_path (throw (ex-info "Missing required parameter: file_path" {:inputs inputs}))) @@ -88,10 +91,11 @@ Before using this tool: ;; Return validated inputs with normalized path {:file-path validated-path - :content content}))) + :content content + :dry_run dry_run}))) (defmethod tool-system/execute-tool :file-write [{:keys [nrepl-client-atom]} inputs] - (let [{:keys [file-path content]} inputs + (let [{:keys [file-path content dry_run]} inputs ;; Capture original content - empty string for new files _ (when nrepl-client-atom (let [file (io/file file-path)] @@ -105,9 +109,9 @@ Before using this tool: nrepl-client-atom file-path "")))) - result (core/write-file nrepl-client-atom file-path content)] + result (core/write-file nrepl-client-atom file-path content dry_run)] ;; Update the timestamp if write was successful and we have a client atom - (when (and nrepl-client-atom (not (:error result))) + (when (and nrepl-client-atom (not (:error result)) (not dry_run)) (file-timestamps/update-file-timestamp-to-current-mtime! nrepl-client-atom file-path)) result)) @@ -116,16 +120,20 @@ Before using this tool: ;; If there's an error, return the error message {:result [(:message result)] :error true} - ;; Otherwise, format a successful result - (let [file-type (if (core/is-clojure-file? (:file-path result)) "Clojure" "Text") - response (str file-type " file " (:type result) "d: " (:file-path result))] - (if (seq (:diff result)) - ;; If there's a diff, include it in the response - {:result [(str response "\nChanges:\n" (:diff result))] - :error false} - ;; Otherwise, just show the success message - {:result [response] - :error false})))) + ;; Check if this is a dry_run with new-source + (if-let [new-source (:new-source result)] + {:result [new-source] + :error false} + ;; Otherwise, format a successful result + (let [file-type (if (core/is-clojure-file? (:file-path result)) "Clojure" "Text") + response (str file-type " file " (:type result) "d: " (:file-path result))] + (if (seq (:diff result)) + ;; If there's a diff, include it in the response + {:result [(str response "\nChanges:\n" (:diff result))] + :error false} + ;; Otherwise, just show the success message + {:result [response] + :error false}))))) ;; Backward compatibility function that returns the registration map (defn file-write-tool diff --git a/test/clojure_mcp/tools/file_write/core_test.clj b/test/clojure_mcp/tools/file_write/core_test.clj index 1a30f288..30822c70 100644 --- a/test/clojure_mcp/tools/file_write/core_test.clj +++ b/test/clojure_mcp/tools/file_write/core_test.clj @@ -66,7 +66,7 @@ (testing "Creating a new text file" (let [new-file (io/file *test-dir* "new-text-file.txt") content "This is a brand new file." - result (file-write-core/write-text-file (.getPath new-file) content)] + result (file-write-core/write-text-file (.getPath new-file) content nil)] (is (not (:error result))) (is (= "create" (:type result))) (is (= (.getPath new-file) (:file-path result))) @@ -79,7 +79,7 @@ (let [path (.getPath *test-txt-file*) original-content (slurp *test-txt-file*) new-content "This is updated content.\nWith new lines." - result (file-write-core/write-text-file path new-content)] + result (file-write-core/write-text-file path new-content nil)] (is (not (:error result))) (is (= "update" (:type result))) (is (= path (:file-path result))) @@ -93,7 +93,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* (.getPath new-file) - content)] + content + nil)] (is (not (:error result))) (is (= "create" (:type result))) (is (= (.getPath new-file) (:file-path result))) @@ -107,7 +108,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* path - new-content)] + new-content + nil)] (is (not (:error result))) (is (= "update" (:type result))) (is (= path (:file-path result))) @@ -120,7 +122,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* path - unformatted-content)] + unformatted-content + nil)] (is (not (:error result))) (is (= "update" (:type result))) (is (not (str/includes? (slurp *test-clj-file*) "poorly-formatted-fn[x]"))) @@ -132,7 +135,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* path - content-with-missing-paren)] + content-with-missing-paren + nil)] (is (not (:error result))) (is (= "update" (:type result))) (let [saved-content (slurp *test-clj-file*)] @@ -146,7 +150,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* path - content-with-mismatched-brackets)] + content-with-mismatched-brackets + nil)] (is (not (:error result))) (is (= "update" (:type result))) (let [saved-content (slurp *test-clj-file*)] @@ -161,7 +166,8 @@ result (file-write-core/write-clojure-file test-utils/*nrepl-client-atom* path - content-with-syntax-error)] + content-with-syntax-error + nil)] ;; The test should fail with a specific error (is (:error result) "Should have error for non-repairable syntax error") (when (:message result) @@ -176,7 +182,8 @@ content "(ns dispatch.test)" result (file-write-core/write-file test-utils/*nrepl-client-atom* (.getPath clj-file) - content)] + content + nil)] (is (not (:error result))) (is (= "create" (:type result))) (is (.exists clj-file)) @@ -187,7 +194,8 @@ content "Plain text content" result (file-write-core/write-file test-utils/*nrepl-client-atom* (.getPath txt-file) - content)] + content + nil)] (is (not (:error result))) (is (= "create" (:type result))) (is (.exists txt-file)) From d51faa4c62704f7f2b4cba081fa181e45aa56092 Mon Sep 17 00:00:00 2001 From: Bruce Hauman Date: Wed, 22 Oct 2025 17:03:25 -0600 Subject: [PATCH 2/4] Remove dry_run from file_write tool schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dry_run parameter is for internal use only and should not be exposed in the public schema. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/clojure_mcp/tools/file_write/tool.clj | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/clojure_mcp/tools/file_write/tool.clj b/src/clojure_mcp/tools/file_write/tool.clj index 5444cda8..8dfd7f26 100644 --- a/src/clojure_mcp/tools/file_write/tool.clj +++ b/src/clojure_mcp/tools/file_write/tool.clj @@ -59,10 +59,7 @@ Before using this tool: :properties {:file_path {:type :string :description "The absolute path to the file to write (must be absolute, not relative)"} :content {:type :string - :description "The content to write to the file"} - :dry_run {:type :string - :enum ["diff" "new-source"] - :description "Optional: Preview mode. 'diff' returns unified diff without writing, 'new-source' returns complete file content without writing"}} + :description "The content to write to the file"}} :required [:file_path :content]}) (defmethod tool-system/validate-inputs :file-write [{:keys [nrepl-client-atom]} inputs] From 91dd9de600b770e4c64f64685b0816c15ef411ef Mon Sep 17 00:00:00 2001 From: Bruce Hauman Date: Wed, 22 Oct 2025 17:13:35 -0600 Subject: [PATCH 3/4] Return only diff or new-source when dry_run is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dry_run parameter is present, return just the diff or new-source without any preamble (file type, path, or "Changes:" prefix). Changes: - Modified write-clojure-file to return only diff when dry_run="diff" - Modified write-text-file to return only diff when dry_run="diff" - Updated format-results to detect dry_run mode and return raw output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/clojure_mcp/tools/file_write/core.clj | 24 ++++++++++----- src/clojure_mcp/tools/file_write/tool.clj | 37 ++++++++++++++--------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/clojure_mcp/tools/file_write/core.clj b/src/clojure_mcp/tools/file_write/core.clj index 0e7cf37d..c49616cb 100644 --- a/src/clojure_mcp/tools/file_write/core.clj +++ b/src/clojure_mcp/tools/file_write/core.clj @@ -67,7 +67,12 @@ {:error false :new-source (::pipeline/output-source result)} - ;; Return diff for dry_run="diff" or normal operation + ;; Return just diff for dry_run="diff" + (= dry_run "diff") + {:error false + :diff (::pipeline/diff result)} + + ;; Return full result for normal operation :else {:error false :type (::pipeline/type result) @@ -96,16 +101,21 @@ (diff-utils/generate-unified-diff old-content content)) "")] - ;; Return new-source if dry_run="new-source" - (if (= dry_run "new-source") + (cond + ;; Return new-source if dry_run="new-source" + (= dry_run "new-source") {:error false :new-source content} - (do - ;; Write the content only if not in dry_run mode - (when-not dry_run - (spit file content)) + ;; Return just diff for dry_run="diff" + (= dry_run "diff") + {:error false + :diff diff} + ;; Normal operation - write and return full result + :else + (do + (spit file content) {:error false :type (if file-exists? "update" "create") :file-path file-path diff --git a/src/clojure_mcp/tools/file_write/tool.clj b/src/clojure_mcp/tools/file_write/tool.clj index 8dfd7f26..5d86cc66 100644 --- a/src/clojure_mcp/tools/file_write/tool.clj +++ b/src/clojure_mcp/tools/file_write/tool.clj @@ -113,24 +113,33 @@ Before using this tool: result)) (defmethod tool-system/format-results :file-write [_ result] - (if (:error result) + (cond ;; If there's an error, return the error message + (:error result) {:result [(:message result)] :error true} + ;; Check if this is a dry_run with new-source - (if-let [new-source (:new-source result)] - {:result [new-source] - :error false} - ;; Otherwise, format a successful result - (let [file-type (if (core/is-clojure-file? (:file-path result)) "Clojure" "Text") - response (str file-type " file " (:type result) "d: " (:file-path result))] - (if (seq (:diff result)) - ;; If there's a diff, include it in the response - {:result [(str response "\nChanges:\n" (:diff result))] - :error false} - ;; Otherwise, just show the success message - {:result [response] - :error false}))))) + (:new-source result) + {:result [(:new-source result)] + :error false} + + ;; Check if this is a dry_run with diff only (no file-path) + (and (:diff result) (not (:file-path result))) + {:result [(:diff result)] + :error false} + + ;; Otherwise, format a successful result with preamble + :else + (let [file-type (if (core/is-clojure-file? (:file-path result)) "Clojure" "Text") + response (str file-type " file " (:type result) "d: " (:file-path result))] + (if (seq (:diff result)) + ;; If there's a diff, include it in the response + {:result [(str response "\nChanges:\n" (:diff result))] + :error false} + ;; Otherwise, just show the success message + {:result [response] + :error false})))) ;; Backward compatibility function that returns the registration map (defn file-write-tool From 594825a1ee5cd334a3a7675e3851bc045159efd9 Mon Sep 17 00:00:00 2001 From: Bruce Hauman Date: Wed, 22 Oct 2025 17:21:09 -0600 Subject: [PATCH 4/4] Add :dry_run field to file_write results for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When dry_run parameter is used, include :dry_run field in the result containing the mode value ("diff" or "new-source") to make the API more self-documenting. Result formats: - dry_run="new-source": {:error false, :dry_run "new-source", :new-source "..."} - dry_run="diff": {:error false, :dry_run "diff", :diff "..."} - Normal operation: {:error false, :type "update", :file-path "...", :diff "..."} 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/clojure_mcp/tools/file_write/core.clj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/clojure_mcp/tools/file_write/core.clj b/src/clojure_mcp/tools/file_write/core.clj index c49616cb..55906a03 100644 --- a/src/clojure_mcp/tools/file_write/core.clj +++ b/src/clojure_mcp/tools/file_write/core.clj @@ -65,11 +65,13 @@ ;; Return new-source for dry_run="new-source" (= dry_run "new-source") {:error false + :dry_run "new-source" :new-source (::pipeline/output-source result)} ;; Return just diff for dry_run="diff" (= dry_run "diff") {:error false + :dry_run "diff" :diff (::pipeline/diff result)} ;; Return full result for normal operation @@ -105,11 +107,13 @@ ;; Return new-source if dry_run="new-source" (= dry_run "new-source") {:error false + :dry_run "new-source" :new-source content} ;; Return just diff for dry_run="diff" (= dry_run "diff") {:error false + :dry_run "diff" :diff diff} ;; Normal operation - write and return full result