├── src └── modex │ └── mcp │ ├── reflection.clj │ ├── json_rpc.clj │ ├── protocols.clj │ ├── core.clj │ ├── client.clj │ ├── server.clj │ ├── tools.clj │ └── schema.clj ├── .gitignore ├── deps.edn ├── .github └── workflows │ └── tests.yml ├── src-build └── build.clj ├── test └── modex │ └── mcp │ ├── rpc_test.clj │ ├── tools_test.clj │ ├── server_test.clj │ └── client_test.clj └── README.md /src/modex/mcp/reflection.clj: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea/ 3 | .cpcache/ 4 | .clj-kondo/ -------------------------------------------------------------------------------- /src/modex/mcp/json_rpc.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.json-rpc 2 | (:require [modex.mcp.schema :as schema :refer [json-rpc-version]])) 3 | 4 | (defn result [id result] 5 | {:jsonrpc json-rpc-version 6 | :id id 7 | :result result}) 8 | 9 | (defn error [id error] 10 | {:jsonrpc json-rpc-version 11 | :id id 12 | :error error}) 13 | 14 | (defn method 15 | ([id method] 16 | {:jsonrpc json-rpc-version 17 | :id id 18 | :method method}) 19 | ([method] 20 | {:jsonrpc json-rpc-version 21 | :method method})) -------------------------------------------------------------------------------- /src/modex/mcp/protocols.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.protocols) 2 | 3 | (defprotocol AResource) 4 | 5 | (defprotocol APrompt) 6 | 7 | ; todo: move JSON specific stuff out to a JSON-RPCWireFormat thing. 8 | (defprotocol AServer 9 | (protocol-version [this]) 10 | (server-name [this]) 11 | (version [this]) 12 | 13 | (on-receive [this msg] "For testing receive.") 14 | (on-send [this msg] "For testing sent messages.") 15 | (enqueue-notification [_this msg] "For testing notifications. Will collapse into an async bus lands.") 16 | 17 | (send-notification [this notification] "Called after a has been sent.") 18 | 19 | (capabilities [this]) 20 | (initialize [this _init-params]) 21 | 22 | (list-tools [this]) 23 | (call-tool [this tool-name arg-map]) 24 | 25 | (list-resources [this]) 26 | (list-prompts [this])) -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "test"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0-alpha5"} 3 | 4 | ; Schema Validation during I/O 5 | metosin/malli {:mvn/version "0.17.0"} 6 | 7 | ; Interleaved logging 8 | com.taoensso/timbre {:mvn/version "6.7.0-alpha1"} 9 | 10 | ; nREPL for live development / debugging of tools 11 | nrepl/nrepl {:mvn/version "1.3.1"} 12 | 13 | ; For JSON encoding: 14 | metosin/jsonista {:mvn/version "0.3.13"}} 15 | :aliases {:dev {} 16 | :build {:extra-paths ["src-build"] 17 | :deps {io.github.clojure/tools.build {:git/tag "v0.9.6" :git/sha "8e78bcc"}} 18 | :ns-default build} 19 | :test {:extra-paths ["test"] 20 | :extra-deps {io.github.cognitect-labs/test-runner 21 | {:git/tag "v0.5.1" :git/sha "dfb30dd"}} 22 | :main-opts ["-m" "cognitect.test-runner"] 23 | :exec-fn cognitect.test-runner.api/test}}} -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Modex Unit Tests 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | clojure: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Cache Clojure Dependencies 15 | uses: actions/cache@v3 16 | with: 17 | path: | 18 | ~/.m2 19 | ~/.gitlibs 20 | key: cache-${{ hashFiles('**/deps.edn') }} 21 | restore-keys: clojure-deps- 22 | 23 | - name: Prepare java 24 | uses: actions/setup-java@v4 25 | with: 26 | distribution: 'temurin' 27 | java-version: '24' 28 | 29 | - name: Install clojure tools 30 | uses: DeLaGuardo/setup-clojure@13.2 31 | with: 32 | cli: 1.12.0-alpha5 # Clojure CLI based on tools.deps 33 | #cljstyle: 0.15.0 # cljstyle 34 | #clj-kondo: 2022.10.05 # Clj-kondo 35 | 36 | - name: Run Unit tests 37 | run: clojure -X:test 38 | 39 | #- name: "Lint with clj-kondo" 40 | # run: clj-kondo --lint deps.edn src resources test --config .clj-kondo/config-ci.edn 41 | 42 | #- name: "Check Clojure Style" 43 | # run: cljstyle check --report 44 | 45 | #- name: Package Clojure project 46 | # run: clojure -X:project/uberjar -------------------------------------------------------------------------------- /src-build/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | ;(defn get-git-tag [] (b/git-process {:git-args "describe --tags --exact-match" :dir "."})) 5 | 6 | ;(def re-version-format #"(\d+)\.(\d+)\.(\d+)") 7 | 8 | ;(comment 9 | ; (get-git-tag) 10 | ; (re-matches re-version-format "0.2.0") 11 | ; (re-matches re-version-format "0.x.0")) 12 | 13 | ;(defn valid-version? [version] (first (re-seq re-version-format version))) 14 | 15 | (def lib 'com.theronic/modex) 16 | (def version "0.3.0") ;(get-git-tag)) ;(format "0.0.%s" (b/git-count-revs nil))) ;(def version "0.1.0") once stable. 17 | ;(assert (valid-version? version) "Version expects a git tag in format major.minor.thingy") ; should probably be dates. 18 | (def class-dir "target/classes") 19 | (def basis (b/create-basis {:project "deps.edn"})) 20 | (def uberjar-filename (format "target/%s-%s.jar" (name lib) version)) 21 | 22 | (defn clean [_] 23 | (b/delete {:path "target"})) 24 | 25 | (defn jar [_] 26 | (b/write-pom {:class-dir class-dir 27 | :lib lib 28 | :version version 29 | :basis basis 30 | :src-dirs ["src"]}) 31 | (b/copy-dir {:src-dirs ["src" "resources"] 32 | :target-dir class-dir}) 33 | (b/jar {:class-dir class-dir 34 | :jar-file uberjar-filename})) 35 | 36 | (defn uber [_] 37 | (clean nil) 38 | (b/copy-dir {:src-dirs ["src" "resources"] 39 | :target-dir class-dir}) 40 | (b/compile-clj {:basis basis 41 | :src-dirs ["src"] 42 | :class-dir class-dir}) 43 | (b/uber {:class-dir class-dir 44 | :uber-file uberjar-filename 45 | :basis basis 46 | :main 'modex.mcp.core}) 47 | (println (str "Compiled uberjar to path: " uberjar-filename))) -------------------------------------------------------------------------------- /test/modex/mcp/rpc_test.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.rpc-test 2 | "" 3 | (:require [clojure.test :as t :refer [deftest testing is]] 4 | [modex.mcp.json-rpc :as json-rpc] 5 | [modex.mcp.server :as server] 6 | [modex.mcp.tools :as tools] 7 | [modex.mcp.server :as mcp-server])) 8 | 9 | (def fixture-basic-tools 10 | ; todo: move to fixtures namespace 11 | (tools/tools 12 | (foo "Greets a person by name." 13 | [^{:type :text :doc "A person's name."} name] 14 | [(str "Hello, " name "!")]) 15 | (inc "A simple tool that returns a greeting" 16 | [^{:type :number :doc "A number to increment."} x] 17 | [(clojure.core/inc x)]))) 18 | 19 | (deftest modex-rcp-tests 20 | (testing "JSON RPC requests are routed to MCP server" 21 | (let [server (mcp-server/->server {:tools fixture-basic-tools})] 22 | (testing "tools/list" 23 | (let [request-id 1 24 | expected (json-rpc/result 25 | request-id 26 | {:tools 27 | [{:name :foo, 28 | :description "Greets a person by name.", 29 | :inputSchema {:type "object", :required [:name] 30 | :properties {:name {:type :text 31 | :doc "A person's name." 32 | :required true}}}} 33 | {:name :inc, 34 | :description "A simple tool that returns a greeting", 35 | :inputSchema {:type "object", 36 | :required [:x], 37 | :properties {:x {:type :number 38 | :doc "A number to increment." 39 | :required true}}}}]})] 40 | (is (= expected 41 | (server/handle-request server {:id request-id :method "tools/list"}))))) 42 | 43 | (testing "tools/call foo" 44 | (let [request-id 2] 45 | (is (= {:jsonrpc "2.0" 46 | :id request-id 47 | :result {:content [{:type "text", :text "Hello, AI!"}] 48 | :isError false}} 49 | (server/handle-request server {:id request-id 50 | :method "tools/call" 51 | :params {:name "foo" :arguments {:name "AI"}}}))))) 52 | 53 | (testing "tools/call inc" 54 | (let [request-id 2] 55 | (is (= {:jsonrpc "2.0" 56 | :id request-id 57 | :result {:content [{:type "text", :text "101"}] ; can this be type number, plz? 58 | :isError false}} 59 | (server/handle-request server {:id request-id 60 | :method "tools/call" 61 | :params {:name "inc" :arguments {:x 100}}})))))))) -------------------------------------------------------------------------------- /src/modex/mcp/core.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.core 2 | (:require [modex.mcp.protocols :as mcp] 3 | [modex.mcp.server :as server] 4 | [modex.mcp.tools :as tools] 5 | [clojure.core :as cc] 6 | [clojure.repl] ; just for pst stack trace. 7 | [taoensso.timbre :as log] 8 | [nrepl.server :as nrepl]) 9 | (:gen-class)) 10 | 11 | ; Optional nREPL for live dev: 12 | (def nrepl-bind-address (System/getenv "NREPL_BIND_ADDRESS")) ; won't start if nil. usually 127.0.0.1 13 | (def nrepl-port (or (some-> (System/getenv "NREPL_PORT") (parse-long)) 4001)) 14 | 15 | (server/redirect-logs-to-stderr!) ; any logs or prn's to stdio will break MCP over stdio transport. 16 | 17 | (def !server-ready? (atom false)) 18 | 19 | (def my-tools 20 | "Define your tools here." 21 | (tools/tools 22 | (greet 23 | "Greets a person by name." 24 | ; Tool handler arguments support {:keys [...] destructuring with maps for :doc, :type and :or. 25 | ; Presence in :or implies optionality. 26 | [{:keys [first-name last-name] 27 | :doc {first-name "A person's first name." 28 | last-name "A person's last name."} 29 | :type {first-name :string 30 | last-name :string} 31 | :or {last-name nil}}] ; last-name is optional, first-name required 32 | ; Presently, tools should return collections. 33 | ; Upcoming change: tools will return a map of {:keys [success results ?error]}. 34 | [(str "Hello from Modex, " 35 | (if last-name 36 | (str first-name " " last-name) 37 | first-name) "!")]) 38 | 39 | (inc 40 | "Increments a number." 41 | ; also support vector arg-style with metadata. :required default is true. 42 | [^{:type :number :doc "x is a number to increment."} x] 43 | [(cc/inc x)]) 44 | 45 | (range 46 | "Calls (range n) in Clojure and returns a seq." ; demonstrates seq returns 47 | [^{:type :number :doc "(range n)"} n] 48 | (cc/range n)) ; note (cc/range n) returns a coll. 49 | 50 | (add "Adds two numbers." 51 | [^{:type :number, :doc "1st Number"} a 52 | ^{:type :number, :doc "2nd number"} b] 53 | [(+ a b)]))) 54 | 55 | (comment 56 | (tools/invoke-handler (get-in my-tools [:range :handler]) {:n 5}) 57 | (tools/invoke-handler (get-in my-tools [:greet :handler]) {:first-name "Petrus"}) 58 | (tools/invoke-handler (get-in my-tools [:add :handler]) {:a 10 :b 6})) 59 | 60 | (defn init-server 61 | "Blocking init function for long-running I/O like connecting to remote databases. 62 | Modex will notify the MCP client when ready. 63 | 64 | Here we simulate a DB connect call that takes 1 second. 65 | 66 | With no delay, notification/initialized can be sent before the init request, but it still works. Will fix." 67 | [init-params] 68 | ;(Thread/sleep 1000) 69 | (reset! !server-ready? true)) 70 | 71 | (def my-mcp-server 72 | "Here we create a reified instance of AServer. Only tools are presently supported." 73 | (server/->server 74 | {:name "Modex MCP Server" 75 | :version "0.0.1" 76 | :initialize init-server 77 | :tools my-tools 78 | :prompts nil 79 | :resources nil})) 80 | 81 | (defn -main 82 | "Starts an MCP server that talks JSON-RPC over stdio/stdout." 83 | [& args] 84 | (log/debug "Starting nREPL...") 85 | (let [nrepl-server (if (and (string? nrepl-bind-address) (int? nrepl-port)) ; todo move nrepl config to args or env. 86 | (nrepl/start-server :bind nrepl-bind-address :port nrepl-port) 87 | nil)] 88 | (log/debug "Server starting via -main") 89 | (try 90 | (server/start-server! my-mcp-server) ; todo: impl. shutdown signal (return fn?) 91 | (catch Throwable t 92 | (log/error "Fatal error in -main:" (.getMessage t)) 93 | (log/error "Stack trace:" (with-out-str (clojure.repl/pst))) 94 | (.printStackTrace t (java.io.PrintWriter. *err*))) 95 | (finally 96 | (when nrepl-server 97 | (nrepl/stop-server nrepl-server)))))) 98 | 99 | (comment 100 | 101 | (require '[clojure.repl]) 102 | (throw (ex-info "hi" {})) 103 | (with-out-str (clojure.repl/pst)) 104 | 105 | (-main) 106 | 107 | "You can init server (this will call init-server):" 108 | (mcp/initialize my-mcp-server) 109 | 110 | "You can list tools:" 111 | (mcp/list-tools my-mcp-server) 112 | 113 | "You can invoke a tool with:" ; will probably rename to invoke. 114 | (mcp/call-tool my-mcp-server :greet {:first-name "Petrus"})) 115 | -------------------------------------------------------------------------------- /test/modex/mcp/tools_test.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.tools-test 2 | (:require [clojure.test :as t :refer [deftest testing is]] 3 | [modex.mcp.tools :as tools] 4 | [jsonista.core :as json])) 5 | 6 | (deftest tool-macro-tests 7 | 8 | (testing "tools/handler returns fn with :mcp metadata" 9 | ; todo, infer :required via :or. 10 | (let [handler (tools/handler 11 | [{:keys [title age] 12 | :or {age 37} 13 | :type {title :string} 14 | :doc {title "Person's name"}}] 15 | (str "Hi, " title " (" age ")"))] 16 | (is (= '{:mcp {:type {title :string} 17 | :doc {title "Person's name"}}}) 18 | (meta handler)) 19 | (is (= "Hi, Petrus (37)" (handler {:title "Petrus"})))) 20 | 21 | (testing "tools/handler validates :type" 22 | (is (thrown? AssertionError (tools/handler [{:keys [a b] 23 | :type {a :text}}])))))) 24 | 25 | (deftest tools-tests 26 | (testing "Tool argument types :text are correctly coerced to JSON" 27 | (json/write-value-as-string [{:x {:y [:z "hi"]}} :a :b] json/keyword-keys-object-mapper) 28 | (let [tool (tools/map->Tool 29 | {:name :greet 30 | :doc "Greeter" 31 | :args [(tools/map->Parameter {:name :name 32 | :doc "A person's name" 33 | :type :text 34 | :required true})]})] 35 | (is (= {:name :greet 36 | :description "Greeter" 37 | :inputSchema {:type "object" 38 | :required [:name] 39 | :properties {:name {:type :text 40 | :doc "A person's name" 41 | :required true}}}} 42 | (tools/tool->json-schema tool))))) 43 | 44 | (testing "we can make a tool" 45 | (let [adder (tools/tool (add [{:keys [x y] 46 | :type {x :number 47 | y :number}}] 48 | [(+ x y)]))] 49 | (testing "and we can invoke that tool with an argument map." 50 | (is (= {:success true :results [13]} (tools/invoke-tool adder {:x 6 :y 7})))))) 51 | 52 | (testing "tools macro just calls tool for each tool definition and returns a map from (keyword tool-name) => tool." 53 | (let [my-tools (tools/tools 54 | (add "Adds two numbers." 55 | [{:keys [x y] 56 | :type {x :number 57 | y :number}}] 58 | [(+ x y)]) 59 | (subtract "Subtracts two numbers (b from a)" 60 | [^{:type :number} a, 61 | ^{:type :number} b] [(- a b)]))] 62 | (testing "can invoke tool" 63 | (let [{:keys [add subtract]} my-tools] 64 | (is (= {:success true, :results [11]} (tools/invoke-tool add {:x 5, :y 6}))) 65 | (is (= {:success true, :results [3]} (tools/invoke-tool subtract {:a 10 :b 7}))))))) 66 | 67 | (testing "docstrings are optional (defaults to tool name)") 68 | (let [add-tool (tools/tool 69 | (add [^{:type :number} x 70 | ^{:type :number} y] 71 | (+ x y)))] 72 | (is (= ({:name :add 73 | :doc "add" 74 | :args [(tools/map->Parameter {:name :x 75 | :doc "x" 76 | :type :number 77 | :required true}) 78 | (tools/map->Parameter {:name :y 79 | :doc "y" 80 | :type :number 81 | :required true})]} 82 | (dissoc add-tool :handler))))) 83 | 84 | (testing "we can define multiple tools" 85 | (let [multiple-tools 86 | (tools/tools 87 | (greet [^{:type :string} name 88 | ^{:type :number} birth-year] 89 | (let [current-year (+ 1900 (.getYear (java.util.Date.)))] ; => 2025]) 90 | (str "Hello, " name "! You are " (- current-year birth-year) " years old :)"))) 91 | (add [^{:type :number} a 92 | ^{:type :number} b] 93 | (+ a b)) 94 | (subtract "Subtracts two numbers" 95 | [{:keys [x y] 96 | :type {x :number 97 | y :number} 98 | :or {y 0}}] 99 | (+ x y)))] 100 | 101 | (is (= [{:name :greet 102 | :doc "greet" 103 | :args [(tools/map->Parameter {:name :name, :doc "name", :type :string, :required true}) 104 | (tools/map->Parameter {:name :birth-year, :doc "birth-year", :type :number, :required true})]} 105 | {:name :add 106 | :doc "add" 107 | :args [(tools/map->Parameter {:name :a, :doc "a", :type :number, :required true}) 108 | (tools/map->Parameter {:name :b, :doc "b", :type :number, :required true})]} 109 | {:name :subtract 110 | :doc "Subtracts two numbers" 111 | :args [(tools/map->Parameter {:name :x, :doc "x", :type :number, :required true}) 112 | (tools/map->Parameter {:name :y, :doc "y", :type :number, :required false :default 0})]}] 113 | (map #(dissoc % :handler) (vals multiple-tools)))))) 114 | 115 | (testing "tools can dispatch to external handlers works" 116 | (let [add-handler (fn [a b] (+ a b)) 117 | tools-with-handlers (tools/tools 118 | (add "" [^{:type :number} a 119 | ^{:type :number} b] 120 | [(add-handler a b)]) 121 | (subtract "subtracts two numbers" 122 | [^{:type :number} x 123 | ^{:type :number} y] 124 | [(- x y)])) 125 | add-tool (get tools-with-handlers :add) 126 | subtract-tool (get tools-with-handlers :subtract)] 127 | (is (= {:success true :results [11]} (tools/invoke-tool add-tool {:a 5 :b 6}))) 128 | (is (= {:success true :results [4]} (tools/invoke-tool subtract-tool {:x 10 :y 6})))))) 129 | -------------------------------------------------------------------------------- /src/modex/mcp/client.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.client 2 | "MCP client implementation using the stdio transport mechanism." 3 | (:require [jsonista.core :as json] 4 | [modex.mcp.json-rpc :as json-rpc] 5 | [modex.mcp.schema :as schema] 6 | [taoensso.timbre :as log] 7 | [clojure.java.io :as io]) 8 | (:gen-class)) 9 | 10 | ;; State to store the subprocess and IO streams 11 | (defonce ^:private state (atom nil)) 12 | 13 | ;; Logging helper 14 | (defn log [& args] 15 | (binding [*out* *err*] 16 | (log/debug "Client: " args) 17 | (flush))) 18 | 19 | ;; JSON-RPC helpers 20 | (defn- send-request [writer request-id method & [params]] 21 | (let [request (json-rpc/method request-id method) 22 | request (if params (assoc request :params params) request) 23 | json-str (json/write-value-as-string request json/keyword-keys-object-mapper)] 24 | (log "Sending request:" json-str) 25 | (.write writer (str json-str "\n")) 26 | (.flush writer))) 27 | 28 | (defn- send-notification [writer method & [params]] 29 | (let [notification {:jsonrpc "2.0" 30 | :method method} 31 | notification (if params (assoc notification :params params) notification) 32 | json-str (json/write-value-as-string notification json/keyword-keys-object-mapper)] 33 | (log "Sending notification:" json-str) 34 | (.write writer (str json-str "\n")) 35 | (.flush writer))) 36 | 37 | (defn- read-response [reader request-id] 38 | (loop [] 39 | (let [line (.readLine reader)] 40 | (if (nil? line) 41 | (do 42 | (log "Server closed connection") 43 | {:error "Server closed connection"}) 44 | (let [response (json/read-value line json/keyword-keys-object-mapper)] 45 | (log "Received response:" line) 46 | (cond 47 | ;; If it's a notification, process it and continue 48 | (and (= (:jsonrpc response) "2.0") (:method response)) 49 | (do 50 | (log "Received notification:" response) 51 | (recur)) 52 | 53 | ;; If it's the response we're waiting for 54 | (= (:id response) request-id) 55 | response 56 | 57 | ;; Otherwise, continue reading 58 | :else 59 | (recur))))))) 60 | 61 | ;; Client API 62 | (defn start-client 63 | "Launches the MCP server as a subprocess and establishes the stdio transport connection." 64 | [server-command] 65 | (log "Starting client with command:" server-command) 66 | (try 67 | (let [process (if (string? server-command) 68 | (.exec (Runtime/getRuntime) server-command) 69 | (.exec (Runtime/getRuntime) 70 | (into-array String server-command))) 71 | in (io/reader (.getInputStream process)) ; Server's stdout -> Client's input 72 | out (io/writer (.getOutputStream process)) ; Client's output -> Server's stdin 73 | err (io/reader (.getErrorStream process))] 74 | 75 | (reset! state {:process process 76 | :in in 77 | :out out 78 | :err err}) 79 | 80 | ;; Start a thread to log server stderr output 81 | (future 82 | (try 83 | (loop [] 84 | (when-let [line (.readLine err)] 85 | (log/debug "Server stderr:" line) 86 | (recur))) 87 | (catch Exception e 88 | (log/debug "Error reading server stderr:" (.getMessage e))))) 89 | 90 | ;; Return the client state 91 | @state) 92 | (catch Exception e 93 | (log "Error starting client:" (.getMessage e)) 94 | (.printStackTrace e (java.io.PrintWriter. *err*)) 95 | nil))) 96 | 97 | (defn stop-client 98 | "Stops the client by closing all streams and terminating the server subprocess." 99 | [] 100 | (log "Stopping client") 101 | (when-let [{:keys [process in out err]} @state] 102 | (try 103 | (.close out) ; Close stdin to the server process 104 | (.close in) 105 | (.close err) 106 | 107 | ;; Wait for the process to exit naturally 108 | (let [exited (future (.waitFor process 2 java.util.concurrent.TimeUnit/SECONDS))] 109 | (if (deref exited 2000 false) 110 | ;; Process exited naturally 111 | (log "Server process exited naturally") 112 | ;; Process didn't exit, so destroy it 113 | (do 114 | (log "Server process did not exit, destroying it") 115 | (.destroy process)))) 116 | 117 | (reset! state nil) 118 | (catch Exception e 119 | (log "Error stopping client:" (.getMessage e)) 120 | (.printStackTrace e (java.io.PrintWriter. *err*)))))) 121 | 122 | (defn initialize [protocol-version] 123 | (log "Initializing client with protocol version:" protocol-version) 124 | (let [{:keys [in out]} @state 125 | request-id 1 126 | params {:protocolVersion protocol-version 127 | :capabilities {:sampling {}} 128 | :clientInfo {:name "MCP Hello World Client" 129 | :version "1.0.0"}}] 130 | (send-request out request-id "initialize" params) 131 | (let [response (read-response in request-id)] 132 | ;; Send initialized notification 133 | (when-not (:error response) 134 | (send-notification out "notifications/initialized")) 135 | response))) 136 | 137 | (defn list-tools [] 138 | (log "Listing tools") 139 | (let [{:keys [in out]} @state 140 | request-id 2] 141 | (send-request out request-id "tools/list") 142 | (read-response in request-id))) 143 | 144 | (defn call-tool [tool-name & [arguments]] 145 | (log "Calling tool:" tool-name) 146 | (let [{:keys [in out]} @state 147 | request-id 3 148 | params {:name tool-name} 149 | params (if arguments (assoc params :arguments arguments) params)] 150 | (send-request out request-id "tools/call" params) 151 | (read-response in request-id))) 152 | 153 | ;; Helper function to start the server and initialize the client 154 | (defn connect-to-server [server-command protocol-version] 155 | (log "Connecting to server with command:" server-command) 156 | (start-client server-command) 157 | (let [init-result (initialize protocol-version)] 158 | (if (:error init-result) 159 | (do 160 | (log "Initialization failed:" (:error init-result)) 161 | (stop-client) 162 | {:error (:error init-result)}) 163 | init-result))) 164 | 165 | (defn -main [& args] 166 | (log "Client starting via -main") 167 | (let [server-command (or (first args) "clojure -M:run-server")] 168 | (try 169 | (let [connection (connect-to-server server-command schema/latest-protocol-version)] 170 | (if (:error connection) 171 | (log/debug "Failed to connect:" (:error connection)) 172 | (do 173 | (log/debug "Connected successfully. Server info:" 174 | (get-in connection [:result :serverInfo])) 175 | (let [tools-result (list-tools)] 176 | (log/debug "Available tools:" (get-in tools-result [:result :tools])) 177 | (let [call-result (call-tool "foo")] 178 | (log/debug "Tool response:" 179 | (get-in call-result [:result :content 0 :text]))))))) 180 | (finally 181 | (stop-client))))) -------------------------------------------------------------------------------- /test/modex/mcp/server_test.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.server-test 2 | (:require [clojure.test :refer :all] 3 | [clojure.core :as cc] 4 | [jsonista.core :as json] 5 | [modex.mcp.protocols :as mcp] 6 | [modex.mcp.schema :as schema] 7 | [modex.mcp.server :as server] 8 | [modex.mcp.tools :as tools] 9 | [taoensso.timbre :as log])) 10 | 11 | (def fixture-basic-tools 12 | (tools/tools 13 | (foo "Greets a person by name." 14 | [^{:type :text :doc "A person's name."} name] 15 | [(str "Hello, " name "!")]) 16 | (inc "A simple tool that returns a greeting" 17 | [^{:type :number :doc "A number to increment."} x] 18 | [(cc/inc x)]) 19 | (broken-tool "A tool that throws for error tests." 20 | [^{:type :text :doc "Anything"} x] 21 | (throw (ex-info "Tool throws intentionally" {:cause "broken-tool"}))))) 22 | 23 | (defn make-request [id method params] 24 | (let [request (cond-> {:jsonrpc "2.0" 25 | :id id 26 | :method method} 27 | params (assoc :params params))] 28 | (str (json/write-value-as-string request json/keyword-keys-object-mapper) "\n"))) 29 | 30 | ;(def fixture-initialize 31 | ; (make-request 1 "initialize")) 32 | 33 | (deftest mcp-server-delayed-init-tests 34 | ; probably does not belong here. 35 | (testing "server sends notification/initialized when on-init callback is called" 36 | (let [!rx (atom []) 37 | !tx (atom []) 38 | !inited (atom false) 39 | server (server/->server {:name "Test Server" 40 | :version "1.0" 41 | :on-receive (fn [msg] (swap! !rx conj msg)) 42 | :on-send (fn [msg] (swap! !tx conj msg)) 43 | :on-init #(do (log/debug 'init-called) true) ; sample delay 44 | :tools fixture-basic-tools})] 45 | ;(mcp/initialize server) 46 | (server/handle-message server 47 | {:id 1 48 | :method "initialize" 49 | :params {}} 50 | (fn [msg] 51 | ; (prn 'send-msg msg) ; todo: switch to Timbre. 52 | ; todo check contents 53 | (reset! !inited true))) 54 | ; ok now to handle message 55 | (Thread/sleep 100) ; todo better concurrency on send 56 | 57 | (is (true? @!inited))))) 58 | 59 | (deftest mcp-server-protocol-tests 60 | (let [server (server/->server {:name "Test Server" 61 | :version "1.0" 62 | :tools fixture-basic-tools})] 63 | 64 | ; order may be non-deterministic. will need to cache tools in AServer. 65 | 66 | (testing "server has name & version" 67 | (= "Test Server" (mcp/server-name server)) 68 | (= "1.0" (mcp/version server))) 69 | 70 | (testing "we can list tools" 71 | (is (= [{:name :foo, 72 | :description "Greets a person by name.", 73 | :inputSchema {:type "object" 74 | :required [:name] 75 | :properties {:name {:type :text 76 | :doc "A person's name." 77 | :required true}}}} 78 | {:name :inc, 79 | :description "A simple tool that returns a greeting", 80 | :inputSchema {:type "object" 81 | :required [:x] 82 | :properties {:x {:type :number 83 | :doc "A number to increment." 84 | :required true}}}} 85 | {:name :broken-tool, 86 | :description "A tool that throws for error tests.", 87 | :inputSchema {:type "object" 88 | :required [:x] 89 | :properties {:x {:type :text 90 | :doc "Anything" 91 | :required true}}}}] 92 | (mcp/list-tools server)))) 93 | 94 | (testing "we can invoke tools via server. call-tool returns [?result ?error]." 95 | (is (= {:success true, :results ["Hello, Petrus!"]} (mcp/call-tool server :foo {:name "Petrus"}))) 96 | (let [ex (try 97 | (mcp/call-tool server :foo {:bad-arg "Petrus"}) 98 | (catch Exception ex 99 | ;(prn 'ex ex) 100 | ex)) 101 | ex-data (ex-data ex) 102 | ex-msg (ex-message ex)] 103 | (is (= "Missing tool parameters: :name" ex-msg)) 104 | (is (= :tool.exception/missing-parameters (:cause ex-data))))) 105 | 106 | (testing "handle-tools-call throws protocol-level error for unknown tools" 107 | ; this test is at wrong level 108 | (let [missing-tool-request {:id 4 109 | :method "tools/call" 110 | :params {:name "unknown-tool" 111 | :arguments {:missing-arg "abc"}}} 112 | mcp-server (server/->server {:tools fixture-basic-tools})] 113 | (log/warn (server/handle-request mcp-server missing-tool-request)) 114 | ; todo missing tool should return error code invalid params 115 | ; this is broken. should be a protocol level error. 116 | (is (= {:jsonrpc schema/json-rpc-version 117 | :id 4 118 | :error {:code schema/error-invalid-params 119 | :message (str "Unknown tool: unknown-tool")}} 120 | (server/handle-request mcp-server missing-tool-request))))) 121 | 122 | (testing "broken tools return error in result (not via protocol-level errors)" 123 | (let [bad-tool-request {:id 4 124 | :method "tools/call" 125 | :params {:name "broken-tool", :arguments {:x ""}}} 126 | mcp-server (server/->server {:tools fixture-basic-tools})] 127 | ; todo missing tool should return error code invalid params 128 | ; {:error {:code 123 129 | ; :message "Tool throws intentionally"} 130 | ; :jsonrpc schema/json-rpc-version 131 | ; :id 4} 132 | (is (= {:jsonrpc schema/json-rpc-version 133 | :id 4 134 | :result {:isError true 135 | :content [{:type "text", :text "Tool throws intentionally"}]}} 136 | (server/handle-request mcp-server bad-tool-request))))) 137 | 138 | (testing "missing tool arguments are considered protocol-level errors" 139 | (let [missing-args-req {:id 4 140 | :method "tools/call" 141 | :params {:name "broken-tool"}} 142 | mcp-server (server/->server {:tools fixture-basic-tools})] 143 | ;(log/warn (server/handle-request mcp-server missing-args-req)) 144 | ; todo missing tool should return error code invalid params 145 | (is (= {:jsonrpc schema/json-rpc-version 146 | :id 4 147 | :error {:code schema/error-invalid-params 148 | :message (str "Missing tool parameters: :x")}} 149 | (server/handle-request mcp-server missing-args-req))))))) 150 | 151 | (deftest test-handle-initialize 152 | (testing "initialize returns capabilities and sends init notification." 153 | (let [init-request {:id 1 154 | :method "initialize" 155 | :params {:protocolVersion schema/latest-protocol-version 156 | :capabilities {} 157 | :clientInfo {:name "Test Client" :version "1.0.0"}}} 158 | !notifications (atom []) 159 | notification-handler (fn [msg] (comment "until we have an async bus, we use :on-send-notification for testing.")) 160 | mcp-server (server/->server {:initialize (fn [] (log/warn "mcp-server initialize called")) 161 | :enqueue-notification (fn [msg] (swap! !notifications conj msg)) 162 | :tools fixture-basic-tools}) 163 | init-response (server/handle-request mcp-server init-request notification-handler)] 164 | (is (= {:jsonrpc "2.0" 165 | :id 1 166 | :result {:protocolVersion "2024-11-05" 167 | :capabilities {:tools {:listChanged true} 168 | :resources {:listChanged false} 169 | :prompts {:listChanged false}} 170 | :serverInfo {:name nil, :version nil}}} 171 | init-response)) 172 | 173 | (is (= [{:jsonrpc "2.0" 174 | :method "notifications/initialized"}] @!notifications))))) 175 | -------------------------------------------------------------------------------- /test/modex/mcp/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.client-test 2 | (:require [clojure.test :refer :all] 3 | [jsonista.core :as json] 4 | [clojure.java.io :as io] 5 | [clojure.core :as cc] 6 | [modex.mcp.client :as client] 7 | [modex.mcp.protocols :as mcp] 8 | [modex.mcp.schema :as schema] 9 | [modex.mcp.server :as server] 10 | [modex.mcp.tools :as tools] 11 | [taoensso.timbre :as log]) 12 | (:import [java.io PipedInputStream PipedOutputStream])) 13 | 14 | (def tool-fixtures 15 | (tools/tools 16 | (foo "Greets a person by name." 17 | [^{:type :text :doc "A person's name."} name] 18 | [(str "Hello, " name "!")]) 19 | (slow "A slow tool to test async." 20 | [] 21 | [(do (Thread/sleep 500) :done)]) 22 | (inc "A simple tool that returns a greeting" 23 | [^{:type :number :doc "A number to increment."} x] 24 | [(cc/inc x)]))) 25 | 26 | (defprotocol ATransport ; probably this exists elsewhere 27 | (write! [this method params]) 28 | (read [this])) 29 | 30 | (defn make-request [id method params] 31 | (let [request (cond-> {:jsonrpc "2.0" 32 | :id id 33 | :method method} 34 | params (assoc :params params))] 35 | (str (json/write-value-as-string request json/keyword-keys-object-mapper) "\n"))) 36 | 37 | (comment 38 | (make-request 1 "tools/call" {:name "foo" :arguments {:name "AI"}}) 39 | 40 | (make-request 1 "initialize" {:protocolVersion schema/latest-protocol-version 41 | :capabilities {:sampling {}} 42 | :clientInfo {:name "Test Client" :version "1.0.0"}})) 43 | 44 | (defn write-request! 45 | "Returns ID." 46 | [writer id method & [params]] 47 | ; can this use our concurrency-safe writer? 48 | (let [msg (make-request id method params)] 49 | (log/debug 'client-test-send msg) 50 | (locking writer ; can we use existing fns for this? 51 | (.write writer msg) 52 | (.flush writer)) 53 | id)) 54 | 55 | 56 | (defn read-response [reader] 57 | (let [line (.readLine reader)] 58 | (when line 59 | (log/debug 'parsing-line line) 60 | (let [parsed (json/read-value line json/keyword-keys-object-mapper)] 61 | (log/debug 'parsed parsed) 62 | parsed)))) 63 | 64 | (defrecord MockTransport [!request-id writer reader] 65 | ATransport 66 | (write! [this method params] 67 | (write-request! writer (swap! !request-id inc) method params)) 68 | (read [this] (read-response reader))) 69 | 70 | (defn make-piped-streams [] 71 | (let [client-to-server (PipedOutputStream.) 72 | server-in (PipedInputStream. client-to-server) 73 | 74 | server-to-client (PipedOutputStream.) 75 | client-in (PipedInputStream. server-to-client)] 76 | 77 | {:server-reader (io/reader server-in) 78 | :server-writer (io/writer server-to-client) 79 | :client-reader (io/reader client-in) 80 | :client-writer (io/writer client-to-server)})) 81 | 82 | (defn close-pipes! [{:as pipes 83 | :keys [server-writer server-reader 84 | client-writer client-reader]} 85 | stdio-server] 86 | (testing "order matters here" 87 | ;; First close client-writer to signal end-of-stream to the server 88 | (.close client-writer) 89 | 90 | ;; Give the server a brief period to process the disconnection 91 | (Thread/sleep 100) ; todo figure out a way to wait for this. probably server notification? 92 | 93 | ;; Then cancel the server future 94 | (future-cancel stdio-server) 95 | 96 | ;; Clean up remaining resources 97 | (.close client-reader) 98 | (.close server-writer) 99 | (.close server-reader))) 100 | 101 | (deftest test-server-client-integration 102 | (testing "Server responds correctly to requests over piped streams" 103 | (let [{:as pipes 104 | :keys [server-writer server-reader 105 | client-writer client-reader]} (make-piped-streams) 106 | 107 | mcp-server (server/->server {:name "Test MCP Server" 108 | :version "1.0.0" 109 | :initialize (fn [init-params] (Thread/sleep 50)) 110 | :tools tool-fixtures}) 111 | ;; Start the server in a separate thread 112 | stdio-server (future (server/start-server! mcp-server server-reader server-writer)) 113 | 114 | ;; Create a mini client for testing 115 | !request-id (atom 0) 116 | client (->MockTransport !request-id client-writer client-reader)] ; client transport? 117 | (try 118 | ;; Test 1: Initialize 119 | (let [init-id (write! client "initialize" 120 | {:protocolVersion schema/latest-protocol-version 121 | :capabilities {:sampling {}} 122 | :clientInfo {:name "Test Client" :version "1.0.0"}}) 123 | init-response (read client) 124 | _ (log/debug 'init-response init-response)] 125 | 126 | (is (= {:id init-id 127 | :jsonrpc "2.0" 128 | :result {:capabilities {:prompts {:listChanged false} 129 | :resources {:listChanged false} 130 | :tools {:listChanged true}} 131 | :protocolVersion "2024-11-05" 132 | :serverInfo {:name "Test MCP Server" 133 | :version "1.0.0"}}} init-response)) 134 | 135 | (testing "initialize response should be followed by notifications/initalized") 136 | (let [init-notification (read client)] 137 | ;(prn 'init-notif init-notification) ; todo: switch to Timbre. 138 | (is (= "notifications/initialized" (:method init-notification))))) 139 | 140 | ;; Test 2: List tools 141 | (let [list-tools-id (write! client "tools/list" {}) 142 | list-tools-response (read client)] 143 | ;(prn list-tools-response) 144 | (is (= {:id list-tools-id 145 | :jsonrpc "2.0" 146 | :result {:tools [{:name "foo" 147 | :description "Greets a person by name." 148 | :inputSchema {:properties {:name {:doc "A person's name." 149 | :required true 150 | :type "text"}} 151 | :type "object" 152 | :required ["name"]}} 153 | {:name "slow" 154 | :description "A slow tool to test async." 155 | :inputSchema {:properties {} 156 | :type "object" 157 | :required []}} 158 | {:name "inc" 159 | :description "A simple tool that returns a greeting" 160 | :inputSchema {:properties {:x {:doc "A number to increment." 161 | :required true 162 | :type "number"}} 163 | :type "object" 164 | :required ["x"]}}]}} 165 | list-tools-response))) 166 | 167 | ;; Test 3: Call the foo tool 168 | ;; ; actually arrives from client: 169 | ;; {:jsonrpc "2.0", :method "tools/call", :params {:arguments {:x 5}, :name "inc"}, :id 6} 170 | 171 | (let [call-id (write! client "tools/call" {:name "foo" :arguments {:name "AI"}}) 172 | call-response (read client)] 173 | (log/debug call-response) 174 | (is (= {:jsonrpc schema/json-rpc-version 175 | :id call-id 176 | :result {:content [{:type "text", :text "Hello, AI!"}] 177 | :isError false}} 178 | call-response))) 179 | 180 | (let [call-id (write! client "tools/call" {:name "inc" :arguments {:x 5}}) 181 | call-response (read client)] 182 | (log/debug call-response) 183 | (is (= {:jsonrpc schema/json-rpc-version 184 | :id call-id 185 | :result {:content [{:type "text", :text "6"}] 186 | :isError false}} 187 | call-response))) 188 | 189 | (testing "missing arguments are -32602 protocol-level errors" 190 | (let [call-id (write! client "tools/call" {:name "inc" :arguments {:y 5}}) 191 | call-response (read client)] 192 | (log/debug call-response) 193 | (is (= {:jsonrpc schema/json-rpc-version 194 | :id call-id 195 | :error {:code schema/error-invalid-params 196 | :message "Missing tool parameters: :x"}} 197 | call-response)))) 198 | 199 | (finally 200 | (close-pipes! pipes stdio-server)))))) 201 | 202 | (deftest async-tests 203 | (testing "slow tools do not block execution of fast tools" 204 | (let [{:as pipes 205 | :keys [server-writer server-reader 206 | client-writer client-reader]} (make-piped-streams) 207 | 208 | mcp-server (server/->server {:tools tool-fixtures}) 209 | stdio-server (future (server/start-server! mcp-server server-reader server-writer)) 210 | 211 | !request-id (atom 0) 212 | client (->MockTransport !request-id client-writer client-reader)] 213 | 214 | (try 215 | (write! client "initialize" {:protocolVersion schema/latest-protocol-version 216 | :capabilities {:sampling {}} 217 | :clientInfo {:name "Test Client" :version "1.0.0"}}) 218 | (is (= "notifications/initialized" (:method (read client)))) 219 | (write! client "tools/call" {:name "slow" :arguments {}}) 220 | (write! client "tools/call" {:name "inc" :arguments {:x 100}}) 221 | (is (= 1 (:id (read client)))) ; init - don't care. 222 | (is (= 3 (:id (read client)))) ; fast cool comes back first. 223 | (is (= 2 (:id (read client)))) ; slow call come back later. 224 | (finally 225 | (testing "order matters here" 226 | (close-pipes! pipes stdio-server))))))) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modex: Model Context Protocol Server & Client Library in Clojure 2 | 3 | Modex (MOdel + ContEXt) is a Clojure library that lets you augment your AI with new tools, resources and prompts. 4 | 5 | Modex implements (most of) the [Model Context Protocol](https://modelcontextprotocol.io/) to build MCP Servers & Clients in 'native' Clojure. 6 | 7 | Because it's native Clojure, you don't need to deal with Anthropic's [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk). 8 | 9 | Modex implements the `stdio` transport in the 2024-11-05 MCP spec, so no need for a proxy like 10 | [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) to translate between SSE <=> stdio or vice versa. 11 | 12 | ## Screenshot of Modex in Action 13 | 14 | Claude Desktop can talk to a Modex MCP Server via its MCP client: 15 | 16 | image 17 | 18 | ## Table of Contents 19 | 20 | 1. [Quickstart](#quickstart) 21 | 2. [What is MCP?](#what-is-mcp) 22 | 3. [What can Modex do?](#what-can-modex-do) 23 | 4. [Detailed Step-by-Step Instructions](#detailed-step-by-step-instructions) 24 | 5. [Implementation](#implementation) 25 | 6. [Project Status](#project-status) 26 | 7. [Rationale](#rationale) 27 | 8. [FAQ](#faq) 28 | 9. [Licence](#licence) 29 | 30 | ## Example Tools 31 | 32 | - [Datomic MCP](https://github.com/theronic/datomic-mcp) uses Modex to expose Datomic tools so your models can query DB schema and data in dev or prod. 33 | 34 | ## Quickstart 35 | 36 | 1. `git clone git@github.com:theronic/modex.git` 37 | 2. `cd modex` 38 | 3. `./build.sh` builds an uberjar at `target/modex-mcp-0.2.2.jar`. 39 | 4. Open your Claude Desktop Config at `~/Library/Application\ Support/Claude/claude_desktop_config.json` 40 | 5. Configure a new MCP Server that will run the uberjar at its _full path_: 41 | 42 | ```json 43 | { 44 | "mcpServers": { 45 | "modex-mcp-hello-world": { 46 | "command": "java", 47 | "args": ["-jar", "/Users/your-username/code/modex/target/modex-mcp-0.2.2.jar"] 48 | } 49 | }, 50 | "globalShortcut": "" 51 | } 52 | ``` 53 | 54 | 6. Restart Claude Desktop to activate your new MCP Server + tools :) (Cmd+R refresh does not reload config, only restarts tools) 55 | 7. Tell Claude to "run the inc tool with 123", authorize the tool and you should see an output of 124. 56 | 57 | ## What is MCP? 58 | 59 | MCP lets you augment your AI models with Tools, Resources & Prompts: 60 | 61 | - **Tools** are things it can do, like query a database (e.g. Datomic). 62 | - **Resources** are files and data it can read, like PDF bank statements. 63 | - **Prompts** are templated messages and workflows. 64 | 65 | ## Use Cases 66 | 67 | Modex is used by [datomic-mcp](https://github.com/theronic/datomic-mcp), which exposes our production Datomic databases to an MCP client like Claude Desktop. The AI model intelligently diagnoses support queries in production by reading our database schema and running queries that checks server state & IPs, so it can try to reach it and compare the desired state of VMs against the actual state in our clusters. 68 | 69 | Over time, I hope to automate the bulk of our recurring support queries using Modex + other MCP tools. 70 | 71 | ## What can Modex do? 72 | 73 | ### Full Example 74 | 75 | There is an MCP server example in [src/modex/mcp/core.clj](src/modex/mcp/core.clj) that defines an MCP server with some basic tools. 76 | 77 | Your MCP client (e.g. Claude Desktop) can connect to this server and use exposed tools to provide additional context to your AI models. 78 | 79 | ## Data Structures 80 | 81 | ### Tools 82 | 83 | Internally, a tool is just `Tool` record with several `Parameter` arguments: 84 | 85 | - `(defrecord Tool [name doc args handler])` 86 | - `(defrecord Parameter [name doc type required default])` 87 | 88 | However, it is more convenient to define tools using the `tool` & `tools` macros below. 89 | 90 | ### Describe a single tool with the `tool` macro: 91 | 92 | The `tool` macro acts like `defrecord`, where the handler definition takes a map of arguments ala `{:keys [arg1 arg2 ...]}` but with additional (optional) maps for `:type`, `:or` & `:doc`. This metadata is used to describe the tool to the MCP Client. 93 | 94 | - The MCP spec currently only supports `:string` & `:number` tool parameter types. 95 | - Presence in the `:or` map implies optionality. 96 | - Missing parameter docstrings default to parameter name string. 97 | 98 | ```clojure 99 | (require '[modex.mcp.tools :as tools]) 100 | 101 | (def add-tool 102 | (tools/tool 103 | ; feels like defrecord. 104 | (add [{:keys [x y] 105 | :type {x :number 106 | y :number} 107 | :or {y 0} ; y is optional due to its presence in the :or map. 108 | :doc {x "First number" 109 | y "Second number"}}] 110 | [(+ x y)]))) ; tools should return a vector (to support multiple values). 111 | ``` 112 | 113 | ### Invoke a Tool with `invoke-tool` incl. validation: 114 | 115 | Invocation uses a map of arguments like an MCP client would for a `tools/call` request: 116 | 117 | ```clojure 118 | (tools/invoke-tool add-tool {:x 5 :y 6}) ; Modex will map these arguments and call the handler. 119 | => {:success true, :results [11]} ; note :results is vector to support multiple values. 120 | ``` 121 | 122 | ### Invoke a tool handler directly to skip validation & error-handling: 123 | 124 | ```clojure 125 | (tools/invoke-handler (:handler add-tool) {:x 5 :y 6}) 126 | => [11] ; note vector result to support multiple values. 127 | ``` 128 | 129 | ### Define a Toolset with `tools` macro 130 | 131 | The `tools` macro just calls the `tool` macro for each tool definition and returns a map of tools keyed on tool name (keyword): 132 | 133 | ```clojure 134 | (def my-tools 135 | "Define your tools here." 136 | (tools/tools 137 | (greet 138 | "Greets a person by name." ; tools can have a docstring 139 | [{:keys [first-name last-name] 140 | :doc {first-name "A person's first name." 141 | last-name "A person's last name (optional)."} 142 | :type {first-name :string 143 | last-name :string} 144 | :or {last-name nil}}] ; last-name is optional, implied by presence in `:or` map. 145 | ; tools should return collection. 146 | [(str "Hello from Modex, " 147 | (if last-name ; args can be optional 148 | (str first-name " " last-name) 149 | first-name) "!")]) 150 | 151 | (add 152 | "Adds two numbers." 153 | ; Tool handler args also support deprecated vector arg-style, 154 | ; but this is superseded by the newer map-destructuring style: 155 | [^{:type :number :doc "First number to add."} a 156 | ^{:type :number :doc "Second number to add."} b] 157 | [(+ a b)]) 158 | 159 | (subtract 160 | "Subtracts two numbers (- a b)" 161 | [^{:type :number :doc "First number."} a 162 | ^{:type :number :doc "Second number."} b] 163 | [(- a b)]) 164 | 165 | (error-handling 166 | "This tool throws intentionally. Modex will handle errors for you." 167 | [] 168 | (throw (ex-info "Modex will handle exceptions." {}))))) 169 | ``` 170 | 171 | ### Create a Modex MCP Server + tools: 172 | 173 | ```clojure 174 | (require '[modex.mcp.server :as server]) 175 | (def my-mcp-server 176 | "Here we create a reified instance of AServer. Only tools are presently supported." 177 | (server/->server 178 | {:name "Modex MCP Server" 179 | :version "0.0.2" 180 | :initialize (fn [_init-params] ; init-params, but may contain client capabilities in future. 181 | "Do long-running setup & blocking I/O here, like connecting to prod database.") 182 | :tools my-tools 183 | :prompts nil ; Prompts are WIP. 184 | :resources nil})) ; Resources are WIP. 185 | ``` 186 | 187 | ### Start your MCP Server 188 | 189 | ```clojure 190 | (server/start-server! my-mcp-server) 191 | ``` 192 | 193 | Or put that in your `-main` function. 194 | 195 | ### Protocols 196 | 197 | Modex exposes an `AServer` protocol and a DSL to define tools protocols that describe MCP servers, which expose tools, resources & prompts. 198 | 199 | AServer Protocol: 200 | ```clojure 201 | (defprotocol AServer 202 | (protocol-version [this]) 203 | 204 | (server-name [this]) 205 | (version [this]) 206 | 207 | (capabilities [this]) 208 | 209 | (initialize [this _init-params]) ; init-params is empty for now, but may contain client capabilities in future. 210 | 211 | (list-tools [this]) 212 | (call-tool [this tool-name arg-map]) 213 | 214 | (list-resources [this]) 215 | (list-prompts [this])) 216 | ``` 217 | 218 | ## Detailed Step-by-Step Instructions 219 | 220 | ### Step 1: Build the Uberjar 221 | 222 | Before you can run it, you have to build it first. The build outputs an uberjar, which is like a Java executable. 223 | 224 | ```bash 225 | clojure -T:build uber 226 | ``` 227 | 228 | or run the helper which does that: 229 | ```bash 230 | ./build.sh 231 | ``` 232 | (you might need to run `chmod +x build.sh`) 233 | 234 | ### Step 2: Open Claude Desktop Config 235 | 236 | Open your Claude Desktop Configuration file, `claude_desktop_config.json`, which on MacOS should be at: 237 | 238 | ~/Library/Application\ Support/Claude/claude_desktop_config.json 239 | 240 | ### Step 3: Configure your MCP Server 241 | 242 | Add an element under `mcpServers` so it looks like this: 243 | 244 | ```json 245 | { 246 | "mcpServers": { 247 | "modex": { 248 | "command": "java", 249 | "args": ["-jar", "/Users/your-username/code/modex/target/modex-mcp-0.2.2.jar"] 250 | } 251 | }, 252 | "globalShortcut": "" 253 | } 254 | ``` 255 | 256 | This tells Claude Desktop there is a tool named `modex` and it can connect to by running `java -jar /path/to/your/uber.jar`. 257 | 258 | The way this works is that your local MCP Client (i.e. Claude Desktop), starts your MCP server process and communicates with it via stdin/stdout pipes. 259 | 260 | ### Step 4: Restart Claude Desktop 261 | 262 | You should now be able to ask Claude "run foo", or "what does foo say?" and it will run 263 | the `foo` tool and reply with the response, "Hello, AI!". 264 | 265 | ## Implementation 266 | 267 | Modex implements an MCP client & server in Clojure that is _mostly_ compliant with the [2024-11-05 MCP Spec](https://spec.modelcontextprotocol.io/specification/2024-11-05/). 268 | 269 | Messages are encoded using the JSON-RPC 2.0 wire format. 270 | 271 | There are 3 message types: 272 | - Requests have `{:keys [id method ?params]}` 273 | - Responses have `{:keys [id result ?error]}` 274 | - Notifications have `{:keys [method ?params}` 275 | 276 | MCP supports two transport types: 277 | - [x] stdio/stdout – implemented in Modex. 278 | - [ ] Server-Sent Events (SSE) – not implemented yet. Useful for restricted networks 279 | 280 | ## Project Status 281 | 282 | - [x] Passing tests 283 | - [x] Ergonomics (AServer / AClient protocol?) 284 | - [x] Tools 285 | - [x] nREPL for live changes to running process 286 | - [ ] Resources 287 | - [ ] Prompts 288 | - [in progress] SSE support 289 | - [in progress] Streaming HTTP Support (2025-03-26 MCP spec) 290 | 291 | ## Rationale 292 | 293 | There is an existing library [mcp-clj](https://github.com/hugoduncan/mcp-clj) that uses SSE, so it requires mcp-proxy to proxy from SSE <=> stdio. I was annoyed by this, so I made Modex. 294 | 295 | ## FAQ 296 | 297 | ### Can I modify the server while an MCP Client (like Claude Desktop) is connected? 298 | 299 | Not yet, but I'll add an nREPL soon so you can eval changes while Claude Desktop is connected to the process without rebuilding the uberjar. 300 | 301 | Btw. I tried to get it to run `clojure -M -m modex.mcp.server`, but you can't set Claude Desktop's working directory. 302 | 303 | So currently, I rebuild the uberjar and restart Claude Desktop. Will fix. 304 | 305 | ## Thank You To Paid Modex Customers: 306 | 307 | - [Nextdoc](https://nextdoc.io/) – Document Streaming for Salesforce 308 | - [Huppi](https://www.huppi.io/) — Small Business Accounting Software 309 | 310 | ## License 311 | 312 | In summary: 313 | - **Free for non-commercial use**: Use it, modify it, share it under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) at no cost, just keep it open source. 314 | - **Commercial use**: Want to keep your changes private? Pay $20 once-off for a perpetual commercial license. This covers the cost of my AI tokens to keep building this in public. 315 | 316 | This tool is licensed under the [GNU General Public License v3.0 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0.html). You are free to use, modify, and distribute it, provided that any derivative works are also licensed under the GPLv3 and made open source. This ensures the tool remains freely available to the community while requiring transparency for any changes. 317 | 318 | If you wish to use or modify this tool in a proprietary project—without releasing your changes under the GPLv3—you 319 | may purchase a commercial license. This allows you to keep your modifications private for personal or commercial use. 320 | To obtain a commercial license, please contact me at [modex@petrus.co.za](mailto:modex@petrus.co.za). 321 | 322 | ## Author(s) 323 | 324 | - [Petrus Theron](http://petrustheron.com) 325 | -------------------------------------------------------------------------------- /src/modex/mcp/server.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.server 2 | "Handles JSON RPC interface and dispatches to an MCP server " 3 | (:require [taoensso.timbre :as log] 4 | [jsonista.core :as json] 5 | [modex.mcp.protocols :as mcp :refer [AServer]] 6 | [modex.mcp.schema :as schema] 7 | [modex.mcp.json-rpc :as json-rpc] 8 | [modex.mcp.tools :as tools]) 9 | (:gen-class)) ; gen-class should move to core with main. 10 | 11 | (defn redirect-logs-to-stderr! [] 12 | ;; Configure Timbre to output to stderr so it shows up in MCP Server 13 | (log/set-config! 14 | {:level :debug ;info ;; Set minimum logging level 15 | :appenders 16 | {:println 17 | {:enabled? true 18 | :output-fn :inherit ;; Use default output formatting 19 | :fn (fn [data] ;; Custom appender function to use stderr 20 | (let [{:keys [output-fn]} data 21 | formatted-output (output-fn data)] 22 | (binding [*out* *err*] ;; Redirect to stderr 23 | (println formatted-output))))}}})) 24 | 25 | (redirect-logs-to-stderr!) ; do we want this here? 26 | 27 | (defn format-tool-results 28 | "Format a tool result into the expected JSON-RPC text response format. 29 | Everything is text right now, but could be (TextContent | ImageContent | EmbeddedResource)." 30 | [results] 31 | (let [content-type "text"] ; supported content types are under schemacall-tool-result 32 | {:content (vec (for [result results] 33 | {:type content-type ; todo result types. just text or number basically. 34 | :text (str result)})) 35 | :isError false})) 36 | 37 | (defn format-tool-errors 38 | [errors] 39 | (-> (format-tool-results errors) 40 | (assoc :isError true))) 41 | 42 | (defn ->server 43 | "Returns a reified instance of AServer (an MCP Server), 44 | given tools, resources and prompts. Only tools are supported at this time." 45 | [{:keys [protocol-version 46 | name version initialize 47 | tools resources prompts 48 | on-receive 49 | on-send 50 | enqueue-notification] 51 | :or {protocol-version schema/latest-protocol-version 52 | initialize (fn [_init-params])}}] 53 | (reify AServer 54 | ; todo: add handle-message or handle-request 55 | (protocol-version [_this] protocol-version) 56 | 57 | (server-name [_this] name) 58 | (version [_this] version) 59 | 60 | (capabilities [_this] 61 | {:tools {:listChanged (boolean (seq tools))} 62 | :resources {:listChanged (boolean (seq resources))} 63 | :prompts {:listChanged (boolean (seq prompts))}}) 64 | 65 | ; For debugging: 66 | (on-receive [_this msg] 67 | (when on-receive (on-receive msg))) 68 | 69 | (on-send [_this msg] 70 | (when on-send (on-send msg))) 71 | 72 | (enqueue-notification [_this msg] 73 | (when enqueue-notification 74 | (enqueue-notification msg))) 75 | 76 | ; initialize is triggered by MCP client 'initialize' method request 77 | (initialize [_this init-params] ; init-params empty for now. 78 | (initialize init-params)) ; this can block. 79 | 80 | (list-tools [_this] 81 | (->> (vals tools) ; tools is a map. 82 | (mapv tools/tool->json-schema))) 83 | 84 | (list-resources [_this] []) ; not impl. 85 | (list-prompts [_this] []) ; not impl. 86 | 87 | (call-tool 88 | ; returns [?result ?error]. Considering switching to maps, even for tool responses. 89 | ; This maps the argument map to the tool handler's expected arity, should validate MAlli schema and invokes the tool. 90 | [_this tool-name arg-map] 91 | ;(log/debug "call-tool:" tool-name arg-map) 92 | 93 | (let [tool-key (keyword tool-name) ; can throw? 94 | tool (if tool-key (get tools tool-key))] ; is casting to keyword not a server impl. detail? 95 | ;(log/debug "tool:" tool) 96 | (if-not tool 97 | (throw (ex-info (str "Unknown tool: " (str tool-name)) 98 | ; invalid params also used for missing tool (weird). 99 | {:code schema/error-invalid-params 100 | :cause :tool.exception/missing-tool 101 | :tool/name tool-name})) 102 | (tools/invoke-tool tool arg-map)))))) 103 | 104 | (defn read-json-rpc-message 105 | "Reads a JSON value from reader and coerces map string keys to keywords. 106 | Returns parse error on JSON parse exception." 107 | [reader] 108 | (try 109 | (when-let [line (.readLine reader)] 110 | (log/debug "Received message:" line) 111 | (json/read-value line json/keyword-keys-object-mapper)) 112 | (catch java.io.IOException e 113 | (log/debug "IO error reading message:" (.getMessage e)) 114 | nil) ;; Return nil to exit the loop for any IO error 115 | (catch Exception e 116 | (log/debug "Error parsing message:" (.getMessage e)) 117 | (.printStackTrace e (java.io.PrintWriter. *err*)) 118 | {:error {:code schema/error-parse 119 | :message "Parse error"}}))) 120 | 121 | ; can move to JSON-RPC-specific namespace. 122 | (defn write-json-rpc! 123 | "Concurrency-safe JSON writes. Locks writer during .write & .flush. 124 | Returns string written on success, nil otherwise." 125 | [writer message] 126 | (try 127 | (let [json-str (json/write-value-as-string message json/keyword-keys-object-mapper)] 128 | (log/debug "Server Sending message:" json-str) 129 | (locking writer 130 | (.write writer (str json-str "\n")) 131 | (.flush writer)) 132 | json-str) 133 | (catch Exception e 134 | (log/debug "Error writing message:" (.getMessage e)) 135 | (.printStackTrace e (java.io.PrintWriter. *err*)) 136 | nil))) 137 | 138 | (defn handle-tool-call-request 139 | "Handles tools/call request and invokes tool. Returns JSON-RPC result or error. 140 | Errors need work." 141 | [server {:as _request :keys [id params]}] 142 | (log/debug "Handling tools/call request (ID " id ") with params:" (pr-str params)) 143 | (let [{tool-name :name 144 | arg-map :arguments} params] 145 | (try 146 | (let [{:keys [success results errors]} (mcp/call-tool server tool-name arg-map)] 147 | (if success 148 | (->> (format-tool-results results) 149 | (json-rpc/result id)) 150 | (do 151 | (log/debug "Tool Error:" errors) 152 | (->> (format-tool-errors errors) ; note that tool errors are reported via a normal JSON-RPC result, but with isError = true. 153 | (json-rpc/result id))))) 154 | (catch Exception ex ; unexpected (non-tool) exception: 155 | (log/debug "handle-tool-call-request exception:" ex) 156 | ; note that tool errors are reported via a normal JSON-RPC result, but with isError = true. 157 | (let [err-data (ex-data ex)] 158 | ; todo: hoist tool parameters up to request handler. 159 | ; note that tool errors are reported via a normal JSON-RPC result, but with isError = true. 160 | (json-rpc/error id {:code (:code err-data) ; is this always present? 161 | :message (ex-message ex)})))))) 162 | 163 | ;; Main request dispatcher 164 | (defn handle-request 165 | "Just a router for AServer. 166 | Each dispatch may return aa map (single message), or a collection of sequential messages." 167 | [mcp-server, {:as request :keys [id method params]} & [send-notification]] 168 | ; todo: move out JSON-RPC-specific results to a format handler. 169 | ;(log/debug "Dispatching request method:" method) 170 | (try 171 | (case method 172 | "ping" (do 173 | ; we deal with ping here not in server. todo: liveness checks. 174 | (log/debug "Handling ping request with id:" id) 175 | (json-rpc/result id {})) 176 | 177 | "tools/call" (handle-tool-call-request mcp-server request) ; todo: run invoke in future / thread. 178 | 179 | "initialize" (let [init-response {:protocolVersion (mcp/protocol-version mcp-server) 180 | :capabilities (mcp/capabilities mcp-server) ; calls above. 181 | :serverInfo {:name (mcp/server-name mcp-server) 182 | :version (mcp/version mcp-server)}}] 183 | (let [inited-notification (json-rpc/method "notifications/initialized")] 184 | (mcp/enqueue-notification mcp-server inited-notification) ; for testing w/o bus. 185 | (future 186 | (log/warn 'initialize) 187 | (try 188 | (mcp/initialize mcp-server params) 189 | ; todo: consider only init notifs if initialize returned true. 190 | ; this will move to an async bus. 191 | ; note that if initialize is fast, this can arrive before the init result. 192 | (send-notification inited-notification) 193 | (catch Exception ex 194 | (log/error "MCP Server initialize failed: " (ex-message ex)))))) ; too coupled. 195 | 196 | (json-rpc/result id init-response)) 197 | 198 | ;; Enumeration methods: 199 | "tools/list" (json-rpc/result id {:tools (mcp/list-tools mcp-server)}) 200 | "prompts/list" (do 201 | (log/debug "Handling prompts/list request with id:" id) 202 | (json-rpc/result id {:prompts (mcp/list-prompts mcp-server)})) 203 | "resources/list" (do 204 | (log/debug "Handling resources/list request with id:" id) 205 | (json-rpc/result id {:resources (mcp/list-resources mcp-server)})) 206 | (do 207 | (log/debug "Unknown method:" method) 208 | (json-rpc/error id {:code schema/error-method-not-found 209 | :message (str "Method not found: " method)}))) 210 | (catch Exception e 211 | (log/error "Error handling request:" (.getMessage e)) 212 | (json-rpc/error id {:code schema/error-internal 213 | :message (str "Internal error: " (.getMessage e))})))) 214 | 215 | (defn notification? 216 | "Notifications have method, but no id." 217 | [{:as _message :keys [method id]}] 218 | (and method (not id))) 219 | 220 | (defn handle-notification 221 | "We don't need to do anything special for notifications right now. 222 | Just log them and continue." 223 | [{:as _notification :keys [method]}] 224 | (log/debug "Received notification:" method) 225 | nil) 226 | 227 | (defn handle-message 228 | "Returns a JSON-RPC message." 229 | [server message send-notification] ; not loving send-message here. 230 | (try 231 | (log/debug "Handling message: " message) 232 | (cond 233 | ; Log errors: 234 | (:error message) 235 | (log/debug "Error message: " message) 236 | 237 | ;; Handle requests (have method & id) 238 | (and (:method message) (:id message)) 239 | (handle-request server message send-notification) 240 | 241 | ; Notification (has method, no id) 242 | (notification? message) 243 | (do 244 | (log/debug "Handling notification:" (:method message)) 245 | (handle-notification message)) 246 | 247 | ;; Unknown message type (we just log it) 248 | :else 249 | (do (log/warn "Unknown message type:" message) 250 | nil)) 251 | (catch Exception e 252 | ; this needs to be handled by router. 253 | (log/error "Critical error handling message:" (.getMessage e)) 254 | (.printStackTrace e (java.io.PrintWriter. *err*)) 255 | (if-let [id (:id message)] 256 | (json-rpc/error id {:code schema/error-internal 257 | :message (str "Internal error: " (.getMessage e))}) 258 | ;; No ID, can't send an error response 259 | nil)))) 260 | 261 | (defn start-server! 262 | "Main server loop – supports *in* & *out* bindings." 263 | ([server] (start-server! server *in* *out*)) 264 | ([server reader writer] 265 | (log/debug "Starting Modex MCP server...") 266 | (try 267 | (let [send-notification-handler (fn [message] (write-json-rpc! writer message))] 268 | (loop [] 269 | (log/debug "Waiting for request...") 270 | (let [message (read-json-rpc-message reader)] 271 | (mcp/on-receive server message) ; notify caller on rx (mainly for testing) 272 | 273 | (if-not message 274 | (do ; we exit here. 275 | (log/debug "Reader returned nil, client probably disconnected")) 276 | 277 | (do (future 278 | (let [?response (handle-message server message send-notification-handler)] 279 | (when ?response 280 | (log/debug "Responding with messages: " (pr-str ?response)) 281 | (mcp/on-send server ?response) ; tracking for send events. 282 | (write-json-rpc! writer ?response)))) ; this has locks. 283 | (recur)))))) 284 | 285 | (log/debug "Exiting.") 286 | (catch Exception e 287 | (log/error "Critical error in server:" (.getMessage e)) 288 | (.printStackTrace e (java.io.PrintWriter. *err*)))))) 289 | 290 | -------------------------------------------------------------------------------- /src/modex/mcp/tools.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.tools 2 | (:require [modex.mcp.schema :as schema] 3 | [clojure.string :as string] 4 | [modex.mcp.json-rpc :as json-rpc] 5 | [taoensso.timbre :as log])) 6 | 7 | (defrecord Parameter [name doc type required default]) 8 | 9 | ; todo: move to protocols 10 | (defprotocol ITool 11 | (required-args [this]) 12 | (input-schema [this])) 13 | 14 | (defn tool-arg->property 15 | [^Parameter tool-arg] 16 | (select-keys tool-arg [:type :doc :required])) 17 | 18 | (defn tool-args->input-schema [args] 19 | (into {} 20 | (for [tool-arg args] 21 | [(:name tool-arg) (tool-arg->property tool-arg)]))) 22 | 23 | (comment 24 | (tool-args->input-schema 25 | [(map->Parameter {:name :name 26 | :doc "Person's name" 27 | :type :string 28 | :required true}) 29 | (map->Parameter {:name :x 30 | :doc "Person's Age (optional)" 31 | :type :number 32 | :required false})])) 33 | 34 | (defrecord Tool [name doc args handler] 35 | ITool 36 | (required-args [this] (->> (filter :required args) 37 | (mapv :name))) 38 | (input-schema [^Tool this] 39 | {:type "object" ; object = map. 40 | :required (required-args this) ; strings? 41 | :properties (tool-args->input-schema args)})) 42 | 43 | (comment 44 | (let [tool (->Tool :foo "test" 45 | [(map->Parameter {:name :name 46 | :doc "Person's name" 47 | :type :string 48 | :required true})] 49 | (fn [name] (str "Hi there, " name "!")))] 50 | [(required-args tool) 51 | (input-schema tool)])) 52 | 53 | (defn missing-elements 54 | "Returns seq of elements in required that are not present in input." 55 | [required input] 56 | (remove (set input) (set required))) 57 | 58 | (comment 59 | (missing-elements #{:a :b} [:a])) 60 | 61 | (defn validate-arg-types 62 | "Validates argument types based on tool parameter definitions" 63 | [args arg-map] 64 | ; this could be Malli if we generate schema in macros. 65 | (let [type-errors (reduce (fn [errors arg] 66 | (let [arg-name (:name arg) 67 | arg-type (:type arg) 68 | arg-value (get arg-map arg-name)] 69 | (cond 70 | (nil? arg-value) errors ; Skip if nil (missing args checked elsewhere) 71 | (and (= :number arg-type) (not (number? arg-value))) 72 | (conj errors {:parameter arg-name 73 | :expected :number 74 | :got (type arg-value)}) 75 | (and (= :string arg-type) (not (string? arg-value))) 76 | (conj errors {:parameter arg-name 77 | :expected :string 78 | :got (type arg-value)}) 79 | (and (= :text arg-type) (not (string? arg-value))) 80 | (conj errors {:parameter arg-name 81 | :expected :text 82 | :got (type arg-value)}) 83 | :else errors))) 84 | [] args)] 85 | (when (seq type-errors) 86 | {:type-validation-errors type-errors}))) 87 | 88 | (defn missing-tool-args 89 | "Returns a seq of missing args or nil, for args with :required true." 90 | [tool-args arg-map] 91 | (let [required-args (filter #(true? (:required %)) tool-args) 92 | required-key-set (set (map :name required-args))] 93 | (missing-elements required-key-set (keys arg-map)))) 94 | 95 | (defn invoke-handler 96 | "Invokes tool handler & arg-map. 97 | WIP: validation is moving out of this. This will just return result straight up. 98 | Performs validation on arguments (missing required, types). 99 | Returns a map with :success and either :results or :errors." 100 | [handler arg-map] 101 | (try 102 | (handler arg-map) 103 | (catch Exception ex 104 | (log/error ex "Tool handler exception: " (ex-message ex)) 105 | (throw (ex-info (ex-message ex) 106 | (assoc (ex-data ex) :cause :tool/exception)))))) 107 | 108 | (defn invoke-tool 109 | "1. Validates tool input parameters (missing args or wrong types) 110 | - Missing params are reported as protocol-level errors via throw. 111 | 2. calls invoke-tool-handler, gathers result 112 | 3. Check success and returns result or error via {:keys [success results errors]}. Either: 113 | - `{:success true :results [...]}`, or 114 | - `{:success false :errors [...]}` 115 | 116 | Packages result." 117 | [^Tool {:as tool :keys [handler args]} 118 | arg-map] 119 | (let [required-args (filter #(true? (:required %)) args) 120 | required-key-set (set (map :name required-args)) 121 | missing-args (missing-elements required-key-set (keys arg-map)) 122 | type-errors (when (empty? missing-args) 123 | (validate-arg-types args arg-map))] 124 | (cond 125 | ;; Check for missing required arguments 126 | (seq missing-args) ; throw on protocol-level error. 127 | (throw (ex-info (str "Missing tool parameters: " (string/join ", " missing-args)) 128 | {:code schema/error-invalid-params ; ideally specify codes at server-level. 129 | :cause :tool.exception/missing-parameters 130 | :provided-args (keys arg-map) 131 | :required-args required-key-set})) 132 | 133 | ;; Validate argument types (tool-level error) 134 | (seq type-errors) 135 | {:success false 136 | :errors type-errors} 137 | 138 | ;; Validation passed, invoke handler: 139 | :else 140 | (try 141 | (let [results (invoke-handler handler arg-map)] ; can throw 142 | {:success true 143 | :results results}) 144 | (catch Exception ex 145 | (log/error ex "Exception during tool handler invocation for" (:name tool) ": " (ex-message ex)) 146 | {:success false 147 | :errors [(ex-message ex)]}))))) 148 | ;(throw (ex-info (ex-message ex) (assoc (ex-data ex) :cause :tool/exception)))))))) 149 | 150 | (comment 151 | (let [tool (->Tool :foo "test" 152 | [(map->Parameter {:name :name 153 | :doc "Person's name" 154 | :type :string 155 | :required true})] 156 | (fn [name] (str "Hi there, " name "!")))] 157 | (invoke-handler tool {:name "Petrus"}))) 158 | 159 | (def mcp-meta-keys [:type :doc :required]) 160 | 161 | (defn extract-mcp-meta 162 | "Extracts MCP tool metadata from a {:keys [...]} map that has :type, :doc & :required keys. 163 | Called by tool handler macros." 164 | [m] 165 | {:pre [(map? m)]} 166 | (select-keys m mcp-meta-keys)) 167 | 168 | (defn argmap 169 | "Takes a {:keys [...]} argument map like fn but extracts :type, :doc & :required to metadata. 170 | Called by tool handler macros." 171 | [m] 172 | {:pre [(map? m)]} 173 | (let [mcp-meta (select-keys m mcp-meta-keys)] 174 | (with-meta (apply dissoc m mcp-meta-keys) mcp-meta))) 175 | 176 | (defmacro handler 177 | "Like fn but returns a fn with :mcp metadata, extracted from a single map argument for MCP Tool construction. 178 | Call meta on fn to see :mcp keys extracted from the :keys destructuring. 179 | Refer to tool-macro-tests." 180 | ; todo use Malli schema for :type validation. 181 | [[map-arg] & body] 182 | {:pre [(map? map-arg)]} 183 | (let [mcp-meta (extract-mcp-meta map-arg) 184 | ;; Create a clean destructuring map for the let binding 185 | let-map-arg# (apply dissoc map-arg mcp-meta-keys) 186 | map-sym# (gensym "arg-map-")] ; Generate a unique symbol for the map 187 | `(do (assert (every? #{:string :number} (vals (:type '~mcp-meta))) ":type must be one of :number or :string.") 188 | (with-meta (fn [~map-sym#] ; Fn takes a single map argument 189 | (let [~let-map-arg# ~map-sym#] ; Use clean map for let destructuring 190 | ~@body)) 191 | {:mcp '~mcp-meta})))) 192 | 193 | (defmacro tool-v1 194 | "Deprecated. Superseded by tool-v2-argmap, but still supported via tool." 195 | [[tool-name & tool-body]] 196 | (let [tool-key# (keyword tool-name) 197 | 198 | ;; Handle optional docstring 199 | [docstring# rest-body#] (if (string? (first tool-body)) 200 | [(first tool-body) (rest tool-body)] 201 | [(str tool-name) tool-body]) 202 | 203 | ;; Get args vector and function body 204 | args-vec# (first rest-body#) 205 | fn-body# (rest rest-body#) 206 | 207 | ;; Generate a sequence of keywords from arg names 208 | arg-keywords# (mapv (fn [arg] `(keyword '~arg)) args-vec#)] 209 | 210 | ;; Return a quasiquoted form that will be evaluated at runtime 211 | `(let [arg-info# (vec (for [arg# '~args-vec#] 212 | (let [m# (meta arg#)] 213 | (map->Parameter 214 | {:name (keyword arg#) 215 | :doc (get m# :doc (str arg#)) 216 | :type (get m# :type :string) 217 | :required (get m# :required true)})))) 218 | 219 | ;; Runtime conversion function for v1 tools 220 | v1-to-map-handler# (fn [arg-map#] 221 | ;; Extract values at runtime using arg keywords 222 | (let [arg-values# (map #(get arg-map# %) ~arg-keywords#)] 223 | ;; Apply the original function to the extracted values 224 | (apply 225 | (fn ~args-vec# ~@fn-body#) 226 | arg-values#)))] 227 | (->Tool ~tool-key# ~docstring# arg-info# v1-to-map-handler#)))) 228 | 229 | (defmacro tool-v2-argmap 230 | "Supersedes tool-v1. Expects map of handler args. 231 | 232 | Usage: 233 | (tool-v2-argmap 234 | (greet [{:keys [name age] 235 | :doc {name \"A person's name.\"} 236 | :type {name :string 237 | age :number} 238 | :or {age 37}] ; presence in :or implies optional arg. 239 | (str \"Hi \" name \"! You are \" age \" years old :)\"))" 240 | [[tool-name & tool-body]] 241 | (let [tool-key# (keyword tool-name) 242 | 243 | ;; Handle optional docstring 244 | [docstring# rest-body#] (if (string? (first tool-body)) 245 | [(first tool-body) (rest tool-body)] 246 | [(str tool-name) tool-body]) 247 | 248 | ;; Get args vector and function body 249 | args-vec# (first rest-body#) 250 | fn-body# (rest rest-body#) 251 | 252 | ;; Extract the map from the vector (assuming args-vec# is a vector with a single map) 253 | args-map# (first args-vec#) 254 | 255 | ;; Extract the keys vector from the map 256 | keys-vec# (get args-map# :keys []) 257 | 258 | ;; Extract metadata maps at compile time 259 | type-map# (get args-map# :type {}) 260 | doc-map# (get args-map# :doc {}) 261 | or-map# (get args-map# :or {}) 262 | or-keyset# (set (keys or-map#))] ; not required if key present in :or, even if nil. 263 | 264 | ;; Return a Tool instance with a handler function and parameter info 265 | `(let [arg-info# (vec (for [k# '~keys-vec#] 266 | (map->Parameter 267 | (cond-> {:name (keyword k#) 268 | :doc (get '~doc-map# k# (str k#)) 269 | :type (get '~type-map# k# :string) 270 | :required (let [has-default?# (get '~or-keyset# k#)] 271 | (boolean (not has-default?#))) 272 | :default (get '~or-map# k#)})))) 273 | 274 | handler-fn# (handler ~args-vec# ~@fn-body#)] 275 | (->Tool ~tool-key# ~docstring# arg-info# handler-fn#)))) 276 | 277 | ;; 'tool' macro dispatches to either tool1 or tool2 278 | (defmacro tool [[tool-name & tool-body]] 279 | (let [;; Handle optional docstring 280 | [_docstring# rest-body#] (if (string? (first tool-body)) 281 | [(first tool-body) (rest tool-body)] 282 | [(str tool-name) tool-body]) 283 | args# (first rest-body#)] 284 | ;; v2 uses map. v1 parses vector w/metadata. 285 | (if (map? (first args#)) 286 | `(tool-v2-argmap [~tool-name ~@tool-body]) 287 | `(tool-v1 [~tool-name ~@tool-body])))) 288 | 289 | (defmacro tools 290 | "Returns a map of tool name => Tool. 291 | 292 | Syntax like defrecord: 293 | ``` 294 | (tools 295 | (add \"Adds two numbers, a & b.\" 296 | [{:keys [a b] 297 | :type {a :number 298 | b :number}} 299 | (+ a b)]) 300 | (subtract [x y] (- x y))) 301 | ```" 302 | [& tool-defs] 303 | `(let [tools# (vector ~@(map (fn [tool-def] `(tool ~tool-def)) tool-defs)) ; there is better way 304 | tool-map# (into {} (for [tool# tools#] 305 | [(:name tool#) tool#]))] 306 | tool-map#)) 307 | 308 | (comment 309 | (tools 310 | (add [a b] (+ a b))) 311 | 312 | (macroexpand '(tools 313 | (add [a b] (+ a b))))) 314 | 315 | (defmacro deftools 316 | "Just calls tools and binds to a symbol via def. Of dubious merit." 317 | [name & tool-defs] 318 | `(def ~name (tools ~@tool-defs))) 319 | 320 | (defn tool->json-schema 321 | "Builds MCP-compatible {:keys [name description inputSchema]}, 322 | where inputSchema has {:keys [type required properties]}, 323 | where properties has each tool, and required is list of arg names." 324 | [^Tool 325 | {:as tool :keys [name doc]}] 326 | {:name name 327 | :description doc 328 | :inputSchema (input-schema tool)}) -------------------------------------------------------------------------------- /src/modex/mcp/schema.clj: -------------------------------------------------------------------------------- 1 | (ns modex.mcp.schema 2 | (:require [malli.core :as m])) 3 | 4 | ;;; Constants 5 | (def latest-protocol-version "2024-11-05") 6 | (def json-rpc-version "2.0") 7 | 8 | ;; Standard JSON-RPC error codes 9 | (def error-parse -32700) 10 | (def error-invalid-request -32600) 11 | (def error-method-not-found -32601) 12 | (def error-invalid-params -32602) 13 | (def error-internal -32603) 14 | 15 | ;;; Basic Types 16 | ;; A progress token used to associate progress notifications with the original request 17 | (def progress-token [:or string? number?]) 18 | 19 | ;; An opaque token used to represent a cursor for pagination 20 | (def cursor string?) 21 | 22 | ;; A uniquely identifying ID for a request in JSON-RPC 23 | (def request-id [:or string? number?]) 24 | 25 | ;; Role for messages and data in a conversation 26 | (def role [:enum "user" "assistant"]) 27 | 28 | ;; Logging levels 29 | (def logging-level 30 | [:enum "debug" "info" "notice" "warning" "error" "critical" "alert" "emergency"]) 31 | 32 | ;;; JSON-RPC Message Types 33 | (def request 34 | [:map 35 | [:method string?] 36 | [:params {:optional true} 37 | [:map 38 | [:_meta {:optional true} 39 | [:map 40 | [:progressToken {:optional true} progress-token] 41 | [:* [:map-of string? any?]]]] 42 | [:* [:map-of string? any?]]]]]) 43 | 44 | (def notification 45 | [:map 46 | [:method string?] 47 | [:params {:optional true} 48 | [:map 49 | [:_meta {:optional true} [:map-of string? any?]] 50 | [:* [:map-of string? any?]]]]]) 51 | 52 | (def result 53 | [:map 54 | [:_meta {:optional true} [:map-of string? any?]] 55 | [:* [:map-of string? any?]]]) 56 | 57 | (def jsonrpc-request 58 | [:map 59 | [:jsonrpc [:= json-rpc-version]] 60 | [:id request-id] 61 | [:method string?] 62 | [:params {:optional true} [:map]]]) 63 | 64 | (def jsonrpc-notification 65 | [:map 66 | [:jsonrpc [:= json-rpc-version]] 67 | [:method string?] 68 | [:params {:optional true} [:map]]]) 69 | 70 | (def jsonrpc-response 71 | [:map 72 | [:jsonrpc [:= json-rpc-version]] 73 | [:id request-id] 74 | [:result result]]) 75 | 76 | (def jsonrpc-error 77 | [:map 78 | [:jsonrpc [:= json-rpc-version]] 79 | [:id request-id] 80 | [:error 81 | [:map 82 | [:code number?] 83 | [:message string?] 84 | [:data {:optional true} any?]]]]) 85 | 86 | (def jsonrpc-message 87 | [:or 88 | jsonrpc-request 89 | jsonrpc-notification 90 | jsonrpc-response 91 | jsonrpc-error]) 92 | 93 | ;;; Empty Result 94 | (def empty-result result) 95 | 96 | ;;; Cancellation 97 | (def cancelled-notification 98 | [:map 99 | [:method [:= "notifications/cancelled"]] 100 | [:params 101 | [:map 102 | [:requestId request-id] 103 | [:reason {:optional true} string?]]]]) 104 | 105 | ;;; Implementation Info 106 | (def implementation 107 | [:map 108 | [:name string?] 109 | [:version string?]]) 110 | 111 | ;;; Initialization 112 | (def client-capabilities 113 | [:map 114 | [:experimental {:optional true} [:map-of string? map?]] 115 | [:roots {:optional true} 116 | [:map 117 | [:listChanged {:optional true} boolean?]]] 118 | [:sampling {:optional true} map?]]) 119 | 120 | (def server-capabilities 121 | [:map 122 | [:experimental {:optional true} [:map-of string? map?]] 123 | [:logging {:optional true} map?] 124 | [:prompts {:optional true} 125 | [:map 126 | [:listChanged {:optional true} boolean?]]] 127 | [:resources {:optional true} 128 | [:map 129 | [:subscribe {:optional true} boolean?] 130 | [:listChanged {:optional true} boolean?]]] 131 | [:tools {:optional true} 132 | [:map 133 | [:listChanged {:optional true} boolean?]]]]) 134 | 135 | (def initialize-request 136 | [:map 137 | [:method [:= "initialize"]] 138 | [:params 139 | [:map 140 | [:protocolVersion string?] 141 | [:capabilities client-capabilities] 142 | [:clientInfo implementation]]]]) 143 | 144 | (def initialize-result 145 | [:map 146 | [:protocolVersion string?] 147 | [:capabilities server-capabilities] 148 | [:serverInfo implementation] 149 | [:instructions {:optional true} string?]]) 150 | 151 | (def initialized-notification 152 | [:map 153 | [:method [:= "notifications/initialized"]]]) 154 | 155 | ;;; Ping 156 | (def ping-request 157 | [:map 158 | [:method [:= "ping"]]]) 159 | 160 | ;;; Progress Notifications 161 | (def progress-notification 162 | [:map 163 | [:method [:= "notifications/progress"]] 164 | [:params 165 | [:map 166 | [:progressToken progress-token] 167 | [:progress number?] 168 | [:total {:optional true} number?]]]]) 169 | 170 | ;;; Pagination 171 | (def paginated-request 172 | [:map 173 | [:method string?] 174 | [:params {:optional true} 175 | [:map 176 | [:cursor {:optional true} cursor]]]]) 177 | 178 | (def paginated-result 179 | [:map 180 | [:nextCursor {:optional true} cursor]]) 181 | 182 | ;;; Annotated Base 183 | (def annotated 184 | [:map 185 | [:annotations {:optional true} 186 | [:map 187 | [:audience {:optional true} [:vector role]] 188 | [:priority {:optional true} [:and number? [:>= 0] [:<= 1]]]]]]) 189 | 190 | ;;; Content Types 191 | ;;; note: tool string parameters use 'string', not 'text'. todo schema checks. 192 | (def text-content 193 | [:merge 194 | annotated 195 | [:map 196 | [:type [:= "text"]] 197 | [:text string?]]]) 198 | 199 | (def image-content 200 | [:merge 201 | annotated 202 | [:map 203 | [:type [:= "image"]] 204 | [:data string?] 205 | [:mimeType string?]]]) 206 | 207 | ;;; Resources 208 | (def resource 209 | [:merge 210 | annotated 211 | [:map 212 | [:uri string?] 213 | [:name string?] 214 | [:description {:optional true} string?] 215 | [:mimeType {:optional true} string?] 216 | [:size {:optional true} number?]]]) 217 | 218 | (def resource-template 219 | [:merge 220 | annotated 221 | [:map 222 | [:uriTemplate string?] 223 | [:name string?] 224 | [:description {:optional true} string?] 225 | [:mimeType {:optional true} string?]]]) 226 | 227 | (def list-resources-request 228 | [:map 229 | [:method [:= "resources/list"]] 230 | [:params {:optional true} 231 | [:map 232 | [:cursor {:optional true} cursor]]]]) 233 | 234 | (def list-resources-result 235 | [:merge 236 | paginated-result 237 | [:map 238 | [:resources [:vector resource]]]]) 239 | 240 | (def list-resource-templates-request 241 | [:map 242 | [:method [:= "resources/templates/list"]] 243 | [:params {:optional true} 244 | [:map 245 | [:cursor {:optional true} cursor]]]]) 246 | 247 | (def list-resource-templates-result 248 | [:merge 249 | paginated-result 250 | [:map 251 | [:resourceTemplates [:vector resource-template]]]]) 252 | 253 | (def resource-contents 254 | [:map 255 | [:uri string?] 256 | [:mimeType {:optional true} string?]]) 257 | 258 | (def text-resource-contents 259 | [:merge 260 | resource-contents 261 | [:map 262 | [:text string?]]]) 263 | 264 | (def blob-resource-contents 265 | [:merge 266 | resource-contents 267 | [:map 268 | [:blob string?]]]) 269 | 270 | (def embedded-resource 271 | [:merge 272 | annotated 273 | [:map 274 | [:type [:= "resource"]] 275 | [:resource [:or text-resource-contents blob-resource-contents]]]]) 276 | 277 | (def read-resource-request 278 | [:map 279 | [:method [:= "resources/read"]] 280 | [:params 281 | [:map 282 | [:uri string?]]]]) 283 | 284 | (def read-resource-result 285 | [:map 286 | [:contents [:vector [:or text-resource-contents blob-resource-contents]]]]) 287 | 288 | (def resource-list-changed-notification 289 | [:map 290 | [:method [:= "notifications/resources/list_changed"]]]) 291 | 292 | (def subscribe-request 293 | [:map 294 | [:method [:= "resources/subscribe"]] 295 | [:params 296 | [:map 297 | [:uri string?]]]]) 298 | 299 | (def unsubscribe-request 300 | [:map 301 | [:method [:= "resources/unsubscribe"]] 302 | [:params 303 | [:map 304 | [:uri string?]]]]) 305 | 306 | (def resource-updated-notification 307 | [:map 308 | [:method [:= "notifications/resources/updated"]] 309 | [:params 310 | [:map 311 | [:uri string?]]]]) 312 | 313 | ;;; Prompts 314 | (def prompt-message 315 | [:map 316 | [:role role] 317 | [:content [:or text-content image-content embedded-resource]]]) 318 | 319 | (def prompt-argument 320 | [:map 321 | [:name string?] 322 | [:description {:optional true} string?] 323 | [:required {:optional true} boolean?]]) 324 | 325 | (def prompt 326 | [:map 327 | [:name string?] 328 | [:description {:optional true} string?] 329 | [:arguments {:optional true} [:vector prompt-argument]]]) 330 | 331 | (def list-prompts-request 332 | [:map 333 | [:method [:= "prompts/list"]] 334 | [:params {:optional true} 335 | [:map 336 | [:cursor {:optional true} cursor]]]]) 337 | 338 | (def list-prompts-result 339 | [:merge 340 | paginated-result 341 | [:map 342 | [:prompts [:vector prompt]]]]) 343 | 344 | (def get-prompt-request 345 | [:map 346 | [:method [:= "prompts/get"]] 347 | [:params 348 | [:map 349 | [:name string?] 350 | [:arguments {:optional true} [:map-of string? string?]]]]]) 351 | 352 | (def get-prompt-result 353 | [:map 354 | [:description {:optional true} string?] 355 | [:messages [:vector prompt-message]]]) 356 | 357 | (def prompt-list-changed-notification 358 | [:map 359 | [:method [:= "notifications/prompts/list_changed"]]]) 360 | 361 | ;;; Tools 362 | (def tool 363 | [:map 364 | [:name string?] 365 | [:description {:optional true} string?] 366 | [:inputSchema 367 | [:map 368 | [:type [:= "object"]] 369 | [:properties {:optional true} [:map-of string? map?]] ; need more detail here. 370 | [:required {:optional true} [:vector string?]]]]]) 371 | 372 | (def list-tools-request 373 | [:map 374 | [:method [:= "tools/list"]] 375 | [:params {:optional true} 376 | [:map 377 | [:cursor {:optional true} cursor]]]]) 378 | 379 | (def list-tools-result 380 | [:merge 381 | paginated-result 382 | [:map 383 | [:tools [:vector tool]]]]) 384 | 385 | (def call-tool-request 386 | [:map 387 | [:method [:= "tools/call"]] 388 | [:params 389 | [:map 390 | [:name string?] 391 | [:arguments {:optional true} [:map-of string? any?]]]]]) 392 | 393 | (def call-tool-result 394 | [:map 395 | [:content [:vector [:or text-content image-content embedded-resource]]] 396 | [:isError {:optional true} boolean?]]) 397 | 398 | (def tool-list-changed-notification 399 | [:map 400 | [:method [:= "notifications/tools/list_changed"]]]) 401 | 402 | ;;; Logging 403 | (def set-level-request 404 | [:map 405 | [:method [:= "logging/setLevel"]] 406 | [:params 407 | [:map 408 | [:level logging-level]]]]) 409 | 410 | (def logging-message-notification 411 | [:map 412 | [:method [:= "notifications/message"]] 413 | [:params 414 | [:map 415 | [:level logging-level] 416 | [:logger {:optional true} string?] 417 | [:data any?]]]]) 418 | 419 | ;;; Sampling 420 | (def sampling-message 421 | [:map 422 | [:role role] 423 | [:content [:or text-content image-content]]]) 424 | 425 | (def model-hint 426 | [:map 427 | [:name {:optional true} string?]]) 428 | 429 | (def model-preferences 430 | [:map 431 | [:hints {:optional true} [:vector model-hint]] 432 | [:costPriority {:optional true} [:and number? [:>= 0] [:<= 1]]] 433 | [:speedPriority {:optional true} [:and number? [:>= 0] [:<= 1]]] 434 | [:intelligencePriority {:optional true} [:and number? [:>= 0] [:<= 1]]]]) 435 | 436 | (def create-message-request 437 | [:map 438 | [:method [:= "sampling/createMessage"]] 439 | [:params 440 | [:map 441 | [:messages [:vector sampling-message]] 442 | [:modelPreferences {:optional true} model-preferences] 443 | [:systemPrompt {:optional true} string?] 444 | [:includeContext {:optional true} [:enum "none" "thisServer" "allServers"]] 445 | [:temperature {:optional true} number?] 446 | [:maxTokens number?] 447 | [:stopSequences {:optional true} [:vector string?]] 448 | [:metadata {:optional true} map?]]]]) 449 | 450 | (def create-message-result 451 | [:merge 452 | sampling-message 453 | [:map 454 | [:model string?] 455 | [:stopReason {:optional true} [:or [:enum "endTurn" "stopSequence" "maxTokens"] string?]]]]) 456 | 457 | ;;; Autocomplete 458 | (def prompt-reference 459 | [:map 460 | [:type [:= "ref/prompt"]] 461 | [:name string?]]) 462 | 463 | (def resource-reference 464 | [:map 465 | [:type [:= "ref/resource"]] 466 | [:uri string?]]) 467 | 468 | (def complete-request 469 | [:map 470 | [:method [:= "completion/complete"]] 471 | [:params 472 | [:map 473 | [:ref [:or prompt-reference resource-reference]] 474 | [:argument 475 | [:map 476 | [:name string?] 477 | [:value string?]]]]]]) 478 | 479 | (def complete-result 480 | [:map 481 | [:completion 482 | [:map 483 | [:values [:vector string?]] 484 | [:total {:optional true} number?] 485 | [:hasMore {:optional true} boolean?]]]]) 486 | 487 | ;;; Roots 488 | (def root 489 | [:map 490 | [:uri string?] 491 | [:name {:optional true} string?]]) 492 | 493 | (def list-roots-request 494 | [:map 495 | [:method [:= "roots/list"]]]) 496 | 497 | (def list-roots-result 498 | [:map 499 | [:roots [:vector root]]]) 500 | 501 | (def roots-list-changed-notification 502 | [:map 503 | [:method [:= "notifications/roots/list_changed"]]]) 504 | 505 | ;;; Client Messages 506 | (def client-request 507 | [:or 508 | ping-request 509 | initialize-request 510 | complete-request 511 | set-level-request 512 | get-prompt-request 513 | list-prompts-request 514 | list-resources-request 515 | list-resource-templates-request 516 | read-resource-request 517 | subscribe-request 518 | unsubscribe-request 519 | call-tool-request 520 | list-tools-request]) 521 | 522 | (def client-notification 523 | [:or 524 | cancelled-notification 525 | progress-notification 526 | initialized-notification 527 | roots-list-changed-notification]) 528 | 529 | (def client-result 530 | [:or 531 | empty-result 532 | create-message-result 533 | list-roots-result]) 534 | 535 | ;;; Server Messages 536 | (def server-request 537 | [:or 538 | ping-request 539 | create-message-request 540 | list-roots-request]) 541 | 542 | (def server-notification 543 | [:or 544 | cancelled-notification 545 | progress-notification 546 | logging-message-notification 547 | resource-updated-notification 548 | resource-list-changed-notification 549 | tool-list-changed-notification 550 | prompt-list-changed-notification]) 551 | 552 | (def server-result 553 | [:or 554 | empty-result 555 | initialize-result 556 | complete-result 557 | get-prompt-result 558 | list-prompts-result 559 | list-resources-result 560 | list-resource-templates-result 561 | read-resource-result 562 | call-tool-result 563 | list-tools-result]) --------------------------------------------------------------------------------