diff --git a/deps.edn b/deps.edn index 964146de..b112104c 100644 --- a/deps.edn +++ b/deps.edn @@ -8,6 +8,9 @@ ;; for prompt templating pogonos/pogonos {:mvn/version "0.2.1"} + ;; for configuration validation + metosin/malli {:mvn/version "0.19.1"} + ;; Clojure source manipulation rewrite-clj/rewrite-clj {:mvn/version "1.1.47"} dev.weavejester/cljfmt {:mvn/version "0.13.1"} diff --git a/doc/configuring-agents.md b/doc/configuring-agents.md index 250d6c5a..4cffff7f 100644 --- a/doc/configuring-agents.md +++ b/doc/configuring-agents.md @@ -16,10 +16,10 @@ The agent tool builder dynamically creates MCP tools from agent configurations d **Agents have NO tools by default.** You must explicitly specify which tools an agent can use via `:enable-tools`. This is a safety feature to prevent unintended access to powerful capabilities. Agents can now access ALL available tools, including: -- Read-only tools (grep, read-file, etc.) -- **File editing tools** (file-write, clojure-edit, etc.) -- **Code execution tools** (clojure-eval, bash) -- Agent tools (dispatch-agent, architect) +- Read-only tools (grep, read_file, etc.) +- **File editing tools** (file_write, clojure_edit, etc.) +- **Code execution tools** (clojure_eval, bash) +- Agent tools (dispatch_agent, architect) Always consider the security implications when granting tool access. @@ -33,7 +33,7 @@ Add an `:agents` key to your `.clojure-mcp/config.edn`: :description "A custom agent for specific tasks" :system-message "You are a helpful assistant specialized in..." :context true - :enable-tools [:read-file :grep] ; Must explicitly list tools + :enable-tools [:read_file :grep] ; Must explicitly list tools :disable-tools nil}]} ``` @@ -65,6 +65,9 @@ Each agent configuration supports the following keys: - **`:memory-size`** - Controls conversation memory behavior: - `nil`, `false`, or `< 10` - **Stateless** (default): Clears memory on each chat - `>= 10` - **Persistent**: Accumulates messages up to limit, then resets completely +- **`:track-file-changes`** - Whether to track and show file diffs (default: `true`) + - `true` - Shows diffs of file modifications made by the agent + - `false` - Disables file change tracking ## Available Tools for Agents @@ -74,33 +77,33 @@ Agents can potentially access all MCP tools: | Tool ID | Description | |---------|-------------| | `:LS` | Directory tree view | -| `:read-file` | Read file contents with pattern matching | +| `:read_file` | Read file contents with pattern matching | | `:grep` | Search file contents | -| `:glob-files` | Find files by pattern | +| `:glob_files` | Find files by pattern | | `:think` | Reasoning tool | -| `:clojure-inspect-project` | Project structure analysis | +| `:clojure_inspect_project` | Project structure analysis | ### Evaluation Tools | Tool ID | Description | |---------|-------------| -| `:clojure-eval` | Execute Clojure code in REPL | +| `:clojure_eval` | Execute Clojure code in REPL | | `:bash` | Execute shell commands | ### File Editing Tools | Tool ID | Description | |---------|-------------| -| `:file-write` | Create or overwrite files | -| `:file-edit` | Edit files by text replacement | -| `:clojure-edit` | Structure-aware Clojure editing | -| `:clojure-edit-replace-sexp` | S-expression replacement | +| `:file_write` | Create or overwrite files | +| `:file_edit` | Edit files by text replacement | +| `:clojure_edit` | Structure-aware Clojure editing | +| `:clojure_edit_replace_sexp` | S-expression replacement | ### Agent Tools | Tool ID | Description | |---------|-------------| -| `:dispatch-agent` | Launch sub-agents | +| `:dispatch_agent` | Launch sub-agents | | `:architect` | Technical planning assistant | -| `:scratch-pad` | Persistent data storage | -| `:code-critique` | Code review feedback | +| `:scratch_pad` | Persistent data storage | +| `:code_critique` | Code review feedback | ## Tool Access Patterns @@ -120,7 +123,7 @@ Agents can potentially access all MCP tools: :name "research_agent" :description "Can read but not modify" :system-message "You research and analyze code" - :enable-tools [:read-file :grep :glob-files :clojure-inspect-project]} + :enable-tools [:read_file :grep :glob_files :clojure_inspect_project]} ``` ### Write Access @@ -129,7 +132,7 @@ Agents can potentially access all MCP tools: :name "code_writer" :description "Can create and modify files" :system-message "You write and refactor code. Always test before writing." - :enable-tools [:read-file :grep :clojure-eval :file-write :clojure-edit]} + :enable-tools [:read_file :grep :clojure_eval :file_write :clojure_edit]} ``` ### Full Access @@ -139,7 +142,7 @@ Agents can potentially access all MCP tools: :description "Access to all tools - use with caution" :system-message "You have full system access. Confirm destructive operations." :enable-tools [:all] - :disable-tools [:dispatch-agent] ; Can still exclude specific tools + :disable-tools [:dispatch_agent] ; Can still exclude specific tools } ``` @@ -184,7 +187,7 @@ Here's a comprehensive configuration with agents of varying capability levels: :model :anthropic/fast :context true :memory-size false ; Explicitly stateless - :enable-tools [:grep :glob-files :read-file :clojure-inspect-project] + :enable-tools [:grep :glob_files :read_file :clojure_inspect_project] :disable-tools nil} ;; Documentation specialist - stateless @@ -195,7 +198,7 @@ Here's a comprehensive configuration with agents of varying capability levels: and focus on practical usage." :context ["README.md" "doc/"] :memory-size nil ; Stateless (same as omitting) - :enable-tools [:read-file :glob-files]} + :enable-tools [:read_file :glob_files]} ;; Test runner - can execute but not modify {:id :test-runner @@ -205,8 +208,8 @@ Here's a comprehensive configuration with agents of varying capability levels: but cannot modify files." :context ["test/"] :memory-size 5 ; < 10 = stateless - :enable-tools [:read-file :grep :glob-files :clojure-eval :bash] - :disable-tools [:file-write :file-edit :clojure-edit]} + :enable-tools [:read_file :grep :glob_files :clojure_eval :bash] + :disable-tools [:file_write :file_edit :clojure_edit]} ;; Code writer - persistent memory for multi-step refactoring {:id :code-writer @@ -218,10 +221,10 @@ Here's a comprehensive configuration with agents of varying capability levels: :model :openai/smart :context true :memory-size 50 ; Persistent - remembers recent edits - :enable-tools [:read-file :grep :glob-files - :clojure-eval :bash - :file-write :file-edit - :clojure-edit :clojure-edit-replace-sexp]} + :enable-tools [:read_file :grep :glob_files + :clojure_eval :bash + :file_write :file_edit + :clojure_edit :clojure_edit_replace_sexp]} ;; Full access agent with large memory {:id :admin-agent @@ -480,11 +483,11 @@ Examples: {:enable-tools nil} ; or omit entirely ;; Specific tools only -{:enable-tools [:read-file :grep]} +{:enable-tools [:read_file :grep]} ;; All tools except some {:enable-tools [:all] - :disable-tools [:bash :file-write]} + :disable-tools [:bash :file_write]} ``` ## Caching and Performance @@ -507,7 +510,7 @@ Examples: ### No Tools Available - Remember: agents have no tools by default - Explicitly list tools in `:enable-tools` -- Check tool IDs match exactly (e.g., `:read-file` not `:read_file`) +- Check tool IDs match exactly (use underscores: `:read_file` not `:read-file`) ### Model Not Found - Verify model is defined in `:models` configuration @@ -517,7 +520,7 @@ Examples: ### Tools Not Working - Verify tool IDs in `:enable-tools` match available tools - Check that tools aren't disabled in `:disable-tools` -- Ensure the agent has the necessary tool combination (e.g., needs `:read-file` before `:clojure-edit`) +- Ensure the agent has the necessary tool combination (e.g., needs `:read_file` before `:clojure_edit`) ### Memory Issues - **Agent doesn't remember previous conversation**: Check if `:memory-size` is `>= 10` for persistent memory @@ -541,7 +544,7 @@ If you have existing agent configurations: ### Migration Steps 1. Review existing agent configs 2. Add explicit `:enable-tools` lists -3. For read-only agents, add: `:enable-tools [:read-file :grep :glob-files ...]` +3. For read-only agents, add: `:enable-tools [:read_file :grep :glob_files ...]` 4. Test thoroughly before deploying ## Integration with MCP Server diff --git a/resources/clojure-mcp/agents/clojure_edit_agent.edn b/resources/clojure-mcp/agents/clojure_edit_agent.edn index 3a638344..ae8f4235 100644 --- a/resources/clojure-mcp/agents/clojure_edit_agent.edn +++ b/resources/clojure-mcp/agents/clojure_edit_agent.edn @@ -59,7 +59,6 @@ The agent will read the file, identify the locations to change based on context, Remember: Prefer structural editing tools over text-based editing for reliability and to maintain proper Clojure syntax." :context false - :include-mentioned-files true :enable-tools :all :disable-tools [:scratch_pad] :track-file-changes true} diff --git a/resources/configs/example-agents.edn b/resources/configs/example-agents.edn index 4842047c..0f3a7336 100644 --- a/resources/configs/example-agents.edn +++ b/resources/configs/example-agents.edn @@ -14,7 +14,7 @@ :system-message "You are a research specialist focused on finding code patterns, examples, and understanding project structure. Be thorough in your analysis and provide specific file locations and code snippets." :model :anthropic/claude-3-5-sonnet-20241022 ; Optional: specific model :context true ; Use default project context (PROJECT_SUMMARY.md and code index) - :enable-tools [:grep :glob-files :read-file :clojure-inspect-project] ; Must specify tools explicitly + :enable-tools [:grep :glob_files :read_file :clojure_inspect_project] ; Must specify tools explicitly :disable-tools nil} {:id :refactor-assistant @@ -22,7 +22,7 @@ :description "Agent specialized in analyzing code for refactoring opportunities" :system-message "You are a refactoring specialist. Analyze code for patterns that could be improved, suggest better abstractions, and identify duplicate code. Focus on readability and maintainability." :context ["PROJECT_SUMMARY.md" "doc/LLM_CODE_STYLE.md"] ; Specific files for context - :enable-tools [:read-file :grep :glob-files] + :enable-tools [:read_file :grep :glob_files] :disable-tools [:bash]} {:id :test-explorer @@ -30,8 +30,8 @@ :description "Agent for exploring and understanding test files" :system-message "You are a test exploration specialist. Help understand test structure, find relevant tests, and explain test patterns. Be concise and focus on test-specific insights." :context false ; No default context - :enable-tools [:read-file :glob-files :grep] - :disable-tools [:bash :clojure-inspect-project]} + :enable-tools [:read_file :glob_files :grep] + :disable-tools [:bash :clojure_inspect_project]} {:id :doc-reader :name "doc_reader" @@ -39,7 +39,7 @@ :system-message "You are a documentation specialist. Read and summarize documentation clearly and concisely. Focus on key concepts and practical usage." ;; model not specified - will use default :context ["README.md" "doc/"] ; Documentation-focused context - :enable-tools [:read-file :glob-files] + :enable-tools [:read_file :glob_files] :disable-tools [:grep :bash]} {:id :code-writer @@ -48,8 +48,8 @@ :system-message "You are a code writing assistant. You can create new files, edit existing ones, and refactor code. Always test code in the REPL before writing to files." :context true ;; Can evaluate code and write files - :enable-tools [:clojure-eval :file-write :file-edit :clojure-edit - :clojure-edit-replace-sexp :read-file :grep :glob-files] + :enable-tools [:clojure_eval :file_write :file_edit :clojure_edit + :clojure_edit_replace_sexp :read_file :grep :glob_files] :disable-tools nil} {:id :full-access-agent @@ -59,7 +59,7 @@ :context true ;; Special keyword :all enables all tools :enable-tools [:all] ; Gives access to every available tool - :disable-tools [:dispatch-agent]}] ; But still can disable specific ones + :disable-tools [:dispatch_agent]}] ; But still can disable specific ones ;; Optional: Define custom models for agents to use :models {:anthropic/claude-3-5-sonnet-20241022 diff --git a/src/clojure_mcp/agent/langchain/model.clj b/src/clojure_mcp/agent/langchain/model.clj index e9960c0a..b0a7e687 100644 --- a/src/clojure_mcp/agent/langchain/model.clj +++ b/src/clojure_mcp/agent/langchain/model.clj @@ -1,7 +1,7 @@ (ns clojure-mcp.agent.langchain.model (:require [clojure.string :as string] - [clojure-mcp.agent.langchain.model-spec :as spec] + [clojure-mcp.config.schema :as schema] [clojure-mcp.config :as config] [clojure.tools.logging :as log]) (:import @@ -403,8 +403,8 @@ (as-> cfg (ensure-api-key cfg (:provider cfg))))] ;; Validate if requested (when validate? - (spec/validate-model-key model-key) - (spec/validate-config-for-provider config)) + (schema/validate-model-key model-key) + (schema/validate-config-for-provider config)) (create-builder config)))) (defn create-builder-from-config @@ -427,7 +427,7 @@ (ensure-api-key final-provider))] ;; Validate if requested (when validate? - (spec/validate-config-for-provider final-config)) + (schema/validate-config-for-provider final-config)) (create-builder final-config)))) (defn build-model @@ -511,8 +511,8 @@ final-config (ensure-api-key config-with-provider provider)] ;; Validate if requested (when validate? - (spec/validate-model-key model-key) - (spec/validate-config-for-provider final-config)) + (schema/validate-model-key model-key) + (schema/validate-config-for-provider final-config)) ;; Create builder (create-builder final-config)))) diff --git a/src/clojure_mcp/agent/langchain/model_spec.clj b/src/clojure_mcp/agent/langchain/model_spec.clj deleted file mode 100644 index f4bc78cf..00000000 --- a/src/clojure_mcp/agent/langchain/model_spec.clj +++ /dev/null @@ -1,287 +0,0 @@ -(ns clojure-mcp.agent.langchain.model-spec - "Specifications for model configuration validation" - (:require - [clojure.spec.alpha :as s])) - -;; ============================================================================ -;; Common parameter specs -;; ============================================================================ - -(s/def ::api-key string?) -(s/def ::base-url string?) -(s/def ::model-name (s/or :string string? - :enum-value #(instance? Enum %))) - -;; Numeric parameters with reasonable bounds -(s/def ::temperature (s/and number? #(<= 0 % 2))) -(s/def ::top-p (s/and number? #(<= 0 % 1))) -(s/def ::top-k (s/and pos-int? #(<= % 1000))) -(s/def ::max-tokens (s/and pos-int? #(<= % 100000))) -(s/def ::seed int?) -(s/def ::frequency-penalty (s/and number? #(<= -2 % 2))) -(s/def ::presence-penalty (s/and number? #(<= -2 % 2))) -(s/def ::max-retries (s/and nat-int? #(<= % 10))) -(s/def ::timeout (s/and pos-int? #(<= % 600000))) ; Max 10 minutes - -;; Boolean flags -(s/def ::log-requests boolean?) -(s/def ::log-responses boolean?) - -;; Collections -(s/def ::stop-sequences (s/coll-of string? :kind sequential?)) - -;; ============================================================================ -;; Thinking/Reasoning configuration -;; ============================================================================ - -(s/def ::effort #{:low :medium :high}) -(s/def ::enabled boolean?) -(s/def ::return boolean?) -(s/def ::send boolean?) -(s/def ::budget-tokens (s/and pos-int? #(<= % 100000))) - -(s/def ::thinking - (s/keys :opt-un [::effort ::enabled ::return ::send ::budget-tokens])) - -;; ============================================================================ -;; Provider-specific configurations -;; ============================================================================ - -;; Anthropic-specific -(s/def ::version string?) -(s/def ::beta (s/nilable string?)) -(s/def ::cache-system-messages boolean?) -(s/def ::cache-tools boolean?) - -(s/def ::anthropic - (s/keys :opt-un [::version ::beta ::cache-system-messages ::cache-tools])) - -;; Google-specific -(s/def ::allow-code-execution boolean?) -(s/def ::include-code-execution-output boolean?) -(s/def ::response-logprobs boolean?) -(s/def ::enable-enhanced-civic-answers boolean?) -(s/def ::logprobs (s/and nat-int? #(<= % 10))) -(s/def ::safety-settings map?) ; Could be more specific - -(s/def ::google - (s/keys :opt-un [::allow-code-execution - ::include-code-execution-output - ::response-logprobs - ::enable-enhanced-civic-answers - ::logprobs - ::safety-settings])) - -;; OpenAI-specific -(s/def ::organization-id string?) -(s/def ::project-id string?) -(s/def ::max-completion-tokens (s/and pos-int? #(<= % 100000))) -(s/def ::logit-bias (s/map-of string? int?)) -(s/def ::strict-json-schema boolean?) -(s/def ::user string?) -(s/def ::strict-tools boolean?) -(s/def ::parallel-tool-calls boolean?) -(s/def ::store boolean?) -(s/def ::metadata (s/map-of string? string?)) -(s/def ::service-tier string?) - -(s/def ::openai - (s/keys :opt-un [::organization-id - ::project-id - ::max-completion-tokens - ::logit-bias - ::strict-json-schema - ::user - ::strict-tools - ::parallel-tool-calls - ::store - ::metadata - ::service-tier])) - -;; ============================================================================ -;; Response format configuration -;; ============================================================================ - -(s/def ::type #{:json :text}) -(s/def ::schema map?) ; JSON schema - -(s/def ::response-format - (s/keys :req-un [::type] - :opt-un [::schema])) - -;; ============================================================================ -;; Main configuration specs -;; ============================================================================ - -(s/def ::model-config - (s/keys :opt-un [;; Common parameters - ::api-key - ::base-url - ::model-name - ::provider - ::temperature - ::top-p - ::top-k - ::max-tokens - ::seed - ::frequency-penalty - ::presence-penalty - ::max-retries - ::timeout - ::log-requests - ::log-responses - ::stop-sequences - - ;; Complex configurations - ::thinking - ::response-format - - ;; Provider-specific - ::anthropic - ::google - ::openai])) - -;; ============================================================================ -;; Provider-specific model config specs -;; ============================================================================ - -(s/def ::anthropic-config - (s/merge ::model-config - (s/keys :opt-un [::top-k]))) ; top-k is Anthropic & Google only - -(s/def ::google-config - (s/merge ::model-config - (s/keys :opt-un [::top-k ::seed ::frequency-penalty ::presence-penalty]))) - -(s/def ::openai-config - (s/merge ::model-config - (s/keys :opt-un [::seed ::frequency-penalty ::presence-penalty]))) - -;; ============================================================================ -;; Validation functions -;; ============================================================================ - -(defn validate-config - "Validates a model configuration map against the spec. - Returns the config if valid, throws ex-info with explain-data if not." - [config] - (if (s/valid? ::model-config config) - config - (throw (ex-info "Invalid model configuration" - {:explain-data (s/explain-data ::model-config config) - :config config})))) - -(defn validate-config-for-provider - "Validates a model configuration for its specified provider. - Provider is extracted from the config's :provider key." - [config] - (let [provider (:provider config) - spec (case provider - :anthropic ::anthropic-config - :google ::google-config - :openai ::openai-config - ::model-config)] - (if (s/valid? spec config) - config - (throw (ex-info (str "Invalid configuration for provider " provider) - {:provider provider - :explain-data (s/explain-data spec config) - :config config}))))) - -(defn explain-config - "Returns a human-readable explanation of why a config is invalid. - Returns nil if the config is valid." - [config] - (when-not (s/valid? ::model-config config) - (s/explain-str ::model-config config))) - -(defn explain-config-for-provider - "Returns a human-readable explanation for provider-specific config validation. - Provider is extracted from the config's :provider key." - [config] - (let [provider (:provider config) - spec (case provider - :anthropic ::anthropic-config - :google ::google-config - :openai ::openai-config - ::model-config)] - (when-not (s/valid? spec config) - (s/explain-str spec config)))) - -;; ============================================================================ -;; Conforming and coercion -;; ============================================================================ - -(defn conform-config - "Attempts to conform a config to the spec, coercing values where appropriate." - [config] - (let [conformed (s/conform ::model-config config)] - (if (= conformed ::s/invalid) - (throw (ex-info "Cannot conform configuration" - {:explain-data (s/explain-data ::model-config config) - :config config})) - conformed))) - -(defn coerce-numeric-params - "Coerces string numeric parameters to appropriate numeric types." - [config] - (cond-> config - (string? (:temperature config)) - (update :temperature #(Double/parseDouble %)) - - (string? (:top-p config)) - (update :top-p #(Double/parseDouble %)) - - (string? (:top-k config)) - (update :top-k #(Integer/parseInt %)) - - (string? (:max-tokens config)) - (update :max-tokens #(Integer/parseInt %)) - - (string? (:timeout config)) - (update :timeout #(Integer/parseInt %)) - - (string? (:max-retries config)) - (update :max-retries #(Integer/parseInt %)) - - (string? (:seed config)) - (update :seed #(Integer/parseInt %)) - - (string? (:frequency-penalty config)) - (update :frequency-penalty #(Double/parseDouble %)) - - (string? (:presence-penalty config)) - (update :presence-penalty #(Double/parseDouble %)))) - -;; ============================================================================ -;; Model key validation -;; ============================================================================ - -(def valid-providers #{:anthropic :google :openai}) - -(s/def ::provider valid-providers) -(s/def ::model-key (s/and keyword? - namespace)) ; Just require it has a namespace - -(defn validate-model-key - "Validates that a model key has a namespace." - [model-key] - (if (s/valid? ::model-key model-key) - model-key - (throw (ex-info "Invalid model key - must have a namespace" - {:model-key model-key - :explain (s/explain-str ::model-key model-key)})))) - -;; ============================================================================ -;; Generator functions for testing -;; ============================================================================ - -(defn gen-valid-config - "Generates a valid random configuration for testing. - Requires clojure.spec.gen.alpha to be available." - [] - (try - (require '[clojure.spec.gen.alpha :as gen]) - ((resolve 'gen/generate) (s/gen ::model-config)) - (catch Exception _ - (throw (ex-info "spec.gen not available - add test.check to dependencies" {}))))) \ No newline at end of file diff --git a/src/clojure_mcp/config.clj b/src/clojure_mcp/config.clj index bb3db94e..06e84e4f 100644 --- a/src/clojure_mcp/config.clj +++ b/src/clojure_mcp/config.clj @@ -3,6 +3,7 @@ [clojure.java.io :as io] [clojure.string :as str] [clojure-mcp.dialects :as dialects] + [clojure-mcp.config.schema :as schema] [clojure.edn :as edn] [clojure.tools.logging :as log])) @@ -56,6 +57,34 @@ [user-config project-config] (deep-merge user-config project-config)) +(defn validate-configs + "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 + - :config - The invalid config + - :file-path - Path to the file with errors (canonical path)" + [config-files] + (doseq [{:keys [config file-path]} config-files] + (when (seq config) + (when-let [errors (schema/explain-config config)] + (let [canonical-path (try + (.getCanonicalPath (io/file file-path)) + (catch Exception _ file-path))] ; fallback to original if error + (throw (ex-info (str "Configuration validation failed: " canonical-path) + {:type ::schema-error + :errors errors + :model schema/Config + :config config + :file-path canonical-path}))))))) + (defn process-config [{:keys [allowed-directories emacs-notify write-file-guard cljfmt bash-over-nrepl nrepl-env-type] :as config} user-dir] (let [ud (io/file user-dir)] (assert (and (.isAbsolute ud) (.isDirectory ud))) @@ -86,8 +115,7 @@ (defn load-config "Loads configuration from both user home (~/.clojure-mcp/config.edn) and project directory. User home config provides defaults, project config provides overrides. - Project config location is either cli-config-file or .clojure-mcp/config.edn in user-dir. - Reads files directly from the filesystem." + Validates both configs before merging." [cli-config-file user-dir] ;; Load user home config first (provides defaults) (let [home-config (load-home-config) @@ -99,6 +127,21 @@ (io/file user-dir ".clojure-mcp" "config.edn")) project-config (load-config-file (.getPath project-config-file)) + ;; Validate configs BEFORE merging + ;; This ensures we know which file has the error + ;; Use canonical paths for consistent error reporting + _ (validate-configs + (cond-> [] + ;; Only validate home config if it exists and has content + (seq home-config) + (conj {:config home-config + :file-path (.getCanonicalPath home-config-path)}) + + ;; Only validate project config if it exists and has content + (seq project-config) + (conj {:config project-config + :file-path (.getCanonicalPath project-config-file)}))) + ;; Merge configs (project overrides home) merged-config (merge-configs home-config project-config) @@ -106,12 +149,13 @@ processed-config (process-config merged-config user-dir)] ;; Logging for debugging - (log/info "Home config file:" (.getPath home-config-path) "exists:" (.exists home-config-path)) - (log/info "Home config:" home-config) - (log/info "Project config file:" (.getPath project-config-file) "exists:" (.exists project-config-file)) - (log/info "Project config:" project-config) - (log/info "Merged config:" merged-config) - (log/info "Final processed config:" processed-config) + (log/debug "Home config file:" (.getCanonicalPath home-config-path) "exists:" (.exists home-config-path)) + (when (seq home-config) + (log/debug "Home config validated successfully")) + (log/debug "Project config file:" (.getCanonicalPath project-config-file) "exists:" (.exists project-config-file)) + (when (seq project-config) + (log/debug "Project config validated successfully")) + (log/debug "Final processed config:" processed-config) processed-config)) diff --git a/src/clojure_mcp/config/schema.clj b/src/clojure_mcp/config/schema.clj new file mode 100644 index 00000000..e65a51d9 --- /dev/null +++ b/src/clojure_mcp/config/schema.clj @@ -0,0 +1,366 @@ +(ns clojure-mcp.config.schema + "Malli schemas for configuration validation. + + Provides comprehensive validation for the .clojure-mcp/config.edn file + with human-readable error messages and spell-checking for typos." + (:require + [clojure.string :as string] + [malli.core :as m] + [malli.error :as me])) + +(def ^:dynamic *validate-env-vars* + "When true, validates that environment variables actually exist and aren't blank. + Can be bound to false for testing." + true) + +;; ============================================================================== +;; Basic Type Schemas +;; ============================================================================== + +(def NonBlankString + [:and + [:string {:min 1}] + [:fn {:error/message "String can't be blank"} + #(not (string/blank? %))]]) + +(def Path + "Schema for file system paths" + NonBlankString) + +(def EnvRef + "Schema for environment variable references like [:env \"VAR_NAME\"]" + [:and + [:tuple {:description "Environment variable reference"} + [:= :env] + NonBlankString] + [:fn {:error/message "Environment variable can't be empty"} + (fn [[_ env-var]] + (or (not *validate-env-vars*) + (let [val (System/getenv env-var)] + (and val + (not (string/blank? val))))))]]) + +(def IntOrDouble [:or :int :double]) + +(def ModelNameOrEnum + "Schema for model names - can be a string or an enum object" + [:or + NonBlankString + EnvRef + [:fn {:error/message "Must be a valid model name object"} + #(instance? Enum %)]]) + +;; ============================================================================== +;; Model Configuration Schemas +;; ============================================================================== + +(def ThinkingConfig + "Schema for thinking/reasoning configuration in models" + [:map {:closed true} + [:enabled {:optional true} :boolean] + [:return {:optional true} :boolean] + [:send {:optional true} :boolean] + [:effort {:optional true} [:enum :low :medium :high]] + [:budget-tokens {:optional true} [:int {:min 1 :max 100000}]]]) + +(def ModelConfig + "Schema for individual model configurations" + [:map {:closed true} + ;; Provider identification + [:provider {:optional true} [:enum :openai :anthropic :google]] + + ;; Core parameters + [:model-name ModelNameOrEnum] + [:api-key {:optional true} [:or NonBlankString EnvRef]] + [:base-url {:optional true} [:or NonBlankString EnvRef]] + + ;; Common generation parameters + [:temperature {:optional true} [:and IntOrDouble [:>= 0] [:<= 2]]] + [:max-tokens {:optional true} [:int {:min 1 :max 100000}]] + [:top-p {:optional true} [:and IntOrDouble [:>= 0] [:<= 1]]] + [:top-k {:optional true} [:int {:min 1 :max 1000}]] + [:seed {:optional true} :int] + [:frequency-penalty {:optional true} [:and IntOrDouble [:>= -2] [:<= 2]]] + [:presence-penalty {:optional true} [:and IntOrDouble [:>= -2] [:<= 2]]] + [:stop-sequences {:optional true} [:sequential NonBlankString]] + + ;; Connection and logging parameters + [:max-retries {:optional true} [:int {:min 0 :max 10}]] + [:timeout {:optional true} [:int {:min 1000 :max 600000}]] ; 1 sec to 10 min + [:log-requests {:optional true} :boolean] + [:log-responses {:optional true} :boolean] + + ;; Thinking/reasoning configuration + [:thinking {:optional true} ThinkingConfig] + + ;; Response format configuration + [:response-format {:optional true} + [:map {:closed true} + [:type [:enum :json :text]] + [:schema {:optional true} :map]]] + + ;; Provider-specific: Anthropic + [:anthropic {:optional true} + [:map {:closed true} + [:version {:optional true} NonBlankString] + [:beta {:optional true} [:maybe NonBlankString]] + [:cache-system-messages {:optional true} :boolean] + [:cache-tools {:optional true} :boolean]]] + + ;; Provider-specific: Google Gemini + [:google {:optional true} + [:map {:closed true} + [:allow-code-execution {:optional true} :boolean] + [:include-code-execution-output {:optional true} :boolean] + [:response-logprobs {:optional true} :boolean] + [:enable-enhanced-civic-answers {:optional true} :boolean] + [:logprobs {:optional true} [:int {:min 0 :max 10}]] + [:safety-settings {:optional true} :map]]] + + ;; Provider-specific: OpenAI + [:openai {:optional true} + [:map {:closed true} + [:organization-id {:optional true} NonBlankString] + [:project-id {:optional true} NonBlankString] + [:max-completion-tokens {:optional true} [:int {:min 1 :max 100000}]] + [:logit-bias {:optional true} [:map-of NonBlankString [:int {:min -100 :max 100}]]] + [:strict-json-schema {:optional true} :boolean] + [:user {:optional true} NonBlankString] + [:strict-tools {:optional true} :boolean] + [:parallel-tool-calls {:optional true} :boolean] + [:store {:optional true} :boolean] + [:metadata {:optional true} [:map-of NonBlankString NonBlankString]] + [:service-tier {:optional true} NonBlankString]]]]) + +;; ============================================================================== +;; Agent Configuration Schemas +;; ============================================================================== + +(def AgentConfig + "Schema for agent configurations" + [:map {:closed true} + + ;; Required fields + [:id {:description "Unique keyword identifier for the agent"} + :keyword] + + [:name {:description "Tool name that appears in the MCP interface"} + NonBlankString] + + [:description {:description "Human-readable description of the agent's purpose"} + NonBlankString] + + ;; System configuration + [:system-message {:optional true + :description "System prompt that defines the agent's behavior and personality"} + NonBlankString] + + ;; Model configuration + [:model {:optional true + :description "AI model to use (keyword reference to :models config, e.g., :openai/gpt-4o)"} + :keyword] + + ;; Context configuration + [:context {:optional true + :description "Context to provide: true (default), false (none), or file paths list"} + [:or :boolean [:sequential Path]]] + + ;; Tool configuration + [:enable-tools {:optional true + :description "Tools the agent can access: :all, specific list, or nil (no tools)"} + [:maybe [:or [:= :all] [:sequential :keyword]]]] + + [:disable-tools {:optional true + :description "Tools to exclude even if enabled (applied after enable-tools)"} + [:maybe [:sequential :keyword]]] + + ;; Memory configuration + [:memory-size {:optional true + :description "Memory behavior: false/nil/<10 = stateless, >=10 = persistent window"} + [:maybe [:or [:= false] [:int {:min 0}]]]] + + ;; File tracking configuration + [:track-file-changes {:optional true + :description "Whether to track and display file diffs (default: true)"} + :boolean]]) + +;; ============================================================================== +;; Resource Configuration Schemas +;; ============================================================================== + +(def ResourceEntry + "Schema for resource entries" + [:map {:closed true} + + [:description {:description "Clear description of resource contents for LLM understanding"} + NonBlankString] + + [:file-path {:description "Path to file (relative to project root or absolute)"} + Path] + + [:url {:optional true + :description "Custom URL for resource (defaults to custom://kebab-case-name)"} + [:maybe NonBlankString]] + + [:mime-type {:optional true + :description "MIME type (auto-detected from file extension if not specified)"} + [:maybe NonBlankString]]]) + +;; ============================================================================== +;; Prompt Configuration Schemas +;; ============================================================================== + +(def PromptArg + "Schema for prompt arguments" + [:map {:closed true} + + [:name {:description "Parameter name used in Mustache template (e.g., {{name}})"} + NonBlankString] + + [:description {:description "Description of what this parameter is for"} + NonBlankString] + + [:required? {:optional true + :description "Whether this argument is required (defaults to false)"} + :boolean]]) + +(def PromptEntry + "Schema for prompt entries" + [:and + [:map {:closed true} + [:description {:description "Clear description of what the prompt does (shown to LLM when listing prompts)"} + NonBlankString] + + [:content {:optional true + :description "Inline Mustache template content (use this OR :file-path)"} + NonBlankString] + + [:file-path {:optional true + :description "Path to Mustache template file (use this OR :content)"} + Path] ;; Alternative to :content + + [:args {:optional true + :description "Vector of argument definitions for the Mustache template"} + [:sequential PromptArg]]] + [:fn {:error/message "Provide exactly one of :content or :file-path"} + (fn [{:keys [content file-path]}] + (and (not (and (some? content) (some? file-path))) + (or (some? content) (some? file-path))))]]) + +;; ============================================================================== +;; Main Configuration Schema +;; ============================================================================== + +(def Config + "Complete configuration schema for .clojure-mcp/config.edn" + [:map {:closed true} ;; Closed to enable spell-checking for typos + + ;; Core configuration + [:allowed-directories {:optional true} [:sequential Path]] + [:emacs-notify {:optional true} :boolean] + [:write-file-guard {:optional true} [:enum :full-read :partial-read false]] + [:cljfmt {:optional true} :boolean] + [:bash-over-nrepl {:optional true} :boolean] + [:nrepl-env-type {:optional true} [:enum :clj :bb :basilisp :scittle]] + + ;; Scratch pad configuration + [:scratch-pad-load {:optional true} :boolean] + [:scratch-pad-file {:optional true} Path] + + ;; Model and tool configuration + [:models {:optional true} [:map-of :keyword ModelConfig]] + [:tools-config {:optional true} [:map-of :keyword :map]] + [:agents {:optional true} [:sequential AgentConfig]] + + ;; MCP client hints + [:mcp-client {:optional true} [:maybe NonBlankString]] + [:dispatch-agent-context {:optional true} + [:or :boolean [:sequential Path]]] + + ;; Component filtering + [:enable-tools {:optional true} + [:maybe [:sequential [:or :keyword NonBlankString]]]] + [:disable-tools {:optional true} + [:maybe [:sequential [:or :keyword NonBlankString]]]] + [:enable-prompts {:optional true} + [:maybe [:sequential NonBlankString]]] + [:disable-prompts {:optional true} + [:maybe [:sequential NonBlankString]]] + [:enable-resources {:optional true} + [:maybe [:sequential NonBlankString]]] + [:disable-resources {:optional true} + [:maybe [:sequential NonBlankString]]] + + ;; Custom resources and prompts + [:resources {:optional true} [:map-of NonBlankString ResourceEntry]] + [:prompts {:optional true} [:map-of NonBlankString PromptEntry]]]) + +;; ============================================================================== +;; Validation Functions +;; ============================================================================== + +(defn explain-config + "Returns human-readable explanation of validation errors, or nil if valid. + Automatically detects typos in configuration keys." + [config] + (when-not (m/validate Config config) + (-> (m/explain Config config) + (me/with-spell-checking) + (me/humanize)))) + +(defn valid? + "Returns true if the configuration is valid." + [config] + (m/validate Config config)) + +;; ============================================================================== +;; Schema Introspection (useful for documentation) +;; ============================================================================== + +(defn schema-properties + "Returns the properties/metadata of the Config schema. + Useful for generating documentation." + [] + (m/properties Config)) + +(defn schema-keys + "Returns all the top-level keys defined in the Config schema." + [] + (-> Config + (m/entries) + (->> (map first)))) + +;; ============================================================================== +;; Model Validation Functions +;; ============================================================================== + +(defn validate-model-key + "Validates that a model key has a namespace. + Returns the key if valid, throws an exception if invalid." + [model-key] + (when-not (and (keyword? model-key) + (namespace model-key)) + (throw (ex-info "Invalid model key" + {:model-key model-key + :type (type model-key) + :namespace (namespace model-key)}))) + model-key) + +(defn validate-model-config + "Validates a model configuration against the ModelConfig schema. + Returns the config if valid, throws an exception if invalid." + [config] + (if (m/validate ModelConfig config) + config + (throw (ex-info "Invalid configuration" + {:config config + :errors (-> (m/explain ModelConfig config) + (me/with-spell-checking) + (me/humanize))})))) + +(defn validate-config-for-provider + "Validates a model configuration. Provider is ignored since validation + is the same for all providers in the current schema. + Exists for backward compatibility with model_spec.clj." + [config] + (validate-model-config config)) diff --git a/src/clojure_mcp/core.clj b/src/clojure_mcp/core.clj index 3ee8da0b..31cc23f4 100644 --- a/src/clojure_mcp/core.clj +++ b/src/clojure_mcp/core.clj @@ -234,8 +234,25 @@ (log/error e "Failed to initialize MCP server") (throw e)))) -;; Setting up a server -;; this could be in a new namespace +(defn load-config-handling-validation-errors [config-file user-dir] + (try + (config/load-config config-file user-dir) + (catch Exception e + (if (= ::config/schema-error (-> e ex-data :type)) + (let [{:keys [errors file-path]} (ex-data e)] + (binding [*out* *err*] + (println (str "\nāŒ Configuration validation failed!\n")) + (when file-path + (println (str "File: " file-path "\n"))) + (println "Errors found:") + (doseq [[k v] errors] + (let [msg (if (sequential? v) (first v) v)] + (println (str " šŸ‘‰ " k " - " msg)))) + (println "\nPlease fix these issues and try again.") + (println "See CONFIG.md for documentation.\n")) + (throw e)) + ;; Other error - re-throw + (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)] @@ -243,7 +260,8 @@ (log/warn "Could not determine working directory") (throw (ex-info "No project directory!!" {}))) (log/info "Working directory set to:" user-dir) - (let [config (config/load-config config-file user-dir) + + (let [config (load-config-handling-validation-errors config-file user-dir) final-env-type (or cli-env-type (if (contains? config :nrepl-env-type) (:nrepl-env-type config) diff --git a/test/clojure_mcp/agent/langchain/model_spec_test.clj b/test/clojure_mcp/agent/langchain/model_spec_test.clj deleted file mode 100644 index bee0fd7e..00000000 --- a/test/clojure_mcp/agent/langchain/model_spec_test.clj +++ /dev/null @@ -1,142 +0,0 @@ -(ns clojure-mcp.agent.langchain.model-spec-test - (:require - [clojure.test :refer [deftest testing is]] - [clojure.spec.alpha :as s] - [clojure-mcp.agent.langchain.model-spec :as spec])) - -(deftest test-temperature-spec - (testing "Temperature validation" - (is (s/valid? ::spec/temperature 0.0)) - (is (s/valid? ::spec/temperature 1.0)) - (is (s/valid? ::spec/temperature 2.0)) - (is (not (s/valid? ::spec/temperature -0.1))) - (is (not (s/valid? ::spec/temperature 2.1))) - (is (not (s/valid? ::spec/temperature "1.0"))))) - -(deftest test-top-p-spec - (testing "Top-p validation" - (is (s/valid? ::spec/top-p 0.0)) - (is (s/valid? ::spec/top-p 0.5)) - (is (s/valid? ::spec/top-p 1.0)) - (is (not (s/valid? ::spec/top-p -0.1))) - (is (not (s/valid? ::spec/top-p 1.1))))) - -(deftest test-thinking-spec - (testing "Thinking configuration validation" - (is (s/valid? ::spec/thinking {:effort :low})) - (is (s/valid? ::spec/thinking {:effort :medium})) - (is (s/valid? ::spec/thinking {:effort :high})) - (is (not (s/valid? ::spec/thinking {:effort :extreme}))) - (is (s/valid? ::spec/thinking {:effort :medium - :enabled true - :return true - :send false - :budget-tokens 4096})) - (is (not (s/valid? ::spec/thinking {:budget-tokens -100}))))) - -(deftest test-model-config-spec - (testing "Complete model config validation" - (is (s/valid? ::spec/model-config {})) ; Empty config is valid (all optional) - (is (s/valid? ::spec/model-config {:temperature 0.7 - :max-tokens 4096 - :thinking {:effort :medium}})) - (is (s/valid? ::spec/model-config {:api-key "test-key" - :temperature 1.5 - :top-p 0.9 - :max-tokens 10000 - :stop-sequences ["END" "STOP"]})))) - -(deftest test-provider-specific-specs - (testing "Anthropic-specific config" - (is (s/valid? ::spec/anthropic-config {:top-k 50})) - (is (s/valid? ::spec/anthropic-config {:anthropic {:cache-system-messages true - :cache-tools false}}))) - - (testing "Google-specific config" - (is (s/valid? ::spec/google-config {:seed 42 - :frequency-penalty 0.5 - :presence-penalty -0.5})) - (is (s/valid? ::spec/google-config {:google {:allow-code-execution true - :logprobs 5}}))) - - (testing "OpenAI-specific config" - (is (s/valid? ::spec/openai-config {:seed 123 - :frequency-penalty 1.0})) - (is (s/valid? ::spec/openai-config {:openai {:organization-id "org-123" - :strict-tools true}})))) - -(deftest test-validation-functions - (testing "validate-config function" - (let [valid-config {:temperature 1.0} - invalid-config {:temperature 3.0}] - (is (= valid-config (spec/validate-config valid-config))) - (is (thrown-with-msg? Exception #"Invalid model configuration" - (spec/validate-config invalid-config))))) - - (testing "validate-config-for-provider function" - (is (spec/validate-config-for-provider {:provider :openai :seed 42})) - (is (spec/validate-config-for-provider {:provider :anthropic :top-k 100})) - (is (thrown-with-msg? Exception #"Invalid configuration for provider" - (spec/validate-config-for-provider {:provider :openai :temperature 3.0}))))) - -(deftest test-explain-config - (testing "Explanation for invalid configs" - (let [invalid {:temperature 3.0}] - (is (string? (spec/explain-config invalid))) - (is (re-find #"failed.*temperature" (spec/explain-config invalid)))) - - (let [valid {:temperature 1.0}] - (is (nil? (spec/explain-config valid)))))) - -(deftest test-coerce-numeric-params - (testing "String to number coercion" - (let [string-config {:temperature "0.7" - :top-p "0.9" - :top-k "50" - :max-tokens "4096" - :seed "42" - :timeout "30000" - :max-retries "3" - :frequency-penalty "0.5" - :presence-penalty "-0.5"} - coerced (spec/coerce-numeric-params string-config)] - (is (= 0.7 (:temperature coerced))) - (is (instance? Double (:temperature coerced))) - (is (= 50 (:top-k coerced))) - (is (instance? Integer (:top-k coerced))) - (is (= 0.5 (:frequency-penalty coerced))) - (is (= -0.5 (:presence-penalty coerced)))))) - -(deftest test-model-key-validation - (testing "Valid model keys - must have namespace" - (is (s/valid? ::spec/model-key :openai/gpt-4)) - (is (s/valid? ::spec/model-key :google/gemini-pro)) - (is (s/valid? ::spec/model-key :anthropic/claude-3)) - (is (s/valid? ::spec/model-key :company/custom-model)) - (is (s/valid? ::spec/model-key :my-org/llm))) - - (testing "Invalid model keys - no namespace" - (is (not (s/valid? ::spec/model-key :model-without-namespace))) - (is (not (s/valid? ::spec/model-key "not-a-keyword")))) - - (testing "validate-model-key function" - (is (= :openai/gpt-4 (spec/validate-model-key :openai/gpt-4))) - (is (= :company/model (spec/validate-model-key :company/model))) - (is (thrown-with-msg? Exception #"Invalid model key" - (spec/validate-model-key :no-namespace))))) - -(deftest test-provider-in-config-spec - (testing "Provider is valid in model config" - (is (s/valid? ::spec/model-config {:provider :openai - :model-name "gpt-4o" - :temperature 0.7})) - (is (s/valid? ::spec/model-config {:provider :google - :model-name "gemini-pro"})) - (is (s/valid? ::spec/model-config {:provider :anthropic - :model-name "claude-3"}))) - - (testing "Invalid provider in config" - (is (not (s/valid? ::spec/model-config {:provider :unknown-provider - :model-name "test"}))) - (is (not (s/valid? ::spec/model-config {:provider "openai" - :model-name "test"}))))) \ No newline at end of file diff --git a/test/clojure_mcp/config/schema_test.clj b/test/clojure_mcp/config/schema_test.clj new file mode 100644 index 00000000..a65879e6 --- /dev/null +++ b/test/clojure_mcp/config/schema_test.clj @@ -0,0 +1,221 @@ +(ns clojure-mcp.config.schema-test + "Tests for configuration schema validation" + (:require [clojure.test :refer [deftest testing is]] + [clojure.edn :as edn] + [clojure.java.io :as io] + [clojure.string :as str] + [clojure-mcp.config.schema :as schema] + [malli.core :as m])) + +;; ============================================================================== +;; Test Data +;; ============================================================================== + +(def valid-minimal-config + "Minimal valid configuration" + {}) + +(def valid-full-config + "Complete valid configuration with all fields" + {:allowed-directories ["." "src" "test"] + :emacs-notify false + :write-file-guard :partial-read + :cljfmt true + :bash-over-nrepl true + :nrepl-env-type :clj + :scratch-pad-load false + :scratch-pad-file "scratch.edn" + :models {:openai/gpt4 {:model-name "gpt-4" + :temperature 0.7 + :max-tokens 2048 + :api-key [:env "OPENAI_API_KEY"]} + :anthropic/claude {:provider :anthropic + :model-name "claude-3-5-sonnet" + :thinking {:enabled true + :budget-tokens 4096}}} + :tools-config {:dispatch_agent {:model :openai/gpt4}} + :agents [{:id :my-agent + :name "My Agent" + :description "A custom agent" + :model :openai/gpt4 + :enable-tools [:read-file :grep]}] + :mcp-client "claude-desktop" + :dispatch-agent-context true + :enable-tools [:clojure-eval :read-file] + :disable-tools ["bash"] + :enable-prompts ["clojure_repl_system_prompt"] + :disable-prompts ["scratch-pad-save-as"] + :enable-resources ["README.md"] + :disable-resources ["CLAUDE.md"] + :resources {"my-doc" {:description "My documentation" + :file-path "doc/my-doc.md" + :url "custom://my-doc" + :mime-type "text/markdown"}} + :prompts {"my-prompt" {:description "My custom prompt" + :content "Hello {{name}}" + :args [{:name "name" + :description "User name" + :required? true}]}}}) + +;; ============================================================================== +;; Valid Configuration Tests +;; ============================================================================== + +(deftest valid-configurations-test + (testing "Minimal config should be valid" + (is (schema/valid? valid-minimal-config))) + + (testing "Full config should be valid" + (binding [schema/*validate-env-vars* false] + (is (schema/valid? valid-full-config)))) + + (testing "Config with only core settings" + (is (schema/valid? {:allowed-directories ["."] + :cljfmt true + :bash-over-nrepl false}))) + + (testing "Config with dispatch-agent-context as file list" + (is (schema/valid? {:dispatch-agent-context ["doc/overview.md" "README.md"]}))) + + (testing "Config with environment variable references" + ;; Disable env var validation for testing + (binding [schema/*validate-env-vars* false] + (is (schema/valid? {:models {:openai/test {:model-name [:env "MODEL_NAME"] + :api-key [:env "API_KEY"] + :base-url [:env "BASE_URL"]}}})))) + + (testing "Config with all nrepl-env-type values" + (doseq [env-type [:clj :bb :basilisp :scittle]] + (is (schema/valid? {:nrepl-env-type env-type}))))) + +;; ============================================================================== +;; Invalid Configuration Tests +;; ============================================================================== + +(deftest invalid-write-file-guard-test + (testing "Invalid write-file-guard value" + (let [config {:write-file-guard :invalid-value} + errors (schema/explain-config config)] + (is (some? errors)) + (is (contains? errors :write-file-guard))))) + +(deftest invalid-model-config-test + (testing "Model without required model-name" + (let [config {:models {:openai/bad {}}} + errors (schema/explain-config config)] + (is (some? errors)))) + + (testing "Model with invalid temperature" + (let [config {:models {:openai/bad {:model-name "gpt-4" + :temperature 3.0}}} + errors (schema/explain-config config)] + (is (some? errors)))) + + (testing "Model with negative max-tokens" + (let [config {:models {:openai/bad {:model-name "gpt-4" + :max-tokens -100}}} + errors (schema/explain-config config)] + (is (some? errors))))) + +(deftest invalid-agent-config-test + (testing "Agent missing required fields" + (let [config {:agents [{:id :agent1}]} + errors (schema/explain-config config)] + (is (some? errors)))) + + (testing "Agent with non-keyword id" + (let [config {:agents [{:id "agent1" + :name "Agent" + :description "Desc"}]} + errors (schema/explain-config config)] + (is (some? errors))))) + +(deftest invalid-resource-config-test + (testing "Resource missing description" + (let [config {:resources {"my-res" {:file-path "file.md"}}} + errors (schema/explain-config config)] + (is (some? errors)) + (is (-> errors :resources (get "my-res") :description))))) + +(deftest invalid-prompt-config-test + (testing "Prompt missing description" + (let [config {:prompts {"my-prompt" {:content "Hello"}}} + errors (schema/explain-config config)] + (is (some? errors))))) + +;; ============================================================================== +;; Typo Detection Tests +;; ============================================================================== + +(deftest typo-detection-test + (testing "Detects typo in configuration key" + (let [config {:write-file-gaurd :full-read} + errors (schema/explain-config config)] + (is (some? errors)) + ;; The error should suggest the correct key + (is (re-find #"write-file-guard" (str errors))))) + + (testing "Detects typo in nested key" + (let [config {:models {:openai/test {:model-nam "gpt-4"}}} + errors (schema/explain-config config)] + (is (some? errors)) + (is (re-find #"model-name" (str errors)))))) + +;; ============================================================================== +;; Example Configuration File Tests +;; ============================================================================== + +(deftest example-config-files-test + (testing "Example configurations should be valid if they exist" + (let [example-dir (io/file "resources/configs") + example-files (when (.exists example-dir) + (filter #(str/ends-with? (.getName %) ".edn") + (.listFiles example-dir)))] + ;; Disable env var validation for testing example files + (binding [schema/*validate-env-vars* false] + (doseq [file example-files] + (testing (str "File: " (.getName file)) + (let [config (edn/read-string (slurp file))] + (when-let [errors (schema/explain-config config)] + (println "Validation errors for" (.getName file) ":") + (println errors)) + (is (schema/valid? config) + (str "Invalid config in " (.getName file)))))))))) + +;; ============================================================================== +;; Edge Cases +;; ============================================================================== + +(deftest edge-cases-test + (testing "Empty lists for filtering" + (is (schema/valid? {:enable-tools [] + :disable-tools [] + :enable-prompts [] + :disable-prompts []}))) + + (testing "Mixed keyword and string tool IDs" + (is (schema/valid? {:enable-tools [:clojure-eval "read-file" :bash]}))) + + (testing "Nil values for optional maybe fields" + (is (schema/valid? {:mcp-client nil}))) + + (testing "Complex nested thinking config" + (is (schema/valid? {:models {:anthropic/test + {:model-name "claude" + :thinking {:enabled true + :return true + :send false + :budget-tokens 8192}}}})))) + +;; ============================================================================== +;; Error Message Quality Tests +;; ============================================================================== + +(deftest error-message-quality-test + (testing "Error messages for enum values list options" + (let [config {:nrepl-env-type :invalid} + errors (schema/explain-config config)] + (is (some? errors)) + ;; Should list valid options + (is (or (re-find #":clj" (str errors)) + (re-find #"clj" (str errors)))))))