Skip to content

Commit ea88611

Browse files
bhaumanBruce Hauman
andauthored
Add lazy per-port nREPL initialization with graceful degradation (#135)
* Add lazy per-port nREPL initialization with graceful degradation - Add lazy initialization of nREPL connections per-port in nrepl.clj - Create unified start entry point in main.clj - Add nrepl-available? check to bash tool for graceful fallback - Add nrepl-available? check to inspect-project for graceful degradation - Fix test and lint commands in CLAUDE.md * Improve error message for nREPL connection failures in eval tool Catch ConnectException and SocketException in execute-tool and return a clear error message that includes the port number and suggests checking that an nREPL server is running. * Add start entry point to sse_main.clj and update deps.edn aliases - Add start function to sse_main.clj that sets project-dir from CWD - Update :mcp-sse alias to use new start function (port now optional) - Update :mcp and :dev-mcp aliases to use lazy initialization * Add .nrepl-port file fallback for eval tool port discovery When no port is provided or configured, the eval tool now checks for a .nrepl-port file in the project directory. This allows automatic connection to any nREPL server that writes its port to this standard location. * Refactor eval tool to resolve port once in validate-inputs Move port resolution logic (provided, configured, or .nrepl-port file) to validate-inputs and pass the resolved port to execute-tool via the inputs map. This removes duplicate port resolution code. * Add .nrepl-port file fallback for eval, bash, and inspect-project tools - Move read-nrepl-port-file to nrepl.clj as shared utility - Update eval tool to discover port from .nrepl-port when not configured - Update bash tool to use discovered port for nREPL execution - Update inspect-project to fetch describe info using discovered port - Update eval tool test to expect resolved port in validate-inputs * Remove unnecessary per-port locking from lazy initialization The locking mechanism was unnecessary because: - swap! on atoms is already atomic - Initialization operations are idempotent - The port-initialized? check provides sufficient protection Simplified ensure-port-initialized! to use cond->. --------- Co-authored-by: Bruce Hauman <bhauman@gmail.com>
1 parent 2f7940d commit ea88611

File tree

15 files changed

+478
-214
lines changed

15 files changed

+478
-214
lines changed

CLAUDE.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
## Build Commands
44
- Run REPL with MCP server: `clojure -X:mcp` (starts on port 7888)
5-
- Run all tests: `clojure -X:test`
6-
- Run single test: `clojure -X:test :dirs '["test"]' :include '"repl_tools_test"'`
7-
- Run linter: `clojure -M:lint` (checks src directory)
5+
- Run all tests: `clojure -M:test`
6+
- Run linter: `clj-kondo --lint src` or `clj-kondo --lint src test` for both
87
- Build JAR: `clojure -T:build ci`
98
- Install locally: `clojure -T:build install`
109

@@ -16,7 +15,6 @@
1615
- **Namespaces**: Align with directory structure (`clojure-mcp.repl-tools`)
1716
- **Testing**: Use `deftest` with descriptive names; `testing` for subsections; `is` for assertions
1817
- **REPL Development**: Prefer REPL-driven development for rapid iteration and feedback
19-
- Don't use the clojure -X:lint tool in the workflow
2018

2119
## MCP Tool Guidelines
2220
- Include clear tool `:description` for LLM guidance

deps.edn

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,15 @@
3838
{:mcp
3939
{:exec-fn clojure-mcp.main/start-mcp-server
4040
;; it needs an nrepl port to talk to
41-
:exec-args {:port 7888}}
41+
;; :exec-args {:port 7888}
42+
}
4243

4344
:mcp-sse
4445
{:extra-deps {jakarta.servlet/jakarta.servlet-api {:mvn/version "6.1.0"}
4546
org.eclipse.jetty/jetty-server {:mvn/version "11.0.20"}
4647
org.eclipse.jetty/jetty-servlet {:mvn/version "11.0.20"}}
47-
:exec-fn clojure-mcp.sse-main/start-sse-mcp-server
48-
49-
:exec-args {:port 7888 ;; the nrepl port to connect to
50-
;; specify the :mcp-sse-port to listen on
51-
:mcp-sse-port 8078}}
48+
:exec-fn clojure-mcp.sse-main/start
49+
:exec-args {:mcp-sse-port 8078}}
5250

5351
:mcp-figwheel
5452
{:exec-fn clojure-mcp.main-examples.figwheel-main/start-mcp-server
@@ -71,9 +69,10 @@
7169

7270
:dev-mcp
7371
{:extra-paths ["dev" "test"]
74-
:exec-fn clojure-mcp.main/start-mcp-server
72+
:exec-fn clojure-mcp.main/start
7573
;; it needs an nrepl port to talk to
76-
:exec-args {:port 7888
74+
:exec-args {;; :port 7888
75+
:enable-logging? true
7776
;; test auto starting the repl
7877
;; :start-nrepl-cmd ["clojure" "-M:nrepl"]
7978
}}

src/clojure_mcp/core.clj

Lines changed: 85 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
[taoensso.timbre :as log]
66
[clojure-mcp.nrepl :as nrepl]
77
[clojure-mcp.config :as config]
8-
[clojure-mcp.dialects :as dialects]
98
[clojure-mcp.file-content :as file-content]
109
[clojure-mcp.nrepl-launcher :as nrepl-launcher])
1110
(:import [io.modelcontextprotocol.server.transport
@@ -255,7 +254,7 @@
255254
(throw e)))))
256255

257256
(defn fetch-config [nrepl-client-map config-file cli-env-type env-type project-dir]
258-
(let [user-dir (dialects/fetch-project-directory nrepl-client-map env-type project-dir)]
257+
(let [user-dir (nrepl/fetch-project-directory nrepl-client-map env-type project-dir)]
259258
(when-not user-dir
260259
(log/warn "Could not determine working directory")
261260
(throw (ex-info "No project directory!!" {})))
@@ -269,58 +268,66 @@
269268
(assoc nrepl-client-map ::config/config (assoc config :nrepl-env-type final-env-type)))))
270269

271270
(defn create-and-start-nrepl-connection
272-
"Convenience higher-level API function to create and initialize an nREPL connection.
273-
274-
This function handles the complete setup process including:
275-
- Creating the nREPL client connection
276-
- Loading required namespaces and helpers (if Clojure environment)
271+
"Creates an nREPL client map and loads configuration.
272+
273+
This function handles:
274+
- Creating the nREPL client connection (if port provided)
277275
- Setting up the working directory
278276
- Loading configuration
279-
280-
Takes initial-config map with :port and optional :host, :project-dir, :nrepl-env-type, :config-file.
277+
278+
REPL initialization (env detection, init expressions, helpers) happens lazily
279+
when the first eval-code call is made.
280+
281+
Takes initial-config map with optional :port, :host, :project-dir, :nrepl-env-type, :config-file.
282+
- If :project-dir is provided, uses it directly (no REPL query needed)
283+
- If :project-dir is NOT provided, queries REPL for project directory (requires :port)
284+
281285
Returns the configured nrepl-client-map with ::config/config attached."
282-
[{:keys [project-dir config-file] :as initial-config}]
283-
(log/info "Creating nREPL connection with config:" initial-config)
286+
[{:keys [project-dir config-file port] :as initial-config}]
287+
(if port
288+
(log/info "Creating nREPL client for port" port)
289+
(log/info "Starting without nREPL connection (project-dir mode)"))
284290
(try
285291
(let [nrepl-client-map (nrepl/create (dissoc initial-config :project-dir :nrepl-env-type))
286292
cli-env-type (:nrepl-env-type initial-config)
287-
_ (log/info "nREPL client map created")
288-
;; Detect environment type early
289-
;; TODO this needs to be sorted out
290-
env-type (dialects/detect-nrepl-env-type nrepl-client-map)
291-
nrepl-client-map-with-config (fetch-config nrepl-client-map
292-
config-file
293-
cli-env-type
294-
env-type
295-
project-dir)
296-
nrepl-env-type' (config/get-config nrepl-client-map-with-config :nrepl-env-type)]
297-
(log/debug "Initializing Clojure environment")
298-
(dialects/initialize-environment nrepl-client-map-with-config nrepl-env-type')
299-
(dialects/load-repl-helpers nrepl-client-map-with-config nrepl-env-type')
300-
(log/debug "Environment initialized")
301-
nrepl-client-map-with-config)
293+
_ (log/info "nREPL client map created")]
294+
(if project-dir
295+
;; Project dir provided - load config directly, no REPL query needed
296+
(let [user-dir (.getCanonicalPath (io/file project-dir))
297+
_ (log/info "Working directory set to:" user-dir)
298+
config (load-config-handling-validation-errors config-file user-dir)
299+
;; Use cli-env-type or config's env-type, default to :clj
300+
final-env-type (or cli-env-type
301+
(:nrepl-env-type config)
302+
:clj)]
303+
(assoc nrepl-client-map ::config/config (assoc config :nrepl-env-type final-env-type)))
304+
;; No project dir - need to query REPL (requires port)
305+
(let [;; Detect environment type (uses describe op, no full init needed)
306+
env-type (nrepl/detect-nrepl-env-type nrepl-client-map)
307+
_ (nrepl/set-port-env-type! nrepl-client-map env-type)]
308+
(fetch-config nrepl-client-map config-file cli-env-type env-type project-dir))))
302309
(catch Exception e
303310
(log/error e "Failed to create nREPL connection")
304311
(throw e))))
305312

306313
(defn create-additional-connection
314+
"Creates a service map for an additional port. Initialization is lazy.
315+
The returned service shares the base client's state atom and config,
316+
but targets a different port.
317+
318+
Note: The third argument (initialize-fn) is deprecated and ignored.
319+
Initialization now happens lazily via nrepl/ensure-port-initialized!"
307320
([nrepl-client-atom initial-config]
308-
(create-additional-connection nrepl-client-atom initial-config identity))
309-
([nrepl-client-atom initial-config initialize-fn]
310-
(log/info "Creating additional nREPL connection" initial-config)
311-
(try
312-
(let [nrepl-client-map (nrepl/create initial-config)]
313-
;; copy config
314-
;; maybe we should create this just like the normal nrelp connection?
315-
;; we should introspect the project and get a working directory
316-
;; and maybe add it to allowed directories for both
317-
(when initialize-fn (initialize-fn nrepl-client-map))
318-
(assert (::config/config @nrepl-client-atom))
319-
;; copy config over for now
320-
(assoc nrepl-client-map ::config/config (::config/config @nrepl-client-atom)))
321-
(catch Exception e
322-
(log/error e "Failed to create additional nREPL connection")
323-
(throw e)))))
321+
(create-additional-connection nrepl-client-atom initial-config nil))
322+
([nrepl-client-atom {:keys [port host]} _deprecated-initialize-fn]
323+
(log/info "Creating additional connection config for port" port)
324+
(let [base-client @nrepl-client-atom]
325+
(assert (::config/config base-client) "Base client must have config")
326+
(-> base-client
327+
(assoc :port port)
328+
(cond-> host (assoc :host host))
329+
;; Ensure port entry exists but don't initialize yet (lazy init)
330+
nrepl/ensure-port-entry!))))
324331

325332
(defn close-servers
326333
"Convenience higher-level API function to gracefully shut down MCP and nREPL servers.
@@ -487,39 +494,38 @@
487494

488495
(defn build-and-start-mcp-server-impl
489496
"Internal implementation of MCP server startup.
490-
497+
491498
Builds and starts an MCP server with the provided configuration.
492-
499+
493500
This is the main entry point for creating custom MCP servers. It handles:
494-
- Validating input options
495-
- Creating and starting the nREPL connection
501+
- Creating the nREPL client and loading configuration
496502
- Setting up the working directory
497503
- Calling factory functions to create tools, prompts, and resources
498504
- Registering everything with the MCP server
499-
505+
506+
REPL initialization happens lazily on first eval-code call.
507+
500508
Args:
501509
- nrepl-args: Map with connection settings
502-
- :port (required) - nREPL server port
510+
- :port (required if no :project-dir) - nREPL server port
503511
- :host (optional) - nREPL server host (defaults to localhost)
504-
- :project-dir (optional) - Root directory for the project (must exist)
505-
512+
- :project-dir (optional) - Root directory for the project. If provided, port is optional.
513+
506514
- component-factories: Map with factory functions
507515
- :make-tools-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of tools
508-
- :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts
516+
- :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts
509517
- :make-resources-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of resources
510-
518+
511519
All factory functions are optional. If not provided, that category won't be populated.
512-
520+
513521
Side effects:
514522
- Stores the nREPL client in core/nrepl-client-atom
515523
- Starts the MCP server on stdio
516-
524+
517525
Returns: nil"
518526
[nrepl-args component-factories]
519-
;; the nrepl-args are a map with :port and optional :host
520-
;; Note: validation should be done by caller
521-
(let [_ (assert (:port nrepl-args) "Port must be provided for build-and-start-mcp-server-impl")
522-
nrepl-client-map (create-and-start-nrepl-connection nrepl-args)
527+
;; Either :port or :project-dir must be provided (validated by ensure-port-if-needed)
528+
(let [nrepl-client-map (create-and-start-nrepl-connection nrepl-args)
523529
working-dir (config/get-nrepl-user-dir nrepl-client-map)
524530
;; Store nREPL process (if auto-started) in client map for cleanup
525531
nrepl-client-with-process (if-let [process (:nrepl-process nrepl-args)]
@@ -536,40 +542,47 @@
536542
(swap! nrepl-client-atom assoc :mcp-server mcp)
537543
nil))
538544

545+
(defn ensure-port-if-needed
546+
"Ensures port is present when project-dir is NOT provided.
547+
When project-dir IS provided, port is optional (REPL not needed for config loading)."
548+
[args]
549+
(if (:project-dir args)
550+
args ;; project-dir provided, port is optional
551+
(ensure-port args))) ;; no project-dir, port is required
552+
539553
(defn build-and-start-mcp-server
540554
"Builds and starts an MCP server with optional automatic nREPL startup.
541-
555+
542556
This function wraps build-and-start-mcp-server-impl with nREPL auto-start capability.
543-
557+
544558
If auto-start conditions are met (see nrepl-launcher/should-start-nrepl?), it will:
545559
1. Start an nREPL server process using :start-nrepl-cmd
546560
2. Parse the port from process output (if no :port provided)
547561
3. Pass the discovered port to the main MCP server setup
548-
549-
Otherwise, it requires a :port parameter.
550-
562+
563+
Port is only required when :project-dir is NOT provided (need REPL to discover project dir).
564+
551565
Args:
552566
- nrepl-args: Map with connection settings and optional nREPL start
553567
configuration
554-
- :port (required if not auto-starting) - nREPL server port
555-
When provided with :start-nrepl-cmd, uses fixed port instead of parsing
556-
- :host (optional) - nREPL server host (defaults to localhost)
557-
- :project-dir (optional) - Root directory for the project
568+
- :port (required if no :project-dir) - nREPL server port
569+
- :host (optional) - nREPL server host (defaults to localhost)
570+
- :project-dir (optional) - Root directory for the project. If provided, port is optional.
558571
- :start-nrepl-cmd (optional) - Command to start nREPL server
559-
572+
560573
- component-factories: Map with factory functions
561574
- :make-tools-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of tools
562-
- :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts
575+
- :make-prompts-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of prompts
563576
- :make-resources-fn - (fn [nrepl-client-atom working-dir] ...) returns seq of resources
564-
577+
565578
Auto-start conditions (must satisfy ONE):
566579
1. Both :start-nrepl-cmd AND :project-dir provided in nrepl-args
567580
2. Current directory contains .clojure-mcp/config.edn with :start-nrepl-cmd
568-
581+
569582
Returns: nil"
570583
[nrepl-args component-factories]
571584
(-> nrepl-args
572585
validate-options
573586
nrepl-launcher/maybe-start-nrepl-process
574-
ensure-port
587+
ensure-port-if-needed
575588
(build-and-start-mcp-server-impl component-factories)))

src/clojure_mcp/dialects.clj

Lines changed: 3 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
(ns clojure-mcp.dialects
22
"Handles environment-specific behavior for different nREPL dialects.
3-
4-
Supports different Clojure-like environments by providing expressions
5-
and initialization sequences specific to each dialect."
3+
4+
Provides dialect-specific expressions for initialization sequences.
5+
The actual execution of these expressions is handled by clojure-mcp.nrepl."
66
(:require [clojure.java.io :as io]
7-
[clojure.string :as str]
8-
[taoensso.timbre :as log]
9-
[nrepl.core :as nrepl-core]
10-
[clojure-mcp.nrepl :as nrepl]
117
[clojure-mcp.utils.file :as file-utils]))
128

139
(defn handle-bash-over-nrepl? [nrepl-env-type]
@@ -73,65 +69,3 @@
7369
(defmethod load-repl-helpers-exp :default
7470
[_]
7571
[])
76-
77-
(defmulti fetch-project-directory-helper (fn [nrepl-env-type _] nrepl-env-type))
78-
79-
(defmethod fetch-project-directory-helper :default [nrepl-env-type nrepl-client-map]
80-
;; default to fetching from the nrepl
81-
(when-let [exp (fetch-project-directory-exp nrepl-env-type)]
82-
(try
83-
(let [result-value (->> (nrepl/eval-code nrepl-client-map exp :session-type :tools)
84-
nrepl-core/combine-responses
85-
:value)]
86-
result-value)
87-
(catch Exception e
88-
(log/warn e "Failed to fetch project directory")
89-
nil))))
90-
91-
(defmethod fetch-project-directory-helper :scittle [_ nrepl-client-map]
92-
(when-let [desc (nrepl/describe nrepl-client-map)]
93-
(some-> desc :aux :cwd io/file (.getCanonicalPath))))
94-
95-
(defn fetch-project-directory
96-
"Fetches the project directory for the given nREPL client.
97-
If project-dir is provided in opts, returns it directly.
98-
Otherwise, evaluates environment-specific expression to get it."
99-
[nrepl-client-map nrepl-env-type project-dir-arg]
100-
(if project-dir-arg
101-
(.getCanonicalPath (io/file project-dir-arg))
102-
(let [raw-result (fetch-project-directory-helper nrepl-env-type nrepl-client-map)]
103-
;; nrepl sometimes returns strings with extra quotes and in a vector
104-
(if (and (vector? raw-result) (= 1 (count raw-result)) (string? (first raw-result)))
105-
(str/replace (first raw-result) #"^\"|\"$" "")
106-
raw-result))))
107-
108-
;; High-level wrapper functions that execute the expressions
109-
110-
(defn initialize-environment
111-
"Initializes the environment by evaluating dialect-specific expressions.
112-
Returns the nREPL client map unchanged."
113-
[nrepl-client-map nrepl-env-type]
114-
(log/debug "Initializing Clojure environment")
115-
(when-let [init-exps (not-empty (initialize-environment-exp nrepl-env-type))]
116-
(doseq [exp init-exps]
117-
(nrepl/eval-code nrepl-client-map exp)))
118-
nrepl-client-map)
119-
120-
(defn load-repl-helpers
121-
"Loads REPL helper functions appropriate for the environment."
122-
[nrepl-client-map nrepl-env-type]
123-
(when-let [helper-exps (not-empty (load-repl-helpers-exp nrepl-env-type))]
124-
(doseq [exp helper-exps]
125-
(nrepl/eval-code nrepl-client-map exp :session-type :tools)))
126-
nrepl-client-map)
127-
128-
(defn detect-nrepl-env-type [nrepl-client-map]
129-
(when-let [{:keys [versions]} (nrepl/describe nrepl-client-map)]
130-
(cond
131-
(get versions :clojure) :clj
132-
(get versions :babashka) :bb
133-
(get versions :basilisp) :basilisp
134-
(get versions :sci-nrepl) :scittle
135-
:else :unknown)))
136-
137-
;; Future dialect support placeholders

0 commit comments

Comments
 (0)