diff --git a/CLAUDE.md b/CLAUDE.md index bffb8f4..87145cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,8 @@ ## Build Commands - Run REPL with MCP server: `clojure -X:mcp` (starts on port 7888) -- Run all tests: `clojure -X:test` -- Run single test: `clojure -X:test :dirs '["test"]' :include '"repl_tools_test"'` -- Run linter: `clojure -M:lint` (checks src directory) +- Run all tests: `clojure -M:test` +- Run linter: `clj-kondo --lint src` or `clj-kondo --lint src test` for both - Build JAR: `clojure -T:build ci` - Install locally: `clojure -T:build install` @@ -16,7 +15,6 @@ - **Namespaces**: Align with directory structure (`clojure-mcp.repl-tools`) - **Testing**: Use `deftest` with descriptive names; `testing` for subsections; `is` for assertions - **REPL Development**: Prefer REPL-driven development for rapid iteration and feedback -- Don't use the clojure -X:lint tool in the workflow ## MCP Tool Guidelines - Include clear tool `:description` for LLM guidance diff --git a/deps.edn b/deps.edn index f37c5a3..90c08eb 100644 --- a/deps.edn +++ b/deps.edn @@ -38,17 +38,15 @@ {:mcp {:exec-fn clojure-mcp.main/start-mcp-server ;; it needs an nrepl port to talk to - :exec-args {:port 7888}} + ;; :exec-args {:port 7888} + } :mcp-sse {:extra-deps {jakarta.servlet/jakarta.servlet-api {:mvn/version "6.1.0"} org.eclipse.jetty/jetty-server {:mvn/version "11.0.20"} org.eclipse.jetty/jetty-servlet {:mvn/version "11.0.20"}} - :exec-fn clojure-mcp.sse-main/start-sse-mcp-server - - :exec-args {:port 7888 ;; the nrepl port to connect to - ;; specify the :mcp-sse-port to listen on - :mcp-sse-port 8078}} + :exec-fn clojure-mcp.sse-main/start + :exec-args {:mcp-sse-port 8078}} :mcp-figwheel {:exec-fn clojure-mcp.main-examples.figwheel-main/start-mcp-server @@ -71,9 +69,10 @@ :dev-mcp {:extra-paths ["dev" "test"] - :exec-fn clojure-mcp.main/start-mcp-server + :exec-fn clojure-mcp.main/start ;; it needs an nrepl port to talk to - :exec-args {:port 7888 + :exec-args {;; :port 7888 + :enable-logging? true ;; test auto starting the repl ;; :start-nrepl-cmd ["clojure" "-M:nrepl"] }} diff --git a/src/clojure_mcp/core.clj b/src/clojure_mcp/core.clj index edb656b..0d79afb 100644 --- a/src/clojure_mcp/core.clj +++ b/src/clojure_mcp/core.clj @@ -5,7 +5,6 @@ [taoensso.timbre :as log] [clojure-mcp.nrepl :as nrepl] [clojure-mcp.config :as config] - [clojure-mcp.dialects :as dialects] [clojure-mcp.file-content :as file-content] [clojure-mcp.nrepl-launcher :as nrepl-launcher]) (:import [io.modelcontextprotocol.server.transport @@ -255,7 +254,7 @@ (throw e))))) (defn fetch-config [nrepl-client-map config-file cli-env-type env-type project-dir] - (let [user-dir (dialects/fetch-project-directory nrepl-client-map env-type project-dir)] + (let [user-dir (nrepl/fetch-project-directory nrepl-client-map env-type project-dir)] (when-not user-dir (log/warn "Could not determine working directory") (throw (ex-info "No project directory!!" {}))) @@ -269,58 +268,66 @@ (assoc nrepl-client-map ::config/config (assoc config :nrepl-env-type final-env-type))))) (defn create-and-start-nrepl-connection - "Convenience higher-level API function to create and initialize an nREPL connection. - - This function handles the complete setup process including: - - Creating the nREPL client connection - - Loading required namespaces and helpers (if Clojure environment) + "Creates an nREPL client map and loads configuration. + + This function handles: + - Creating the nREPL client connection (if port provided) - Setting up the working directory - Loading configuration - - Takes initial-config map with :port and optional :host, :project-dir, :nrepl-env-type, :config-file. + + REPL initialization (env detection, init expressions, helpers) happens lazily + when the first eval-code call is made. + + Takes initial-config map with optional :port, :host, :project-dir, :nrepl-env-type, :config-file. + - If :project-dir is provided, uses it directly (no REPL query needed) + - If :project-dir is NOT provided, queries REPL for project directory (requires :port) + Returns the configured nrepl-client-map with ::config/config attached." - [{:keys [project-dir config-file] :as initial-config}] - (log/info "Creating nREPL connection with config:" initial-config) + [{:keys [project-dir config-file port] :as initial-config}] + (if port + (log/info "Creating nREPL client for port" port) + (log/info "Starting without nREPL connection (project-dir mode)")) (try (let [nrepl-client-map (nrepl/create (dissoc initial-config :project-dir :nrepl-env-type)) cli-env-type (:nrepl-env-type initial-config) - _ (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) - nrepl-client-map-with-config (fetch-config nrepl-client-map - config-file - cli-env-type - env-type - project-dir) - nrepl-env-type' (config/get-config nrepl-client-map-with-config :nrepl-env-type)] - (log/debug "Initializing Clojure environment") - (dialects/initialize-environment nrepl-client-map-with-config nrepl-env-type') - (dialects/load-repl-helpers nrepl-client-map-with-config nrepl-env-type') - (log/debug "Environment initialized") - nrepl-client-map-with-config) + _ (log/info "nREPL client map created")] + (if project-dir + ;; Project dir provided - load config directly, no REPL query needed + (let [user-dir (.getCanonicalPath (io/file project-dir)) + _ (log/info "Working directory set to:" user-dir) + config (load-config-handling-validation-errors config-file user-dir) + ;; Use cli-env-type or config's env-type, default to :clj + final-env-type (or cli-env-type + (:nrepl-env-type config) + :clj)] + (assoc nrepl-client-map ::config/config (assoc config :nrepl-env-type final-env-type))) + ;; No project dir - need to query REPL (requires port) + (let [;; Detect environment type (uses describe op, no full init needed) + env-type (nrepl/detect-nrepl-env-type nrepl-client-map) + _ (nrepl/set-port-env-type! nrepl-client-map env-type)] + (fetch-config nrepl-client-map config-file cli-env-type env-type project-dir)))) (catch Exception e (log/error e "Failed to create nREPL connection") (throw e)))) (defn create-additional-connection + "Creates a service map for an additional port. Initialization is lazy. + The returned service shares the base client's state atom and config, + but targets a different port. + + Note: The third argument (initialize-fn) is deprecated and ignored. + Initialization now happens lazily via nrepl/ensure-port-initialized!" ([nrepl-client-atom initial-config] - (create-additional-connection nrepl-client-atom initial-config identity)) - ([nrepl-client-atom initial-config initialize-fn] - (log/info "Creating additional nREPL connection" initial-config) - (try - (let [nrepl-client-map (nrepl/create initial-config)] - ;; copy config - ;; maybe we should create this just like the normal nrelp connection? - ;; we should introspect the project and get a working directory - ;; and maybe add it to allowed directories for both - (when initialize-fn (initialize-fn nrepl-client-map)) - (assert (::config/config @nrepl-client-atom)) - ;; copy config over for now - (assoc nrepl-client-map ::config/config (::config/config @nrepl-client-atom))) - (catch Exception e - (log/error e "Failed to create additional nREPL connection") - (throw e))))) + (create-additional-connection nrepl-client-atom initial-config nil)) + ([nrepl-client-atom {:keys [port host]} _deprecated-initialize-fn] + (log/info "Creating additional connection config for port" port) + (let [base-client @nrepl-client-atom] + (assert (::config/config base-client) "Base client must have config") + (-> base-client + (assoc :port port) + (cond-> host (assoc :host host)) + ;; Ensure port entry exists but don't initialize yet (lazy init) + nrepl/ensure-port-entry!)))) (defn close-servers "Convenience higher-level API function to gracefully shut down MCP and nREPL servers. @@ -487,39 +494,38 @@ (defn build-and-start-mcp-server-impl "Internal implementation of MCP server startup. - + Builds and starts an MCP server with the provided configuration. - + This is the main entry point for creating custom MCP servers. It handles: - - Validating input options - - Creating and starting the nREPL connection + - Creating the nREPL client and loading configuration - Setting up the working directory - Calling factory functions to create tools, prompts, and resources - Registering everything with the MCP server - + + REPL initialization happens lazily on first eval-code call. + Args: - nrepl-args: Map with connection settings - - :port (required) - nREPL server port + - :port (required if no :project-dir) - nREPL server port - :host (optional) - nREPL server host (defaults to localhost) - - :project-dir (optional) - Root directory for the project (must exist) - + - :project-dir (optional) - Root directory for the project. If provided, port is optional. + - component-factories: Map with factory functions - :make-tools-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of tools - - :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts + - :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts - :make-resources-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of resources - + All factory functions are optional. If not provided, that category won't be populated. - + Side effects: - Stores the nREPL client in core/nrepl-client-atom - Starts the MCP server on stdio - + Returns: nil" [nrepl-args component-factories] - ;; the nrepl-args are a map with :port and optional :host - ;; Note: validation should be done by caller - (let [_ (assert (:port nrepl-args) "Port must be provided for build-and-start-mcp-server-impl") - nrepl-client-map (create-and-start-nrepl-connection nrepl-args) + ;; Either :port or :project-dir must be provided (validated by ensure-port-if-needed) + (let [nrepl-client-map (create-and-start-nrepl-connection nrepl-args) working-dir (config/get-nrepl-user-dir nrepl-client-map) ;; Store nREPL process (if auto-started) in client map for cleanup nrepl-client-with-process (if-let [process (:nrepl-process nrepl-args)] @@ -536,40 +542,47 @@ (swap! nrepl-client-atom assoc :mcp-server mcp) nil)) +(defn ensure-port-if-needed + "Ensures port is present when project-dir is NOT provided. + When project-dir IS provided, port is optional (REPL not needed for config loading)." + [args] + (if (:project-dir args) + args ;; project-dir provided, port is optional + (ensure-port args))) ;; no project-dir, port is required + (defn build-and-start-mcp-server "Builds and starts an MCP server with optional automatic nREPL startup. - + This function wraps build-and-start-mcp-server-impl with nREPL auto-start capability. - + If auto-start conditions are met (see nrepl-launcher/should-start-nrepl?), it will: 1. Start an nREPL server process using :start-nrepl-cmd 2. Parse the port from process output (if no :port provided) 3. Pass the discovered port to the main MCP server setup - - Otherwise, it requires a :port parameter. - + + Port is only required when :project-dir is NOT provided (need REPL to discover project dir). + Args: - nrepl-args: Map with connection settings and optional nREPL start configuration - - :port (required if not auto-starting) - nREPL server port - When provided with :start-nrepl-cmd, uses fixed port instead of parsing - - :host (optional) - nREPL server host (defaults to localhost) - - :project-dir (optional) - Root directory for the project + - :port (required if no :project-dir) - nREPL server port + - :host (optional) - nREPL server host (defaults to localhost) + - :project-dir (optional) - Root directory for the project. If provided, port is optional. - :start-nrepl-cmd (optional) - Command to start nREPL server - + - component-factories: Map with factory functions - :make-tools-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of tools - - :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts + - :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts - :make-resources-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of resources - + Auto-start conditions (must satisfy ONE): 1. Both :start-nrepl-cmd AND :project-dir provided in nrepl-args 2. Current directory contains .clojure-mcp/config.edn with :start-nrepl-cmd - + Returns: nil" [nrepl-args component-factories] (-> nrepl-args validate-options nrepl-launcher/maybe-start-nrepl-process - ensure-port + ensure-port-if-needed (build-and-start-mcp-server-impl component-factories))) diff --git a/src/clojure_mcp/dialects.clj b/src/clojure_mcp/dialects.clj index a724170..d9b3a06 100644 --- a/src/clojure_mcp/dialects.clj +++ b/src/clojure_mcp/dialects.clj @@ -1,13 +1,9 @@ (ns clojure-mcp.dialects "Handles environment-specific behavior for different nREPL dialects. - - Supports different Clojure-like environments by providing expressions - and initialization sequences specific to each dialect." + + Provides dialect-specific expressions for initialization sequences. + The actual execution of these expressions is handled by clojure-mcp.nrepl." (: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])) (defn handle-bash-over-nrepl? [nrepl-env-type] @@ -73,65 +69,3 @@ (defmethod load-repl-helpers-exp :default [_] []) - -(defmulti fetch-project-directory-helper (fn [nrepl-env-type _] nrepl-env-type)) - -(defmethod fetch-project-directory-helper :default [nrepl-env-type nrepl-client-map] - ;; default to fetching from the nrepl - (when-let [exp (fetch-project-directory-exp nrepl-env-type)] - (try - (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)))) - -(defmethod fetch-project-directory-helper :scittle [_ nrepl-client-map] - (when-let [desc (nrepl/describe nrepl-client-map)] - (some-> desc :aux :cwd io/file (.getCanonicalPath)))) - -(defn fetch-project-directory - "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-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 - -(defn initialize-environment - "Initializes the environment by evaluating dialect-specific expressions. - Returns the nREPL client map unchanged." - [nrepl-client-map nrepl-env-type] - (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))) - nrepl-client-map) - -(defn load-repl-helpers - "Loads REPL helper functions appropriate for the environment." - [nrepl-client-map nrepl-env-type] - (when-let [helper-exps (not-empty (load-repl-helpers-exp nrepl-env-type))] - (doseq [exp helper-exps] - (nrepl/eval-code nrepl-client-map exp :session-type :tools))) - nrepl-client-map) - -(defn detect-nrepl-env-type [nrepl-client-map] - (when-let [{:keys [versions]} (nrepl/describe nrepl-client-map)] - (cond - (get versions :clojure) :clj - (get versions :babashka) :bb - (get versions :basilisp) :basilisp - (get versions :sci-nrepl) :scittle - :else :unknown))) - -;; Future dialect support placeholders diff --git a/src/clojure_mcp/main.clj b/src/clojure_mcp/main.clj index 1c4b7ad..e058ab0 100644 --- a/src/clojure_mcp/main.clj +++ b/src/clojure_mcp/main.clj @@ -33,7 +33,14 @@ (defn ^:deprecated my-tools [nrepl-client-atom] (tools/build-all-tools nrepl-client-atom)) -(defn start-mcp-server [opts] +(defn start-mcp-server + "Entry point for MCP server startup. + + When :project-dir is NOT provided, requires a REPL connection to discover + the project directory. When :project-dir IS provided, REPL is optional. + + REPL initialization happens lazily on first eval-code call." + [opts] ;; Configure logging before starting the server (logging/configure-logging! {:log-file (get opts :log-file logging/default-log-file) @@ -45,6 +52,24 @@ :make-prompts-fn make-prompts :make-resources-fn make-resources})) +(defn start + "Entry point for running from project directory. + + Sets :project-dir to current working directory unless :not-cwd is true. + This allows running without an immediate REPL connection - REPL initialization + happens lazily when first needed. + + Options: + - :not-cwd - If true, does NOT set project-dir to cwd (default: false) + - :port - Optional nREPL port (REPL is optional when project-dir is set) + - All other options supported by start-mcp-server" + [opts] + (let [not-cwd? (get opts :not-cwd false) + opts' (if not-cwd? + opts + (assoc opts :project-dir (System/getProperty "user.dir")))] + (start-mcp-server opts'))) + ;; not sure if this is even needed ;; start the server diff --git a/src/clojure_mcp/nrepl.clj b/src/clojure_mcp/nrepl.clj index 7ae463b..f6f8db1 100644 --- a/src/clojure_mcp/nrepl.clj +++ b/src/clojure_mcp/nrepl.clj @@ -1,18 +1,33 @@ (ns clojure-mcp.nrepl (:require + [clojure.java.io :as io] + [clojure.string :as str] [nrepl.core :as nrepl] [nrepl.misc :as nrepl.misc] - [nrepl.transport :as transport]) + [nrepl.transport :as transport] + [clojure-mcp.dialects :as dialects] + [taoensso.timbre :as log]) (:import [java.io Closeable])) (defn- get-state [service] (get service ::state)) +(defn- make-port-entry + "Creates a new port entry with default values." + [] + {:sessions {} + :current-ns {} + :env-type nil + :initialized? false + :project-dir nil}) + (defn create + "Creates an nREPL service map with state tracking. + If port is provided, creates an initial entry for that port." ([] (create nil)) ([config] (let [port (:port config) - initial-state {:ports (if port {port {:sessions {}}} {})} + initial-state {:ports (if port {port (make-port-entry)} {})} state (atom initial-state)] (assoc config ::state state)))) @@ -81,6 +96,61 @@ (let [port (:port service)] (swap! (get-state service) assoc-in [:ports port :current-ns session-type] new-ns))) +;; ----------------------------------------------------------------------------- +;; Per-port state accessors for env-type, initialized?, and project-dir +;; ----------------------------------------------------------------------------- + +(defn get-port-env-type + "Returns the env-type for the service's current port, or nil if not yet detected." + [service] + (let [port (:port service) + state @(get-state service)] + (get-in state [:ports port :env-type]))) + +(defn set-port-env-type! + "Sets the env-type for the service's current port." + [service env-type] + (let [port (:port service)] + (swap! (get-state service) assoc-in [:ports port :env-type] env-type))) + +(defn port-initialized? + "Returns true if the service's current port has been initialized." + [service] + (let [port (:port service) + state @(get-state service)] + (get-in state [:ports port :initialized?] false))) + +(defn set-port-initialized! + "Marks the service's current port as initialized." + [service] + (let [port (:port service)] + (swap! (get-state service) assoc-in [:ports port :initialized?] true))) + +(defn get-port-project-dir + "Returns the project directory for the service's current port, or nil." + [service] + (let [port (:port service) + state @(get-state service)] + (get-in state [:ports port :project-dir]))) + +(defn set-port-project-dir! + "Sets the project directory for the service's current port." + [service project-dir] + (let [port (:port service)] + (swap! (get-state service) assoc-in [:ports port :project-dir] project-dir))) + +(defn ensure-port-entry! + "Ensures the port has an entry in the state atom. Used for lazy port addition. + Returns the service unchanged." + [service] + (let [port (:port service)] + (swap! (get-state service) update :ports + (fn [ports] + (if (contains? ports port) + ports + (assoc ports port (make-port-entry)))))) + service) + (def truncation-length 10000) (defn eval-code* @@ -106,9 +176,9 @@ :eval-id eval-id :session-type session-type})) -(defn eval-code - "Evaluates code synchronously using a new connection. - Returns a sequence of response messages." +(defn- eval-code-internal + "Internal eval - opens connection, no initialization. + Used by init code to avoid circular calls." [service code & {:keys [session-type]}] (let [conn (open-connection service)] (try @@ -117,6 +187,17 @@ (finally (close-connection conn))))) +;; Forward declaration - ensure-port-initialized! is defined later +(declare ensure-port-initialized!) + +(defn eval-code + "Evaluates code synchronously using a new connection. + Triggers lazy port initialization if not already initialized. + Returns a sequence of response messages." + [service code & {:keys [session-type]}] + (ensure-port-initialized! service) + (eval-code-internal service code :session-type session-type)) + (defn interrupt* "Sends an interrupt over an existing connection using the provided session/id." [{:keys [transport]} session-id eval-id] @@ -140,3 +221,140 @@ (with-open [conn (connect service)] (let [client (nrepl/client conn 10000)] (nrepl/combine-responses (nrepl/message client {:op "describe"}))))) + +;; ----------------------------------------------------------------------------- +;; Dialect-aware functions (moved from dialects.clj to avoid circular deps) +;; ----------------------------------------------------------------------------- + +(defn detect-nrepl-env-type + "Detects the nREPL environment type by querying the server's describe op." + [service] + (when-let [{:keys [versions]} (describe service)] + (cond + (get versions :clojure) :clj + (get versions :babashka) :bb + (get versions :basilisp) :basilisp + (get versions :sci-nrepl) :scittle + :else :unknown))) + +(defmulti ^:private fetch-project-directory-helper + "Helper multimethod for fetching project directory based on env-type." + (fn [nrepl-env-type _service] nrepl-env-type)) + +(defmethod fetch-project-directory-helper :default [nrepl-env-type service] + ;; default to fetching from the nrepl + ;; Uses eval-code-internal to avoid triggering full init (we're called during init) + (when-let [exp (dialects/fetch-project-directory-exp nrepl-env-type)] + (try + (let [result-value (->> (eval-code-internal service exp :session-type :tools) + nrepl/combine-responses + :value)] + result-value) + (catch Exception e + (log/warn e "Failed to fetch project directory") + nil)))) + +(defmethod fetch-project-directory-helper :scittle [_ service] + (when-let [desc (describe service)] + (some-> desc :aux :cwd io/file (.getCanonicalPath)))) + +(defn fetch-project-directory + "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." + [service 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 service)] + ;; 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)))) + +(defn initialize-environment + "Initializes the environment by evaluating dialect-specific expressions. + Uses eval-code-internal to avoid circular init calls. + Returns the service unchanged." + [service nrepl-env-type] + (log/debug "Initializing environment for" nrepl-env-type) + (when-let [init-exps (not-empty (dialects/initialize-environment-exp nrepl-env-type))] + (doseq [exp init-exps] + (eval-code-internal service exp))) + service) + +(defn load-repl-helpers + "Loads REPL helper functions appropriate for the environment. + Uses eval-code-internal to avoid circular init calls." + [service nrepl-env-type] + (when-let [helper-exps (not-empty (dialects/load-repl-helpers-exp nrepl-env-type))] + (doseq [exp helper-exps] + (eval-code-internal service exp :session-type :tools))) + service) + +;; ----------------------------------------------------------------------------- +;; Lazy per-port initialization +;; ----------------------------------------------------------------------------- + +(defn detect-and-store-env-type! + "Detects the environment type for the given service's port and stores it. + Returns the detected env-type. Does nothing if already detected." + [service] + (or (get-port-env-type service) + (let [env-type (detect-nrepl-env-type service)] + (set-port-env-type! service env-type) + (log/info "Detected env-type for port" (:port service) ":" env-type) + env-type))) + +(defn initialize-port! + "Initializes a port: runs init expressions and loads helpers. + Does nothing if already initialized. Not thread-safe - use ensure-port-initialized! + Returns the service." + [service] + (ensure-port-entry! service) + (when-not (port-initialized? service) + (log/info "Initializing port" (:port service)) + (let [env-type (detect-and-store-env-type! service)] + (initialize-environment service env-type) + (load-repl-helpers service env-type) + (set-port-initialized! service) + (log/info "Port" (:port service) "initialized successfully"))) + service) + +(defn ensure-port-initialized! + "Ensures a port is initialized before use. + Returns the service unchanged." + [service] + (cond-> service + (not (port-initialized? service)) initialize-port!)) + +(defn with-port + "Returns a service map configured for the specified port. + If port is nil or same as current, returns service unchanged. + Ensures the port entry exists in state but does NOT initialize." + [service port] + (if (or (nil? port) (= port (:port service))) + service + (-> service + (assoc :port port) + ensure-port-entry!))) + +(defn with-port-initialized + "Returns a service map for the specified port, ensuring it is initialized. + Combines with-port and ensure-port-initialized! for convenience." + [service port] + (-> service + (with-port port) + ensure-port-initialized!)) + +(defn read-nrepl-port-file + "Reads the .nrepl-port file from the given directory. + Returns the port number if found and valid, nil otherwise." + [dir] + (when dir + (let [port-file (io/file dir ".nrepl-port")] + (when (.exists port-file) + (try + (-> (slurp port-file) + str/trim + Integer/parseInt) + (catch Exception _ nil)))))) diff --git a/src/clojure_mcp/prompt_cli.clj b/src/clojure_mcp/prompt_cli.clj index f1cdf9d..82d4c00 100644 --- a/src/clojure_mcp/prompt_cli.clj +++ b/src/clojure_mcp/prompt_cli.clj @@ -8,7 +8,6 @@ [clojure.data.json :as json] [clojure-mcp.nrepl :as nrepl] [clojure-mcp.config :as config] - [clojure-mcp.dialects :as dialects] [clojure-mcp.tools.agent-tool-builder.core :as agent-core] [clojure-mcp.tools.agent-tool-builder.default-agents :as default-agents] [clojure-mcp.agent.general-agent :as general-agent] @@ -232,11 +231,11 @@ (let [nrepl-client-map (nrepl/create {:port port}) ;; Detect environment type - env-type (dialects/detect-nrepl-env-type nrepl-client-map) + env-type (nrepl/detect-nrepl-env-type nrepl-client-map) ;; Fetch project directory from REPL or use CLI option project-dir (or dir - (dialects/fetch-project-directory nrepl-client-map env-type nil)) + (nrepl/fetch-project-directory nrepl-client-map env-type nil)) ;; Load configuration _ (println (str "Working directory: " project-dir)) @@ -249,8 +248,8 @@ (assoc config-data :nrepl-env-type final-env-type)) ;; Initialize environment - _ (dialects/initialize-environment nrepl-client-map-with-config final-env-type) - _ (dialects/load-repl-helpers nrepl-client-map-with-config final-env-type) + _ (nrepl/initialize-environment nrepl-client-map-with-config final-env-type) + _ (nrepl/load-repl-helpers nrepl-client-map-with-config final-env-type) nrepl-client-atom (atom nrepl-client-map-with-config) diff --git a/src/clojure_mcp/sse_main.clj b/src/clojure_mcp/sse_main.clj index e3cff53..660a52a 100644 --- a/src/clojure_mcp/sse_main.clj +++ b/src/clojure_mcp/sse_main.clj @@ -21,3 +21,22 @@ :make-prompts-fn main/make-prompts :make-resources-fn main/make-resources})) +(defn start + "Entry point for running SSE server from project directory. + + Sets :project-dir to current working directory unless :not-cwd is true. + This allows running without an immediate REPL connection - REPL initialization + happens lazily when first needed. + + Options: + - :not-cwd - If true, does NOT set project-dir to cwd (default: false) + - :port - Optional nREPL port (REPL is optional when project-dir is set) + - :mcp-sse-port - Port for SSE server (required) + - All other options supported by start-sse-mcp-server" + [opts] + (let [not-cwd? (get opts :not-cwd false) + opts' (if not-cwd? + opts + (assoc opts :project-dir (System/getProperty "user.dir")))] + (start-sse-mcp-server opts'))) + diff --git a/src/clojure_mcp/tools/bash/core.clj b/src/clojure_mcp/tools/bash/core.clj index e08b812..1e65256 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-type] :as _args}] + [nrepl-client-atom {:keys [command working-directory timeout-ms session-type port] :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") @@ -179,8 +179,11 @@ EDN parsing failed: %s\nRaw result: %s" working-directory timeout-ms) eval-timeout-ms (+ 5000 timeout-ms) + ;; Use the provided port if available, otherwise use client's port + nrepl-client (cond-> @nrepl-client-atom + port (assoc :port port)) result (eval-core/evaluate-code - @nrepl-client-atom + nrepl-client (cond-> {:code clj-shell-code :timeout-ms eval-timeout-ms} session-type (assoc :session-type session-type))) diff --git a/src/clojure_mcp/tools/bash/tool.clj b/src/clojure_mcp/tools/bash/tool.clj index e1e52af..a2f9908 100644 --- a/src/clojure_mcp/tools/bash/tool.clj +++ b/src/clojure_mcp/tools/bash/tool.clj @@ -3,6 +3,7 @@ (:require [clojure-mcp.tool-system :as tool-system] [clojure-mcp.config :as config] + [clojure-mcp.nrepl :as nrepl] [clojure-mcp.utils.valid-paths :as valid-paths] [clojure-mcp.tools.bash.core :as core] [clojure.java.io :as io] @@ -115,11 +116,17 @@ in the response to determine command success.") timeout_ms (assoc :timeout-ms timeout_ms))))) (defmethod tool-system/execute-tool :bash [{:keys [nrepl-client-atom nrepl-session-type]} inputs] - (let [nrepl-client @nrepl-client-atom] - (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 + (let [nrepl-client @nrepl-client-atom + working-dir (config/get-nrepl-user-dir nrepl-client) + effective-port (or (:port nrepl-client) + (nrepl/read-nrepl-port-file working-dir))] + (if (and nrepl-session-type effective-port) + ;; Execute over nREPL with session type - pass the resolved port + (core/execute-bash-command-nrepl nrepl-client-atom + (assoc inputs + :session-type nrepl-session-type + :port effective-port)) + ;; Execute locally (fallback when no port available) (core/execute-bash-command nrepl-client inputs)))) (defmethod tool-system/format-results :bash [_ result] diff --git a/src/clojure_mcp/tools/eval/tool.clj b/src/clojure_mcp/tools/eval/tool.clj index dab6840..f998d09 100644 --- a/src/clojure_mcp/tools/eval/tool.clj +++ b/src/clojure_mcp/tools/eval/tool.clj @@ -2,7 +2,9 @@ "Implementation of the eval tool using the tool-system multimethod approach." (:require [clojure-mcp.tool-system :as tool-system] - [clojure-mcp.tools.eval.core :as core])) + [clojure-mcp.tools.eval.core :as core] + [clojure-mcp.config :as config] + [clojure-mcp.nrepl :as nrepl])) ;; Factory function to create the tool configuration (defn create-eval-tool @@ -27,6 +29,8 @@ If the returned value is too long it will be truncated. IMPORTANT: When using `require` to reload namespaces ALWAYS use `:reload` to ensure you get the latest version of files. +PORT PARAMETER: You can optionally specify a different nREPL port to evaluate on. This is useful when you have multiple nREPL servers running (e.g., a Clojure server and a ClojureScript server via shadow-cljs). The port will be lazily initialized on first use. + REPL helper functions are automatically loaded in the 'clj-mcp.repl-tools' namespace, providing convenient namespace and symbol exploration: Namespace/Symbol inspection functions: @@ -51,26 +55,53 @@ Examples: :properties {:code {:type :string :description "The Clojure code to evaluate."} :timeout_ms {:type :integer - :description "Optional timeout in milliseconds for evaluation."}} + :description "Optional timeout in milliseconds for evaluation."} + :port {:type :integer + :description "Optional nREPL port to evaluate on. If not specified, uses the default port. Useful for evaluating on different nREPL servers (e.g., ClojureScript via shadow-cljs)."}} :required [:code]}) -(defmethod tool-system/validate-inputs ::clojure-eval [_ inputs] - (let [{:keys [code timeout_ms]} inputs] +(defmethod tool-system/validate-inputs ::clojure-eval [{:keys [nrepl-client-atom]} inputs] + (let [{:keys [code timeout_ms port]} inputs] (when-not code (throw (ex-info (str "Missing required parameter: code " (pr-str inputs)) {:inputs inputs}))) (when (and timeout_ms (not (number? timeout_ms))) (throw (ex-info (str "Error parameter must be number: timeout_ms " (pr-str inputs)) {:inputs inputs}))) - ;; Return validated inputs (could do more validation/coercion here) - inputs)) + (when (and port (not (pos-int? port))) + (throw (ex-info (str "Error parameter must be positive integer: port " (pr-str inputs)) + {:inputs inputs}))) + ;; Resolve effective port: provided, configured, or from .nrepl-port file + (let [service @nrepl-client-atom + project-dir (config/get-nrepl-user-dir service) + effective-port (or port + (:port service) + (nrepl/read-nrepl-port-file project-dir))] + (when-not effective-port + (throw (ex-info "No nREPL port available. Please provide :port parameter, start server with a port configured, or ensure .nrepl-port file exists in project directory." + {:inputs inputs + :project-dir project-dir}))) + ;; Return inputs with resolved port + (assoc inputs :port effective-port)))) (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-type (assoc :session-type session-type) - (nil? timeout_ms) (assoc :timeout_ms timeout)))) + {:keys [timeout_ms port] :as inputs}] + ;; port is already resolved by validate-inputs + (let [base-client @nrepl-client-atom] + (try + (let [client (nrepl/with-port-initialized base-client port)] + ;; Delegate to core implementation with repair + (core/evaluate-with-repair client (cond-> inputs + session-type (assoc :session-type session-type) + (nil? timeout_ms) (assoc :timeout_ms timeout)))) + (catch java.net.ConnectException e + {:outputs [[:err (format "Failed to connect to nREPL server on port %d: %s. Ensure an nREPL server is running on that port." + port (.getMessage e))]] + :error true}) + (catch java.net.SocketException e + {:outputs [[:err (format "Connection error to nREPL server on port %d: %s. The server may have disconnected." + port (.getMessage e))]] + :error true})))) (defmethod tool-system/format-results ::clojure-eval [_ {:keys [outputs error repaired] :as _eval-result}] ;; The core implementation now returns a map with :outputs (raw outputs), :error (boolean), and :repaired (boolean) diff --git a/src/clojure_mcp/tools/project/core.clj b/src/clojure_mcp/tools/project/core.clj index 8b044f3..50e6884 100644 --- a/src/clojure_mcp/tools/project/core.clj +++ b/src/clojure_mcp/tools/project/core.clj @@ -183,7 +183,8 @@ (let [{:keys [clojure java babashka _basilisp _python]} runtime-data ;; Read and parse project files locally deps (read-deps-edn working-dir) - bb-config (when babashka (read-bb-edn working-dir)) + ;; Always try to read bb.edn - file existence indicates babashka project + bb-config (read-bb-edn working-dir) project-clj (read-project-clj working-dir) lein-config (when project-clj (parse-lein-config project-clj)) project-type (determine-project-type deps project-clj bb-config) @@ -341,16 +342,30 @@ allowed-directories (config/get-allowed-directories nrepl-client) working-directory (config/get-nrepl-user-dir nrepl-client) nrepl-env-type (config/get-nrepl-env-type nrepl-client)] - (try - (when-let [formatted-info (some-> (mcp-nrepl/describe nrepl-client) - format-describe - (format-project-info allowed-directories working-directory nrepl-env-type))] - (let [result {:outputs [formatted-info] - :error false}] - ;; Cache the result in the atom - (swap! nrepl-client-atom assoc ::clojure-project-info result) - (log/debug "Cached project info for future use") - result)) - (catch Exception e - {:outputs [(str "Exception during project inspection: " (.getMessage e))] - :error true}))))) + ;; Guard: need working-directory and allowed-directories for project inspection + (if (or (nil? working-directory) (empty? allowed-directories)) + {:outputs ["Project inspection unavailable: missing working directory or allowed directories configuration"] + :error true} + (try + (let [;; Try configured port or .nrepl-port file + effective-port (or (:port nrepl-client) + (mcp-nrepl/read-nrepl-port-file working-directory)) + ;; Only fetch describe info if we have a port + describe-info (when effective-port + (some-> (mcp-nrepl/describe (assoc nrepl-client :port effective-port)) + format-describe)) + ;; Pass describe-info (may be nil) to format-project-info + formatted-info (format-project-info describe-info + allowed-directories + working-directory + nrepl-env-type)] + (when formatted-info + (let [result {:outputs [formatted-info] + :error false}] + ;; Cache the result in the atom + (swap! nrepl-client-atom assoc ::clojure-project-info result) + (log/debug "Cached project info for future use") + result))) + (catch Exception e + {:outputs [(str "Exception during project inspection: " (.getMessage e))] + :error true})))))) diff --git a/test/clojure_mcp/tools/bash/config_test.clj b/test/clojure_mcp/tools/bash/config_test.clj index 609e365..01cd528 100644 --- a/test/clojure_mcp/tools/bash/config_test.clj +++ b/test/clojure_mcp/tools/bash/config_test.clj @@ -31,8 +31,9 @@ (let [nrepl-calls (atom 0) local-calls (atom 0) - ;; Mock client with bash-over-nrepl = true + ;; Mock client with bash-over-nrepl = true and port configured mock-client-nrepl {:client :mock-client + :port 7888 ;; nrepl-available? requires port ::config/config {:allowed-directories [(System/getProperty "user.dir")] :nrepl-user-dir (System/getProperty "user.dir") :bash-over-nrepl true}} diff --git a/test/clojure_mcp/tools/bash/session_test.clj b/test/clojure_mcp/tools/bash/session_test.clj index c008ebc..387e88a 100644 --- a/test/clojure_mcp/tools/bash/session_test.clj +++ b/test/clojure_mcp/tools/bash/session_test.clj @@ -11,6 +11,7 @@ (testing "Bash command execution passes session-type to evaluate-code" (let [captured-args (atom nil) mock-client {:client :mock-client + :port 7888 ;; nrepl-available? requires port ::nrepl/state (atom {}) ::config/config {:allowed-directories [(System/getProperty "user.dir")] :nrepl-user-dir (System/getProperty "user.dir") @@ -19,24 +20,24 @@ bash-tool-config (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})] + (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)] + (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))) + (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)))))))) + (is (map? result)) + (is (= 0 (:exit-code result))) + (is (= "test" (:stdout result)))))))) diff --git a/test/clojure_mcp/tools/eval/tool_test.clj b/test/clojure_mcp/tools/eval/tool_test.clj index be493b7..8350db7 100644 --- a/test/clojure_mcp/tools/eval/tool_test.clj +++ b/test/clojure_mcp/tools/eval/tool_test.clj @@ -57,10 +57,11 @@ (is (fn? (:tool-fn reg-map)))))) (deftest validate-inputs-test - (testing "Validate accepts valid input" + (testing "Validate accepts valid input and resolves port" (let [tool-instance (eval-tool/create-eval-tool *nrepl-client-atom*) result (tool-system/validate-inputs tool-instance {:code "(+ 1 2)"})] - (is (= {:code "(+ 1 2)"} result)))) + (is (= "(+ 1 2)" (:code result))) + (is (pos-int? (:port result))))) (testing "Validate rejects missing code parameter" (let [tool-instance (eval-tool/create-eval-tool *nrepl-client-atom*)]