diff --git a/clojure-mcp-dev.sh b/clojure-mcp-dev.sh index 0a7fd1b..a6184bb 100644 --- a/clojure-mcp-dev.sh +++ b/clojure-mcp-dev.sh @@ -23,7 +23,9 @@ PORT=7888 # PORT=58709 # conj-talk # Start tee process to capture stdin in background -tee "$STDIN_LOG" < "$PIPE" | clojure -X:dev-mcp :port $PORT 2>&1 | tee "$STDOUT_LOG" & +tee "$STDIN_LOG" < "$PIPE" | clojure -X:dev-mcp :enable-logging? true :port $PORT 2>&1 | tee "$STDOUT_LOG" & +# tee "$STDIN_LOG" < "$PIPE" | clojure -X:mcp-shadow :enable-logging? true :port $PORT 2>&1 | tee "$STDOUT_LOG" & +# tee "$STDIN_LOG" < "$PIPE" | clojure -X:mcp-shadow-dual :enable-logging? true :port 7888 :shadow-port $PORT 2>&1 | tee "$STDOUT_LOG" & # Get the PID of the background pipeline CLOJURE_PID=$! diff --git a/src/clojure_mcp/config.clj b/src/clojure_mcp/config.clj index 979d953..73edaf8 100644 --- a/src/clojure_mcp/config.clj +++ b/src/clojure_mcp/config.clj @@ -110,9 +110,7 @@ (assoc :nrepl-env-type (:nrepl-env-type config))))) (defn load-config - "Loads configuration from both user home (~/.clojure-mcp/config.edn) and project directory. - User home config provides defaults, project config provides overrides. - Validates both configs before merging." + "Loads the configuration from user home and project directories." [cli-config-file user-dir] ;; Load user home config first (provides defaults) (let [home-config (load-home-config) diff --git a/src/clojure_mcp/core.clj b/src/clojure_mcp/core.clj index 3ed41fa..edb656b 100644 --- a/src/clojure_mcp/core.clj +++ b/src/clojure_mcp/core.clj @@ -273,7 +273,6 @@ This function handles the complete setup process including: - Creating the nREPL client connection - - Starting the polling mechanism - Loading required namespaces and helpers (if Clojure environment) - Setting up the working directory - Loading configuration @@ -285,10 +284,7 @@ (try (let [nrepl-client-map (nrepl/create (dissoc initial-config :project-dir :nrepl-env-type)) cli-env-type (:nrepl-env-type initial-config) - _ (do - (log/info "nREPL client map created") - (nrepl/start-polling nrepl-client-map) - (log/info "Started polling nREPL")) + _ (log/info "nREPL client map created") ;; Detect environment type early ;; TODO this needs to be sorted out env-type (dialects/detect-nrepl-env-type nrepl-client-map) @@ -314,7 +310,6 @@ (log/info "Creating additional nREPL connection" initial-config) (try (let [nrepl-client-map (nrepl/create initial-config)] - (nrepl/start-polling nrepl-client-map) ;; copy config ;; maybe we should create this just like the normal nrelp connection? ;; we should introspect the project and get a working directory @@ -331,15 +326,12 @@ "Convenience higher-level API function to gracefully shut down MCP and nREPL servers. This function handles the complete shutdown process including: - - Stopping nREPL polling if a client exists in nrepl-client-atom - Gracefully closing the MCP server - Proper error handling and logging" [nrepl-client-atom] (log/info "Shutting down servers") (try (when-let [client @nrepl-client-atom] - (log/info "Stopping nREPL polling") - (nrepl/stop-polling client) ;; Clean up auto-started nREPL process if present (when-let [nrepl-process (:nrepl-process client)] (log/info "Cleaning up auto-started nREPL process") diff --git a/src/clojure_mcp/dialects.clj b/src/clojure_mcp/dialects.clj index 7f5505e..a724170 100644 --- a/src/clojure_mcp/dialects.clj +++ b/src/clojure_mcp/dialects.clj @@ -3,9 +3,10 @@ Supports different Clojure-like environments by providing expressions and initialization sequences specific to each dialect." - (:require [clojure.edn :as edn] - [clojure.java.io :as io] + (:require [clojure.java.io :as io] + [clojure.string :as str] [taoensso.timbre :as log] + [nrepl.core :as nrepl-core] [clojure-mcp.nrepl :as nrepl] [clojure-mcp.utils.file :as file-utils])) @@ -79,8 +80,10 @@ ;; default to fetching from the nrepl (when-let [exp (fetch-project-directory-exp nrepl-env-type)] (try - (edn/read-string - (nrepl/tool-eval-code nrepl-client-map exp)) + (let [result-value (->> (nrepl/eval-code nrepl-client-map exp :session-type :tools) + nrepl-core/combine-responses + :value)] + result-value) (catch Exception e (log/warn e "Failed to fetch project directory") nil)))) @@ -93,10 +96,14 @@ "Fetches the project directory for the given nREPL client. If project-dir is provided in opts, returns it directly. Otherwise, evaluates environment-specific expression to get it." - [nrepl-client-map nrepl-env-type project-dir] - (if project-dir - (.getCanonicalPath (io/file project-dir)) - (fetch-project-directory-helper nrepl-env-type nrepl-client-map))) + [nrepl-client-map nrepl-env-type project-dir-arg] + (if project-dir-arg + (.getCanonicalPath (io/file project-dir-arg)) + (let [raw-result (fetch-project-directory-helper nrepl-env-type nrepl-client-map)] + ;; nrepl sometimes returns strings with extra quotes and in a vector + (if (and (vector? raw-result) (= 1 (count raw-result)) (string? (first raw-result))) + (str/replace (first raw-result) #"^\"|\"$" "") + raw-result)))) ;; High-level wrapper functions that execute the expressions @@ -107,7 +114,7 @@ (log/debug "Initializing Clojure environment") (when-let [init-exps (not-empty (initialize-environment-exp nrepl-env-type))] (doseq [exp init-exps] - (nrepl/eval-code nrepl-client-map exp identity))) + (nrepl/eval-code nrepl-client-map exp))) nrepl-client-map) (defn load-repl-helpers @@ -115,7 +122,7 @@ [nrepl-client-map nrepl-env-type] (when-let [helper-exps (not-empty (load-repl-helpers-exp nrepl-env-type))] (doseq [exp helper-exps] - (nrepl/tool-eval-code nrepl-client-map exp))) + (nrepl/eval-code nrepl-client-map exp :session-type :tools))) nrepl-client-map) (defn detect-nrepl-env-type [nrepl-client-map] diff --git a/src/clojure_mcp/main_examples/shadow_main.clj b/src/clojure_mcp/main_examples/shadow_main.clj index f0f30b9..c79000f 100644 --- a/src/clojure_mcp/main_examples/shadow_main.clj +++ b/src/clojure_mcp/main_examples/shadow_main.clj @@ -30,7 +30,7 @@ JavaScript interop is fully supported including `js/console.log`, `js/setTimeout **IMPORTANT**: This repl is intended for CLOJURESCRIPT CODE only.") -(defn start-shadow-repl [nrepl-client-atom cljs-session {:keys [shadow-build shadow-watch]}] +(defn start-shadow-repl [nrepl-client-atom {:keys [shadow-build shadow-watch]}] (let [start-code (format ;; TODO we need to check if its already running ;; here and only initialize if it isn't @@ -39,16 +39,13 @@ JavaScript interop is fully supported including `js/console.log`, `js/setTimeout "(do (shadow/repl %s) %s)") (pr-str (keyword (name shadow-build))) (pr-str (keyword (name shadow-build))))] - (nrepl/eval-code-msg - @nrepl-client-atom start-code {:session cljs-session} - (->> identity - (nrepl/out-err #(log/info %) #(log/info %)) - (nrepl/value #(log/info %)) - (nrepl/done (fn [_] (log/info "done"))) - (nrepl/error (fn [args] - (log/info (pr-str args)) - (log/info "ERROR in shadow start"))))) - cljs-session)) + (log/info "Starting Shadow CLJS...") + (try + (nrepl/eval-code @nrepl-client-atom start-code :session-type :shadow) + (log/info "Shadow CLJS started (or command sent)") + (catch Exception e + (log/error e "ERROR in shadow start"))) + :shadow)) ;; when having a completely different connection for cljs (defn shadow-eval-tool-secondary-connection-tool [nrepl-client-atom {:keys [shadow-port _shadow-build _shadow-watch] :as config}] @@ -56,19 +53,19 @@ JavaScript interop is fully supported including `js/console.log`, `js/setTimeout cljs-nrepl-client-atom (atom cljs-nrepl-client-map)] (start-shadow-repl cljs-nrepl-client-atom - (nrepl/eval-session cljs-nrepl-client-map) config) - (-> (eval-tool/eval-code cljs-nrepl-client-atom) + (-> (eval-tool/eval-code cljs-nrepl-client-atom {:session-type :shadow}) (assoc :name tool-name) + (assoc :id (keyword tool-name)) (assoc :description description)))) ;; when sharing the clojure and cljs repl (defn shadow-eval-tool [nrepl-client-atom {:keys [_shadow-build _shadow-watch] :as config}] - (let [cljs-session (nrepl/new-session @nrepl-client-atom) - _ (start-shadow-repl nrepl-client-atom cljs-session config)] - (-> (eval-tool/eval-code nrepl-client-atom {:nrepl-session cljs-session}) - (assoc :name tool-name) - (assoc :description description)))) + (start-shadow-repl nrepl-client-atom config) + (-> (eval-tool/eval-code nrepl-client-atom {:session-type :shadow}) + (assoc :name tool-name) + (assoc :id (keyword tool-name)) + (assoc :description description))) ;; So we can set up shadow two ways ;; 1. as a single repl connection using the shadow clojure connection for cloj eval diff --git a/src/clojure_mcp/nrepl.clj b/src/clojure_mcp/nrepl.clj index e151275..7ae463b 100644 --- a/src/clojure_mcp/nrepl.clj +++ b/src/clojure_mcp/nrepl.clj @@ -1,283 +1,142 @@ (ns clojure-mcp.nrepl (:require - [clojure.main] [nrepl.core :as nrepl] [nrepl.misc :as nrepl.misc] - [nrepl.transport] - [taoensso.timbre :as log] - [clojure.edn])) + [nrepl.transport :as transport]) + (:import [java.io Closeable])) -;; callback system -(defn add-callback! [{:keys [::state]} id f] - (swap! state assoc-in [:id-callbacks id] f)) - -(defn remove-callback! [{:keys [::state]} id] - (swap! state update :id-callbacks dissoc id)) - -(defn set-current-eval-id! [{:keys [::state]} id] - (swap! state assoc :current-eval-id id)) - -(defn remove-current-eval-id! [{:keys [::state]}] - (swap! state dissoc :current-eval-id)) - -(defn dispatch-response! [{:keys [::state]} msg] - (doseq [f (vals (get @state :id-callbacks))] - (f msg))) - -;; message callback-api -(defn new-id [] (nrepl.misc/uuid)) - -(defn select-key [key shouldbe k] - (fn [msg] - (when (= (get msg key) shouldbe) - (k msg)))) - -(defn on-key [key callback k] - (fn [msg] - (when-let [v (get msg key)] - (callback v)) - (k msg))) - -(defn session-id [session' id' k] - (->> k - (select-key :id id') - (select-key :session session'))) - -(defn out-err [print-out print-err k] - (->> k - (on-key :out print-out) - (on-key :err print-err))) - -(defn value [callback k] - (on-key :value callback k)) - -(defn handle-statuses [pred callback k] - (fn [{:keys [status] :as msg}] - (when (some pred status) - (callback msg)) - (k msg))) - -(defn done [callback k] - (handle-statuses #{"done" "interrupt"} callback k)) - -(defn error [callback k] - (handle-statuses #{"error" "eval-error"} callback k)) - -(defn need-input [callback k] - (handle-statuses #{"need-input"} callback k)) - -(defn send-msg! [{:keys [::state] :as service} {:keys [session id] :as msg} callback] - (assert session) - (assert id) - (add-callback! - service id - (->> callback - (error (fn [_] (remove-callback! service id))) - (done (fn [_] (remove-callback! service id))) - (session-id session id))) - (tap> msg) - (nrepl.transport/send (:conn @state) msg)) - -(defn eval-session [{:keys [::state]}] - (get @state :session)) - -(defn tool-session [{:keys [::state]}] - (get @state :tool-session)) - -;; session state management in general is getting a little messy -;; parallel evals seem possible -(defn ns-session - "returns session for when the use wants to temporarily change to a ns - All requests to this session are intended to have the ns declared." - [{:keys [::state]}] - (get @state :ns-session)) - -(defn current-ns - ([{:keys [::state]} session] - (get-in @state [:current-ns session])) - ([{:keys [::state]} session new-ns] - (swap! state assoc-in [:current-ns session] new-ns))) - -(defn new-message [service msg] - (merge - {:session (eval-session service) - :id (new-id)} - msg)) - -(defn new-tool-message [service msg] - (new-message - service - (merge {:session (tool-session service)} - msg))) - -(def truncation-length 10000) ;; 20000 roughly 250 lines - -(defn eval-code-msg - [service code-str msg' k] - (let [msg (merge - msg' - {:op "eval" - :code code-str - :nrepl.middleware.print/print "nrepl.util.print/pprint" - ;; need to be able to set this magic number - :nrepl.middleware.print/quota truncation-length}) - {:keys [id session] :as message} (new-message service msg) - prom (promise) - finish (fn [_] - (deliver prom ::done) - (remove-current-eval-id! service))] - (set-current-eval-id! service id) - (send-msg! service - message - (->> k - (on-key :ns #(current-ns service session %)) - (done finish) - (error finish))) - prom)) - -(defn eval-code-help [service code-str k] - (eval-code-msg service code-str {} k)) - -(defn eval-code [service code-str k] - @(eval-code-help service code-str k)) - -(defn interrupt [{:keys [::state] :as service}] - ;; TODO having a timeout and then calling the - ;; callback with a done message could prevent - ;; terminal lockup in extreme cases - (let [{:keys [current-eval-id]} @state] - (when current-eval-id - (send-msg! - service - (new-message service {:op "interrupt" :interrupt-id current-eval-id}) - identity)))) - -(defn lookup [service symbol] - (let [prom (promise)] - (send-msg! service - (new-tool-message service {:op "lookup" :sym symbol}) - (->> identity - (done #(deliver prom - (some-> % - :info - not-empty - (update :arglists clojure.edn/read-string)))))) - (deref prom 400 nil))) - -(defn completions [service prefix] - (let [prom (promise)] - (send-msg! service - (new-tool-message service {:op "completions" :prefix prefix}) - (->> identity - (done #(deliver prom (get % :completions))))) - (deref prom 400 nil))) - -(defn tool-eval-code [service code-str] - (let [prom (promise)] - (send-msg! service - (new-tool-message service {:op "eval" :code code-str}) - (->> identity - (value #(deliver prom %)))) - (deref prom 400 nil))) - -(defn ls-middleware [service] - (let [prom (promise)] - (send-msg! service - (new-tool-message service {:op "ls-middleware"}) - (->> identity - (on-key :middleware #(deliver prom %)))) - (deref prom 400 nil))) - -(defn describe [service] - (let [prom (promise)] - (send-msg! service - (new-tool-message service {:op "describe"}) - (->> identity - (done #(deliver prom %)))) - (deref prom 600 nil))) - -(defn new-session [service] - (let [prom (promise)] - (send-msg! service - ;; this will clone the tool session - (new-tool-message service {:op "clone"}) - (->> identity - (done #(deliver prom (get % :new-session))))) - (deref prom 600 nil))) - -(defn send-input [service input] - (send-msg! service - (new-message service {:op "stdin" :stdin (when input - (str input "\n"))}) - identity)) - -(defn stop-polling [{:keys [::state]}] - (swap! state dissoc :response-poller)) - -(defn polling? [{:keys [::state]}] - (:response-poller @state)) - -(declare create) - -(defn poll-for-responses [{:keys [::state] :as options} _conn] - (let [retries (atom 60)] - (loop [] - (when (polling? options) - (let [continue - (try - (when-let [resp (nrepl.transport/recv (:conn @state) 100)] - (reset! retries 60) - #_(tap> resp) - (dispatch-response! options resp)) - :success - (catch java.io.IOException e - (log/error e "nREPL connection failure 1") - :retry) - (catch Throwable e - (log/error e "nREPL connection failure 2") - (some-> options :repl/error (reset! e)) - :retry))] - (cond - (= :retry continue) - (if (< 0 @retries) - (do (Thread/sleep 1000) - (log/info (str "nRPEL Trying to reconnect to " (:port options))) - (try - (create options) - (catch Exception e - (log/error e "Reconnect failed"))) - (swap! retries dec) - (recur)) - (stop-polling options)) - (= :success continue) - (recur))))))) - -(defn start-polling [{:keys [::state] :as service}] - (let [response-poller (Thread. ^Runnable (bound-fn [] (poll-for-responses service (:conn @state))))] - (swap! state assoc :response-poller response-poller) - (doto ^Thread response-poller - (.setName "Rebel Readline nREPL response poller") - (.setDaemon true) - (.start)))) +(defn- get-state [service] + (get service ::state)) (defn create ([] (create nil)) ([config] - (let [conn (apply nrepl/connect - (flatten (seq (select-keys config [:port :host :tls-keys-file])))) - client (nrepl/client conn Long/MAX_VALUE) - session (nrepl/new-session client) - tool-session (nrepl/new-session client) - ;; ns-session always has an ns declared in evals - ns-session (nrepl/new-session client) - state (::state config (atom {}))] - (swap! state assoc - :conn conn - :client client - :session session - :ns-session ns-session - :tool-session tool-session) - (assoc config - :repl/error (atom nil) - ::state state)))) + (let [port (:port config) + initial-state {:ports (if port {port {:sessions {}}} {})} + state (atom initial-state)] + (assoc config ::state state)))) + +(defn- connect [service] + (let [{:keys [host port]} service] + (nrepl/connect :host (or host "localhost") :port port))) + +(defn open-connection + "Opens an nREPL transport and client pair for the given service. + Callers are responsible for closing via `close-connection`." + ([service] + (open-connection service Long/MAX_VALUE)) + ([service timeout-ms] + (let [transport (connect service) + client (nrepl/client transport timeout-ms)] + {:transport transport + :client client}))) + +(defn close-connection [{:keys [transport]}] + (when transport + (.close ^Closeable transport))) + +(defn new-eval-id [] + (nrepl.misc/uuid)) + +(defn- get-stored-session [service session-type] + (let [port (:port service) + state @(get-state service)] + (get-in state [:ports port :sessions session-type]))) + +(defn- update-stored-session! [service session-type session-id] + (let [port (:port service)] + (swap! (get-state service) assoc-in [:ports port :sessions session-type] session-id))) + +(defn- session-valid? [client session-id] + (try + (let [sessions (-> (nrepl/message client {:op "ls-sessions"}) + nrepl/combine-responses + :sessions)] + (contains? (set sessions) session-id)) + (catch Exception _ false))) + +(defn- ensure-session! [client service session-type] + (let [stored-id (get-stored-session service session-type)] + (if (and stored-id (session-valid? client stored-id)) + stored-id + (let [new-id (nrepl/new-session client)] + (update-stored-session! service session-type new-id) + new-id)))) + +(defn ensure-session + "Ensures a session exists for the given session-type using the provided connection map." + ([service conn] + (ensure-session service conn :default)) + ([service {:keys [client]} session-type] + (ensure-session! client service session-type))) +(defn current-ns + "Returns the current namespace for the given session type." + [service session-type] + (let [port (:port service) + state @(get-state service)] + (get-in state [:ports port :current-ns session-type]))) + +(defn- update-current-ns! [service session-type new-ns] + (let [port (:port service)] + (swap! (get-state service) assoc-in [:ports port :current-ns session-type] new-ns))) + +(def truncation-length 10000) + +(defn eval-code* + "Low-level eval helper that executes using an existing connection map. + Returns a map containing the responses plus the session/id used." + [service {:keys [client]} code {:keys [session-type session-id eval-id] :as _opts}] + (let [session-type (or session-type :default) + session-id (or session-id (ensure-session! client service session-type)) + eval-id (or eval-id (new-eval-id)) + msg {:op "eval" + :code code + :session session-id + :id eval-id + :nrepl.middleware.print/print "nrepl.util.print/pprint" + :nrepl.middleware.print/quota truncation-length} + responses (doall (nrepl/message client msg))] + ;; Update current-ns if present in any response + (doseq [resp responses] + (when-let [new-ns (:ns resp)] + (update-current-ns! service session-type new-ns))) + {:responses responses + :session-id session-id + :eval-id eval-id + :session-type session-type})) + +(defn eval-code + "Evaluates code synchronously using a new connection. + Returns a sequence of response messages." + [service code & {:keys [session-type]}] + (let [conn (open-connection service)] + (try + (let [{:keys [responses]} (eval-code* service conn code {:session-type session-type})] + responses) + (finally + (close-connection conn))))) + +(defn interrupt* + "Sends an interrupt over an existing connection using the provided session/id." + [{:keys [transport]} session-id eval-id] + (when (and transport session-id eval-id) + (transport/send transport {:op "interrupt" + :session session-id + :interrupt-id eval-id}))) + +(defn interrupt + "Interrupts an eval by creating a short-lived connection." + [service session-id eval-id] + (let [conn (open-connection service 1000)] + (try + (interrupt* conn session-id eval-id) + (finally + (close-connection conn))))) + +(defn describe + "Returns the nREPL server's description, synchronously." + [service] + (with-open [conn (connect service)] + (let [client (nrepl/client conn 10000)] + (nrepl/combine-responses (nrepl/message client {:op "describe"}))))) diff --git a/src/clojure_mcp/prompt_cli.clj b/src/clojure_mcp/prompt_cli.clj index 2c791a1..f1cdf9d 100644 --- a/src/clojure_mcp/prompt_cli.clj +++ b/src/clojure_mcp/prompt_cli.clj @@ -15,7 +15,8 @@ [clojure-mcp.agent.langchain.chat-listener :as listener] [clojure-mcp.agent.langchain.message-conv :as msg-conv] [clojure-mcp.tool-format :as tool-format] - [clojure-mcp.utils.file :as file-utils]) + [clojure-mcp.utils.file :as file-utils] + [clojure-mcp.logging :as logging]) (:import [dev.langchain4j.data.message ChatMessageSerializer ChatMessageDeserializer] [java.time LocalDateTime] [java.time.format DateTimeFormatter]) @@ -223,10 +224,12 @@ "Execute a prompt against the parent agent" [{:keys [prompt model config dir port resume]}] (try + ;; Disable logging to prevent output to stdout + (logging/configure-logging! {:enable-logging? false}) + ;; Connect to nREPL and initialize with configuration (println (str "Connecting to nREPL server on port " port "...")) (let [nrepl-client-map (nrepl/create {:port port}) - _ (nrepl/start-polling nrepl-client-map) ;; Detect environment type env-type (dialects/detect-nrepl-env-type nrepl-client-map) diff --git a/src/clojure_mcp/tools/bash/core.clj b/src/clojure_mcp/tools/bash/core.clj index 3cf5d75..e08b812 100644 --- a/src/clojure_mcp/tools/bash/core.clj +++ b/src/clojure_mcp/tools/bash/core.clj @@ -166,7 +166,7 @@ EDN parsing failed: %s\nRaw result: %s" opts))) (defn execute-bash-command-nrepl - [nrepl-client-atom {:keys [command working-directory timeout-ms session] :as _args}] + [nrepl-client-atom {:keys [command working-directory timeout-ms session-type] :as _args}] (log/debug "Using nREPL bash command: " command) ;; timeout-ms is now required - should be provided by tool (assert timeout-ms "timeout-ms is required") @@ -183,7 +183,7 @@ EDN parsing failed: %s\nRaw result: %s" @nrepl-client-atom (cond-> {:code clj-shell-code :timeout-ms eval-timeout-ms} - session (assoc :session session))) + session-type (assoc :session-type session-type))) output-map (into {} (:outputs result)) inner-value (:value output-map)] (when (:error result) diff --git a/src/clojure_mcp/tools/bash/tool.clj b/src/clojure_mcp/tools/bash/tool.clj index 39e3fea..e1e52af 100644 --- a/src/clojure_mcp/tools/bash/tool.clj +++ b/src/clojure_mcp/tools/bash/tool.clj @@ -5,22 +5,9 @@ [clojure-mcp.config :as config] [clojure-mcp.utils.valid-paths :as valid-paths] [clojure-mcp.tools.bash.core :as core] - [clojure-mcp.nrepl :as nrepl] - [taoensso.timbre :as log] [clojure.java.io :as io] [clojure.string :as str])) -(defn create-bash-over-nrepl-session [nrepl-client] - (or - (try - (nrepl/new-session nrepl-client) - (catch Exception e - (log/error e "Could not create separate session for bash tool") - nil)) - (do - (log/debug "Could not create separate session for bash tool") - nil))) - ;; Factory function to create the tool configuration (defn create-bash-tool "Creates the bash tool configuration. @@ -38,8 +25,7 @@ ;; Check bash-over-nrepl from tool config or global config bash-over-nrepl (get tool-config :bash-over-nrepl (config/get-bash-over-nrepl nrepl-client)) - session (when bash-over-nrepl - (create-bash-over-nrepl-session nrepl-client)) + session-type (when bash-over-nrepl :tools) ;; Extract default-timeout-ms from config and rename it timeout_ms (or (:default-timeout-ms tool-config) (:timeout_ms defaults))] @@ -49,7 +35,7 @@ (dissoc tool-config :default-timeout-ms) ; Remove config key {:tool-type :bash :nrepl-client-atom nrepl-client-atom - :nrepl-session session}))) + :nrepl-session-type session-type}))) ;; Implement the required multimethods for the bash tool (defmethod tool-system/tool-name :bash [_] @@ -128,12 +114,11 @@ in the response to determine command success.") working_directory (assoc :working-directory validated-dir) timeout_ms (assoc :timeout-ms timeout_ms))))) -(defmethod tool-system/execute-tool :bash [{:keys [nrepl-client-atom nrepl-session]} inputs] +(defmethod tool-system/execute-tool :bash [{:keys [nrepl-client-atom nrepl-session-type]} inputs] (let [nrepl-client @nrepl-client-atom] - (if nrepl-session - ;; Execute over nREPL with session (session exists if bash-over-nrepl is true) - (let [inputs-with-session (assoc inputs :session nrepl-session)] - (core/execute-bash-command-nrepl nrepl-client-atom inputs-with-session)) + (if nrepl-session-type + ;; Execute over nREPL with session type + (core/execute-bash-command-nrepl nrepl-client-atom (assoc inputs :session-type nrepl-session-type)) ;; Execute locally (core/execute-bash-command nrepl-client inputs)))) @@ -159,4 +144,3 @@ in the response to determine command success.") - nrepl-client-atom: Atom containing the nREPL client" [nrepl-client-atom] (tool-system/registration-map (create-bash-tool nrepl-client-atom))) - diff --git a/src/clojure_mcp/tools/eval/core.clj b/src/clojure_mcp/tools/eval/core.clj index c171ac2..8134ca0 100644 --- a/src/clojure_mcp/tools/eval/core.clj +++ b/src/clojure_mcp/tools/eval/core.clj @@ -40,6 +40,20 @@ (or (paren-utils/parinfer-repair code) code) code)) +(defn- process-responses [responses] + (let [outputs (atom []) + error-occurred (atom false)] + (doseq [msg responses] + (when (:out msg) (swap! outputs conj [:out (:out msg)])) + (when (:err msg) (swap! outputs conj [:err (:err msg)])) + (when (:value msg) (swap! outputs conj [:value (:value msg)])) + (when (:ex msg) + (reset! error-occurred true) + (swap! outputs conj [:err (:ex msg)])) + (when (some #{"error" "eval-error"} (:status msg)) + (reset! error-occurred true))) + {:outputs @outputs :error @error-occurred})) + (defn evaluate-code "Evaluates Clojure code using the nREPL client. @@ -52,59 +66,52 @@ Returns: - A map with :outputs (raw outputs), :error (boolean flag)" [nrepl-client opts] - (let [{:keys [code timeout_ms session]} opts + (let [{:keys [code timeout_ms session-type]} opts timeout-ms (or timeout_ms 20000) - outputs (atom []) - error-occurred (atom false) form-str code - add-output! (fn [prefix value] (swap! outputs conj [prefix value])) - result-promise (promise)] + session-type (or session-type :default) + conn (nrepl/open-connection nrepl-client)] - ;; Evaluate the code ;; Push to eval history if available (when-let [state (::nrepl/state nrepl-client)] - (swap! state update :clojure-mcp.repl-tools/eval-history conj form-str) - - ;; Evaluate the code, using the namespace parameter if provided - (try - (nrepl/eval-code-msg - nrepl-client form-str - (if session {:session session} {}) - (->> identity - (nrepl/out-err - #(add-output! :out %) - #(add-output! :err %)) - (nrepl/value #(add-output! :value %)) - (nrepl/done (fn [_] - (deliver result-promise - {:outputs @outputs - :error @error-occurred}))) - (nrepl/error (fn [{:keys [exception]}] - (reset! error-occurred true) - (add-output! :err exception) - (deliver result-promise - {:outputs @outputs - :error true}))))) - (catch Exception e - ;; prevent connection errors from confusing the LLM - (log/error e "Error when trying to eval on the nrepl connection") - (throw - (ex-info - (str "Internal Error: Unable to reach the nREPL " - "thus we are unable to execute the bash command.") - {:error-type :connection-error} - e)))) + (swap! state update :clojure-mcp.repl-tools/eval-history conj form-str)) - ;; Wait for the result and return it - (let [tmb (Object.) - res (deref result-promise timeout-ms tmb)] - (if-not (= tmb res) - res + (try + (let [session-id (nrepl/ensure-session nrepl-client conn session-type) + eval-id (nrepl/new-eval-id) + fut (future + (try + (let [{:keys [responses]} (nrepl/eval-code* nrepl-client conn form-str + {:session-type session-type + :session-id session-id + :eval-id eval-id})] + (process-responses responses)) + (catch Exception e + (log/error e "Error during nREPL eval") + {:outputs [[:err (str "Internal Error: " (.getMessage e))]] :error true}) + (finally + ;; The connection is closed after the eval thread completes. + (nrepl/close-connection conn)))) + res (deref fut timeout-ms :timeout)] + (if (= res :timeout) (do - (nrepl/interrupt nrepl-client) + (nrepl/interrupt* conn session-id eval-id) + (future-cancel fut) {:outputs [[:err (str "Eval timed out after " timeout-ms "ms.")] [:err "Perhaps, you had an infinite loop or an eval that ran too long."]] - :error true})))))) + :error true}) + res)) + (catch Exception e + ;; ensure the connection is closed if something failed before the future started + (nrepl/close-connection conn) + ;; prevent connection errors from confusing the LLM + (log/error e "Error when trying to eval on the nrepl connection") + (throw + (ex-info + (str "Internal Error: Unable to reach the nREPL " + "thus we are unable to execute the bash command.") + {:error-type :connection-error} + e)))))) (defn evaluate-with-repair "Evaluates Clojure code with automatic repair of delimiter errors. @@ -124,4 +131,4 @@ repaired? (not= repaired-code code) opts (assoc opts :code repaired-code)] (assoc (evaluate-code nrepl-client opts) - :repaired repaired?))) \ No newline at end of file + :repaired repaired?))) diff --git a/src/clojure_mcp/tools/eval/tool.clj b/src/clojure_mcp/tools/eval/tool.clj index 015a55a..dab6840 100644 --- a/src/clojure_mcp/tools/eval/tool.clj +++ b/src/clojure_mcp/tools/eval/tool.clj @@ -9,11 +9,11 @@ "Creates the evaluation tool configuration" ([nrepl-client-atom] (create-eval-tool nrepl-client-atom {})) - ([nrepl-client-atom {:keys [nrepl-session] :as _config}] + ([nrepl-client-atom {:keys [session-type] :as _config}] (cond-> {:tool-type ::clojure-eval :nrepl-client-atom nrepl-client-atom :timeout 20000} - nrepl-session (assoc :session nrepl-session)))) + session-type (assoc :session-type session-type)))) ;; Implement the required multimethods for the eval tool (defmethod tool-system/tool-description ::clojure-eval [_] @@ -65,11 +65,11 @@ Examples: ;; Return validated inputs (could do more validation/coercion here) inputs)) -(defmethod tool-system/execute-tool ::clojure-eval [{:keys [nrepl-client-atom session timeout]} +(defmethod tool-system/execute-tool ::clojure-eval [{:keys [nrepl-client-atom timeout session-type]} {:keys [timeout_ms] :as inputs}] ;; Delegate to core implementation with repair (core/evaluate-with-repair @nrepl-client-atom (cond-> inputs - session (assoc :session session) + session-type (assoc :session-type session-type) (nil? timeout_ms) (assoc :timeout_ms timeout)))) (defmethod tool-system/format-results ::clojure-eval [_ {:keys [outputs error repaired] :as _eval-result}] diff --git a/src/clojure_mcp/tools/figwheel/tool.clj b/src/clojure_mcp/tools/figwheel/tool.clj index 10f9bbe..900f25f 100644 --- a/src/clojure_mcp/tools/figwheel/tool.clj +++ b/src/clojure_mcp/tools/figwheel/tool.clj @@ -8,31 +8,27 @@ [clojure-mcp.tools.eval.core :as eval-core])) (defn start-figwheel [nrepl-client-atom build] - (let [figwheel-session (nrepl/new-session @nrepl-client-atom) - start-code (format + (let [start-code (format ;; TODO we need to check if its already running ;; here and only initialize if it isn't "(do (require (quote figwheel.main)) (figwheel.main/start %s))" (pr-str build))] - (nrepl/eval-code-msg - @nrepl-client-atom start-code {:session figwheel-session} - (->> identity - (nrepl/out-err #(log/info %) #(log/info %)) - (nrepl/value #(log/info %)) - (nrepl/done (fn [_] (log/info "done"))) - (nrepl/error (fn [args] - (log/info (pr-str args)) - (log/info "ERROR in figwheel start"))))) - figwheel-session)) + (log/info "Starting Figwheel...") + (try + (nrepl/eval-code @nrepl-client-atom start-code :session-type :figwheel) + (log/info "Figwheel started (or command sent)") + (catch Exception e + (log/error e "ERROR in figwheel start"))) + :figwheel)) (defn create-figwheel-eval-tool "Creates the evaluation tool configuration" [nrepl-client-atom {:keys [figwheel-build] :as _config}] - (let [figwheel-session (start-figwheel nrepl-client-atom figwheel-build)] - {:tool-type ::figwheel-eval - :nrepl-client-atom nrepl-client-atom - :timeout 30000 - :session figwheel-session})) + (start-figwheel nrepl-client-atom figwheel-build) + {:tool-type ::figwheel-eval + :nrepl-client-atom nrepl-client-atom + :timeout 30000 + :session-type :figwheel}) ;; delegate schema validate-inputs and format-results to clojure-eval (derive ::figwheel-eval ::eval-tool/clojure-eval) @@ -71,16 +67,16 @@ JavaScript interop is fully supported including `js/console.log`, `js/setTimeout **IMPORTANT**: This repl is intended for CLOJURESCRIPT CODE only.") -(defmethod tool-system/execute-tool ::figwheel-eval [{:keys [nrepl-client-atom session]} inputs] - (assert session) +(defmethod tool-system/execute-tool ::figwheel-eval [{:keys [nrepl-client-atom session-type]} inputs] + (assert session-type) (assert (:code inputs)) ;; :code has to exist at this point (let [code (:code inputs (get inputs "code"))] ;; *ns* doesn't work on ClojureScript and its confusing for the LLM (if (= (string/trim code) "*ns*") - {:outputs [[:value (nrepl/current-ns @nrepl-client-atom session)]] + {:outputs [[:value (nrepl/current-ns @nrepl-client-atom session-type)]] :error false} - (eval-core/evaluate-with-repair @nrepl-client-atom (assoc inputs :session session))))) + (eval-core/evaluate-with-repair @nrepl-client-atom (assoc inputs :session-type session-type))))) ;; config needs :fig (defn figwheel-eval [nrepl-client-atom config] diff --git a/test/clojure_mcp/repl_tools/test_utils.clj b/test/clojure_mcp/repl_tools/test_utils.clj index 292d71b..dd763a7 100644 --- a/test/clojure_mcp/repl_tools/test_utils.clj +++ b/test/clojure_mcp/repl_tools/test_utils.clj @@ -13,14 +13,12 @@ port (:port server) client (nrepl/create {:port port}) client-atom (atom client)] - (nrepl/start-polling client) - (nrepl/eval-code client "(require 'clojure.repl)" identity) + (nrepl/eval-code client "(require 'clojure.repl)") (binding [*nrepl-server* server *nrepl-client-atom* client-atom] (try (f) (finally - (nrepl/stop-polling client) (nrepl-server/stop-server server)))))) (defn cleanup-test-file [f] @@ -41,4 +39,4 @@ ;; Apply fixtures in each test namespace (defn apply-fixtures [_test-namespace] (use-fixtures :once test-nrepl-fixture) - (use-fixtures :each cleanup-test-file)) \ No newline at end of file + (use-fixtures :each cleanup-test-file)) diff --git a/test/clojure_mcp/tools/bash/config_test.clj b/test/clojure_mcp/tools/bash/config_test.clj index 86b5e41..609e365 100644 --- a/test/clojure_mcp/tools/bash/config_test.clj +++ b/test/clojure_mcp/tools/bash/config_test.clj @@ -45,14 +45,8 @@ :bash-over-nrepl false}} client-atom-local (atom mock-client-local)] - ;; Mock the session creation and execution functions - (with-redefs [bash-tool/create-bash-over-nrepl-session - (fn [client] - ;; Return a mock session when bash-over-nrepl is true - (when (config/get-bash-over-nrepl client) - {:mock-session true})) - - bash-core/execute-bash-command-nrepl + ;; Mock the execution functions + (with-redefs [bash-core/execute-bash-command-nrepl (fn [_ _] (swap! nrepl-calls inc) {:exit-code 0 :stdout "nrepl" :stderr "" :timed-out false}) @@ -62,7 +56,7 @@ (swap! local-calls inc) {:exit-code 0 :stdout "local" :stderr "" :timed-out false})] - ;; Create tools (must be inside with-redefs to get mocked session) + ;; Create tools (let [tool-nrepl (bash-tool/create-bash-tool client-atom-nrepl) tool-local (bash-tool/create-bash-tool client-atom-local) inputs {:command "echo test" diff --git a/test/clojure_mcp/tools/bash/session_test.clj b/test/clojure_mcp/tools/bash/session_test.clj index 2d63ff4..c008ebc 100644 --- a/test/clojure_mcp/tools/bash/session_test.clj +++ b/test/clojure_mcp/tools/bash/session_test.clj @@ -7,37 +7,8 @@ [clojure-mcp.config :as config] [clojure-mcp.tool-system :as tool-system])) -(deftest test-bash-tool-creates-separate-session - (testing "Bash tool creates its own nREPL session" - ;; Create a mock nREPL client - (let [mock-sessions (atom []) - mock-client {:client :mock-client - ::nrepl/state (atom {}) - ::config/config {:allowed-directories ["/tmp"] - :nrepl-user-dir "/tmp" - :bash-over-nrepl true}} - client-atom (atom mock-client)] - - ;; Mock the new-session function to track session creation - (with-redefs [nrepl/new-session (fn [_client] - (let [session-id (str "session-" (count @mock-sessions))] - (swap! mock-sessions conj session-id) - session-id))] - ;; Create the bash tool - (let [bash-tool-config (bash-tool/create-bash-tool client-atom)] - - ;; Verify the tool has a session - (is (contains? bash-tool-config :nrepl-session)) - (is (= "session-0" (:nrepl-session bash-tool-config))) - (is (= 1 (count @mock-sessions))) - - ;; Create another bash tool to verify it gets a different session - (let [bash-tool-config-2 (bash-tool/create-bash-tool client-atom)] - (is (= "session-1" (:nrepl-session bash-tool-config-2))) - (is (= 2 (count @mock-sessions))))))))) - (deftest test-bash-execution-uses-session - (testing "Bash command execution passes session to evaluate-code" + (testing "Bash command execution passes session-type to evaluate-code" (let [captured-args (atom nil) mock-client {:client :mock-client ::nrepl/state (atom {}) @@ -45,39 +16,27 @@ :nrepl-user-dir (System/getProperty "user.dir") :bash-over-nrepl true}} client-atom (atom mock-client) - - ;; Create a bash tool with a mock session - bash-tool-config (with-redefs [nrepl/new-session (fn [_] "test-session-123")] - (bash-tool/create-bash-tool client-atom))] - - ;; Mock the evaluate-code function to capture its arguments - (with-redefs [clojure-mcp.tools.eval.core/evaluate-code - (fn [_client opts] - (reset! captured-args opts) - {:outputs [[:value "{:exit-code 0 :stdout \"test\" :stderr \"\" :timed-out false}"]] - :error false})] - - ;; Execute a bash command - (let [inputs {:command "echo test" - :working-directory (System/getProperty "user.dir") - :timeout-ms 30000} - result (tool-system/execute-tool bash-tool-config inputs)] - - ;; Verify the session was passed to evaluate-code - (is (not (nil? @captured-args))) - (is (contains? @captured-args :session)) - (is (= "test-session-123" (:session @captured-args))) - - ;; Verify the result is properly formatted - (is (map? result)) - (is (= 0 (:exit-code result))) - (is (= "test" (:stdout result)))))))) - -(deftest test-bash-tool-handles-missing-client - (testing "Bash tool gracefully handles when session cannot be created" - (let [client-atom (atom nil) bash-tool-config (bash-tool/create-bash-tool client-atom)] - ;; Should still create a tool config, but with nil session - (is (contains? bash-tool-config :nrepl-session)) - (is (nil? (:nrepl-session bash-tool-config)))))) + ;; Mock the evaluate-code function to capture its arguments + (with-redefs [clojure-mcp.tools.eval.core/evaluate-code + (fn [_client opts] + (reset! captured-args opts) + {:outputs [[:value "{:exit-code 0 :stdout \"test\" :stderr \"\" :timed-out false}"]] + :error false})] + + ;; Execute a bash command + (let [inputs {:command "echo test" + :working-directory (System/getProperty "user.dir") + :timeout-ms 30000} + result (tool-system/execute-tool bash-tool-config inputs)] + + ;; Verify the session-type was passed to evaluate-code + (is (not (nil? @captured-args))) + (is (contains? @captured-args :session-type)) + (is (= :tools (:session-type @captured-args))) + + ;; Verify the result is properly formatted + (is (map? result)) + (is (= 0 (:exit-code result))) + (is (= "test" (:stdout result)))))))) diff --git a/test/clojure_mcp/tools/eval/core_test.clj b/test/clojure_mcp/tools/eval/core_test.clj index bcdb40e..c640a8e 100644 --- a/test/clojure_mcp/tools/eval/core_test.clj +++ b/test/clojure_mcp/tools/eval/core_test.clj @@ -14,14 +14,15 @@ (let [server (nrepl-server/start-server :port 0) port (:port server) client (nrepl/create {:port port})] - (nrepl/start-polling client) - (nrepl/eval-code client "(require 'clojure.repl)" identity) + ;; nrepl/start-polling is now a no-op or removed, so we skip it. + ;; nrepl/eval-code is blocking and returns results. + (nrepl/eval-code client "(require 'clojure.repl)") (binding [*nrepl-server* server *nrepl-client* client] (try (f) (finally - (nrepl/stop-polling client) + ;; nrepl/stop-polling is now a no-op or removed. (nrepl-server/stop-server server)))))) (use-fixtures :once test-nrepl-fixture) diff --git a/test/clojure_mcp/tools/eval/tool_test.clj b/test/clojure_mcp/tools/eval/tool_test.clj index d0346f4..2794126 100644 --- a/test/clojure_mcp/tools/eval/tool_test.clj +++ b/test/clojure_mcp/tools/eval/tool_test.clj @@ -16,14 +16,12 @@ port (:port server) client (clojure-mcp.nrepl/create {:port port}) client-atom (atom client)] - (clojure-mcp.nrepl/start-polling client) - (clojure-mcp.nrepl/eval-code client "(require 'clojure.repl)" identity) + (clojure-mcp.nrepl/eval-code client "(require 'clojure.repl)") (binding [*nrepl-server* server *nrepl-client-atom* client-atom] (try (f) (finally - (clojure-mcp.nrepl/stop-polling client) (nrepl.server/stop-server server)))))) (use-fixtures :once test-nrepl-fixture) diff --git a/test/clojure_mcp/tools/project/tool_test.clj b/test/clojure_mcp/tools/project/tool_test.clj index 3fe7a6b..d06796f 100644 --- a/test/clojure_mcp/tools/project/tool_test.clj +++ b/test/clojure_mcp/tools/project/tool_test.clj @@ -27,15 +27,13 @@ {:nrepl-user-dir user-dir :allowed-directories [user-dir]}) client-atom (atom client-with-config)] - (nrepl/start-polling client-with-config) - (nrepl/eval-code client-with-config "(require 'clojure.repl)" identity) - (nrepl/eval-code client-with-config "(require 'clojure.edn)" identity) + (nrepl/eval-code client-with-config "(require 'clojure.repl)") + (nrepl/eval-code client-with-config "(require 'clojure.edn)") (binding [*nrepl-server* server *client-atom* client-atom] (try (f) (finally - (nrepl/stop-polling client-with-config) (nrepl-server/stop-server server)))))) (use-fixtures :once setup-nrepl-client) diff --git a/test/clojure_mcp/tools/test_utils.clj b/test/clojure_mcp/tools/test_utils.clj index 0478e0b..2824e6d 100644 --- a/test/clojure_mcp/tools/test_utils.clj +++ b/test/clojure_mcp/tools/test_utils.clj @@ -17,14 +17,12 @@ port (:port server) client (nrepl/create {:port port}) client-atom (atom client)] - (nrepl/start-polling client) - (nrepl/eval-code client "(require 'clojure.repl)" identity) + (nrepl/eval-code client "(require 'clojure.repl)") (binding [*nrepl-server* server *nrepl-client-atom* client-atom] (try (f) (finally - (nrepl/stop-polling client) (nrepl-server/stop-server server)))))) (defn cleanup-test-file [f]