From a9a4e9802097a20d4d761e9f99371a75e58901da Mon Sep 17 00:00:00 2001 From: Jonathon McKitrick Date: Tue, 7 Oct 2025 09:53:25 -0400 Subject: [PATCH 1/2] Prompt management tool --- src/clojure_mcp/config.clj | 56 ++++-- src/clojure_mcp/tools.clj | 9 +- src/clojure_mcp/tools/manage_prompts/tool.clj | 159 ++++++++++++++++++ 3 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 src/clojure_mcp/tools/manage_prompts/tool.clj diff --git a/src/clojure_mcp/config.clj b/src/clojure_mcp/config.clj index 06e84e4f..e94229f6 100644 --- a/src/clojure_mcp/config.clj +++ b/src/clojure_mcp/config.clj @@ -2,6 +2,7 @@ (:require [clojure.java.io :as io] [clojure.string :as str] + [clojure.pprint :as pprint] [clojure-mcp.dialects :as dialects] [clojure-mcp.config.schema :as schema] [clojure.edn :as edn] @@ -34,13 +35,46 @@ [] (io/file (System/getProperty "user.home") ".clojure-mcp" "config.edn")) -(defn- load-home-config +(defn load-home-config "Loads configuration from ~/.clojure-mcp/config.edn. Returns empty map if the file doesn't exist." [] (let [home-config-file (get-home-config-path)] (load-config-file (.getPath home-config-file)))) +(defn save-config-file + "Saves a config map to the specified path in EDN format. + Creates parent directories if they don't exist." + [config-map file-path] + (let [file (io/file file-path) + parent (.getParentFile file)] + (when (and parent (not (.exists parent))) + (.mkdirs parent)) + (try + (spit file (with-out-str (pprint/pprint config-map))) + (log/info "Config saved to:" (.getPath file)) + true + (catch Exception e + (log/error e "Failed to save config file:" (.getPath file)) + false)))) + +(defn- get-project-config-path + "Returns the path to the project config file in the given working directory." + [working-dir] + (io/file working-dir ".clojure-mcp" "config.edn")) + +(defn load-project-config + "Loads configuration from .clojure-mcp/config.edn in the given working directory. + Returns empty map if the file doesn't exist." + [working-dir] + (let [project-config-file (get-project-config-path working-dir)] + (load-config-file (.getPath project-config-file)))) + +(defn save-project-config + "Saves a config map to the project's config file." + [config-map working-dir] + (save-config-file config-map (get-project-config-path working-dir))) + (defn- deep-merge "Deeply merges maps, with the second map taking precedence. For non-map values, the second value wins." @@ -61,11 +95,11 @@ "Validates a sequence of config files with their file paths. Takes a sequence of maps with :config and :file-path keys. Validates each config sequentially and throws on the first error found. - + Each map should have: - :config - The configuration map to validate - :file-path - The path to the config file (for error reporting) - + Throws ExceptionInfo with: - :type ::schema-error - :errors - Validation errors from Malli @@ -137,7 +171,7 @@ (conj {:config home-config :file-path (.getCanonicalPath home-config-path)}) - ;; Only validate project config if it exists and has content + ;; Only validate project config if it exists and has content (seq project-config) (conj {:config project-config :file-path (.getCanonicalPath project-config-file)}))) @@ -331,13 +365,13 @@ (defn tool-id-enabled? "Check if a tool should be enabled based on :enable-tools and :disable-tools config. - + Logic: - If :enable-tools is nil, all tools are enabled (unless in :disable-tools) - If :enable-tools is [], no tools are enabled - If :enable-tools is provided, only those tools are enabled - :disable-tools is then applied to remove tools from the enabled set - + Both config lists can contain strings or keywords - they are normalized to keywords." [nrepl-client-map tool-id] (let [enable-tools (get-enable-tools nrepl-client-map) @@ -365,13 +399,13 @@ (defn prompt-name-enabled? "Check if a prompt should be enabled based on :enable-prompts and :disable-prompts config. - + Logic: - If :enable-prompts is nil, all prompts are enabled (unless in :disable-prompts) - If :enable-prompts is [], no prompts are enabled - If :enable-prompts is provided, only those prompts are enabled - :disable-prompts is then applied to remove prompts from the enabled set - + Prompt names are converted to keywords for comparison. Both config lists can contain strings or keywords." [nrepl-client-map prompt-name] @@ -398,13 +432,13 @@ (defn resource-name-enabled? "Check if a resource should be enabled based on :enable-resources and :disable-resources config. - + Logic: - If :enable-resources is nil, all resources are enabled (unless in :disable-resources) - If :enable-resources is [], no resources are enabled - If :enable-resources is provided, only those resources are enabled - :disable-resources is then applied to remove resources from the enabled set - + Resource names are used as strings for comparison. Both config lists should contain strings." [nrepl-client-map resource-name] @@ -444,5 +478,3 @@ Uses set-config* to perform the actual update." [nrepl-client-atom k v] (swap! nrepl-client-atom set-config* k v)) - - diff --git a/src/clojure_mcp/tools.clj b/src/clojure_mcp/tools.clj index 5912f587..48dd8b9f 100644 --- a/src/clojure_mcp/tools.clj +++ b/src/clojure_mcp/tools.clj @@ -30,7 +30,8 @@ (def experimental-tool-syms "Symbols for experimental tool creation functions" - ['clojure-mcp.tools.scratch-pad.tool/scratch-pad-tool]) + ['clojure-mcp.tools.scratch-pad.tool/scratch-pad-tool + 'clojure-mcp.tools.manage-prompts.tool/manage-prompts-tool]) ;; Note: introspection tools are already included in read-only-tool-syms ;; This is kept for documentation purposes but not used in all-tool-syms @@ -111,7 +112,7 @@ (defn build-custom-tools "Build a custom set of tools from provided symbols. - + Example: (build-custom-tools nrepl-client-atom ['clojure-mcp.tools.eval.tool/eval-code @@ -122,7 +123,7 @@ (defn filter-tools "Filters tools based on enable/disable lists. - + Args: - all-tools: Vector of all available tools - enable-tools: Can be: @@ -130,7 +131,7 @@ - :all: returns all tools (minus disabled) - [...]: returns only specified tools (minus disabled) - disable-tools: List of tool IDs to disable - + Returns: Filtered vector of tools" [all-tools enable-tools disable-tools] (cond diff --git a/src/clojure_mcp/tools/manage_prompts/tool.clj b/src/clojure_mcp/tools/manage_prompts/tool.clj new file mode 100644 index 00000000..e2a36bad --- /dev/null +++ b/src/clojure_mcp/tools/manage_prompts/tool.clj @@ -0,0 +1,159 @@ +(ns clojure-mcp.tools.manage-prompts.tool + "Implementation of the manage-prompts tool for saving and managing user prompts in config." + (:require + [clojure-mcp.tool-system :as tool-system] + [clojure-mcp.config :as config] + [clojure.tools.logging :as log])) + +(defmethod tool-system/tool-name :manage-prompts [_] + "manage_prompts") + +(defmethod tool-system/tool-description :manage-prompts [_] + "Manages user-defined prompts stored in the project configuration file. + +Operations: +- add_prompt: Add or update a prompt with a name and content +- remove_prompt: Remove a prompt by name +- list_prompts: List all saved prompts +- get_prompt: Get a specific prompt by name + +Prompts are stored in the project config file (.clojure-mcp/config.edn) under the :prompts key. + +Use this tool when users want to: +- Save a prompt they've created for later reuse +- View their saved prompts +- Update or remove existing prompts +- Retrieve a specific prompt + +Example usage: +- Save: manage_prompts(op: \"add_prompt\", prompt_name: \"code-reviewer\", prompt_content: \"Review the following code for best practices...\") +- List: manage_prompts(op: \"list_prompts\") +- Get: manage_prompts(op: \"get_prompt\", prompt_name: \"code-reviewer\") +- Remove: manage_prompts(op: \"remove_prompt\", prompt_name: \"code-reviewer\")") + +(defmethod tool-system/tool-schema :manage-prompts [_] + {:type "object" + :properties {"op" {:type "string" + :enum ["add_prompt" "remove_prompt" "list_prompts" "get_prompt"] + :description "The operation to perform: add_prompt, remove_prompt, list_prompts, or get_prompt"} + "prompt_name" {:type "string" + :description "Name of the prompt (required for add_prompt, remove_prompt, get_prompt)"} + "prompt_content" {:type "string" + :description "The prompt text content (required for add_prompt)"}} + :required ["op"]}) + +(defmethod tool-system/validate-inputs :manage-prompts [_ {:keys [op prompt_name prompt_content] :as inputs}] + (when-not op + (throw (ex-info "Missing required parameter: op" {:inputs inputs}))) + + (when-not (#{"add_prompt" "remove_prompt" "list_prompts" "get_prompt"} op) + (throw (ex-info "Invalid operation. Must be one of: add_prompt, remove_prompt, list_prompts, get_prompt" + {:op op :inputs inputs}))) + + ;; Operation-specific validation + (case op + "add_prompt" (do + (when-not prompt_name + (throw (ex-info "Missing required parameter for add_prompt: prompt_name" {:inputs inputs}))) + (when-not prompt_content + (throw (ex-info "Missing required parameter for add_prompt: prompt_content" {:inputs inputs})))) + ("remove_prompt" "get_prompt") (when-not prompt_name + (throw (ex-info (str "Missing required parameter for " op ": prompt_name") + {:inputs inputs}))) + "list_prompts" nil) ;; no additional validation needed + + inputs) + +(defmethod tool-system/execute-tool :manage-prompts [{:keys [working-dir]} {:keys [op prompt_name prompt_content]}] + (try + (let [result (case op + "add_prompt" + (let [current-config (config/load-project-config working-dir) + updated-config (assoc-in current-config [:prompts prompt_name] prompt_content)] + (config/save-project-config updated-config working-dir) + {:message (str "Prompt '" prompt_name "' saved successfully") + :prompt_name prompt_name}) + + "remove_prompt" + (let [current-config (config/load-project-config working-dir)] + (if (get-in current-config [:prompts prompt_name]) + (let [updated-config (update current-config :prompts dissoc prompt_name)] + (config/save-project-config updated-config working-dir) + {:message (str "Prompt '" prompt_name "' removed successfully") + :prompt_name prompt_name}) + {:message (str "Prompt '" prompt_name "' not found") + :prompt_name prompt_name})) + + "list_prompts" + (let [current-config (config/load-project-config working-dir) + prompts (:prompts current-config)] + {:prompts (or prompts {}) + :count (count (or prompts {}))}) + + "get_prompt" + (let [current-config (config/load-project-config working-dir) + prompt (get-in current-config [:prompts prompt_name])] + (if prompt + {:prompt_name prompt_name + :prompt_content prompt} + {:message (str "Prompt '" prompt_name "' not found") + :prompt_name prompt_name})))] + {:result result + :error false}) + (catch Exception e + (log/error e (str "Error executing manage_prompts operation: " (.getMessage e))) + {:error true + :result (str "Error: " (.getMessage e))}))) + +(defmethod tool-system/format-results :manage-prompts [_ {:keys [error result]}] + (if error + {:result [result] + :error true} + (let [formatted (case (:message result) + nil + ;; list_prompts or get_prompt with results + (if (:prompts result) + (if (empty? (:prompts result)) + "No prompts saved yet." + (str "Saved prompts (" (:count result) "):\n" + (clojure.string/join "\n" + (map (fn [[k v]] + (let [desc (if (map? v) + (or (:description v) + (:content v) + (str v)) + (str v)) + preview (subs desc 0 (min 60 (count desc)))] + (str "- " k ": " preview + (when (> (count desc) 60) "...")))) + (:prompts result))))) + ;; get_prompt with result + (let [content (:prompt_content result)] + (str "Prompt: " (:prompt_name result) "\n\n" + (if (map? content) + (clojure.string/join "\n" + [(when (:description content) + (str "Description: " (:description content))) + (when (:content content) + (str "Content:\n" (:content content))) + (when (:file-path content) + (str "File: " (:file-path content)))]) + content)))) + ;; Messages from add/remove operations or not found + (:message result))] + {:result [formatted] + :error false}))) + +(defn create-manage-prompts-tool [nrepl-client-atom] + (let [working-dir (config/get-nrepl-user-dir @nrepl-client-atom)] + {:tool-type :manage-prompts + :nrepl-client-atom nrepl-client-atom + :working-dir working-dir})) + +(defn manage-prompts-tool + "Returns the registration map for the manage-prompts tool. + + Parameters: + - nrepl-client-atom: Atom containing the nREPL client" + [nrepl-client-atom] + (tool-system/registration-map (create-manage-prompts-tool nrepl-client-atom))) From 8378f588c9b77bf390a07c039c4bd3eab0c74e33 Mon Sep 17 00:00:00 2001 From: Jonathon McKitrick Date: Tue, 7 Oct 2025 10:21:05 -0400 Subject: [PATCH 2/2] Cleanup after rabbit review --- src/clojure_mcp/tools/manage_prompts/tool.clj | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/clojure_mcp/tools/manage_prompts/tool.clj b/src/clojure_mcp/tools/manage_prompts/tool.clj index e2a36bad..97545270 100644 --- a/src/clojure_mcp/tools/manage_prompts/tool.clj +++ b/src/clojure_mcp/tools/manage_prompts/tool.clj @@ -3,6 +3,7 @@ (:require [clojure-mcp.tool-system :as tool-system] [clojure-mcp.config :as config] + [clojure.string :as str] [clojure.tools.logging :as log])) (defmethod tool-system/tool-name :manage-prompts [_] @@ -116,28 +117,28 @@ Example usage: (if (empty? (:prompts result)) "No prompts saved yet." (str "Saved prompts (" (:count result) "):\n" - (clojure.string/join "\n" - (map (fn [[k v]] - (let [desc (if (map? v) - (or (:description v) - (:content v) - (str v)) - (str v)) - preview (subs desc 0 (min 60 (count desc)))] - (str "- " k ": " preview - (when (> (count desc) 60) "...")))) - (:prompts result))))) + (str/join "\n" + (map (fn [[k v]] + (let [desc (if (map? v) + (or (:description v) + (:content v) + (str v)) + (str v)) + preview (subs desc 0 (min 60 (count desc)))] + (str "- " k ": " preview + (when (> (count desc) 60) "...")))) + (:prompts result))))) ;; get_prompt with result (let [content (:prompt_content result)] (str "Prompt: " (:prompt_name result) "\n\n" (if (map? content) - (clojure.string/join "\n" - [(when (:description content) - (str "Description: " (:description content))) - (when (:content content) - (str "Content:\n" (:content content))) - (when (:file-path content) - (str "File: " (:file-path content)))]) + (str/join "\n" + [(when (:description content) + (str "Description: " (:description content))) + (when (:content content) + (str "Content:\n" (:content content))) + (when (:file-path content) + (str "File: " (:file-path content)))]) content)))) ;; Messages from add/remove operations or not found (:message result))] @@ -152,7 +153,7 @@ Example usage: (defn manage-prompts-tool "Returns the registration map for the manage-prompts tool. - + Parameters: - nrepl-client-atom: Atom containing the nREPL client" [nrepl-client-atom]