Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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
Expand Down
15 changes: 7 additions & 8 deletions deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,15 @@
{:mcp
{:exec-fn clojure-mcp.main/start-mcp-server
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Update the exec-fn to match the new unified start entry point.

The :dev-mcp alias (line 72) was updated to use clojure-mcp.main/start, but this :mcp alias still references clojure-mcp.main/start-mcp-server. Per the PR objectives introducing a unified start entry point, this should be updated to start for consistency.

Apply this diff:

-  {:exec-fn clojure-mcp.main/start-mcp-server
+  {:exec-fn clojure-mcp.main/start
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{:exec-fn clojure-mcp.main/start-mcp-server
{:exec-fn clojure-mcp.main/start
🤖 Prompt for AI Agents
In deps.edn around line 39 (alias :mcp), the :exec-fn is still set to
clojure-mcp.main/start-mcp-server; update that value to clojure-mcp.main/start
so the :mcp alias uses the new unified start entry point (mirror the change made
for :dev-mcp), ensuring the alias references clojure-mcp.main/start instead of
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
Expand All @@ -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"]
}}
Expand Down
157 changes: 85 additions & 72 deletions src/clojure_mcp/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!!" {})))
Expand All @@ -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.
Expand Down Expand Up @@ -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)]
Expand All @@ -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)))
72 changes: 3 additions & 69 deletions src/clojure_mcp/dialects.clj
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
Loading