|
1 | 1 | (ns mcp.client
|
2 | 2 | (:require
|
| 3 | + [babashka.fs :as fs] |
3 | 4 | [cheshire.core :as json]
|
4 | 5 | [clojure.core.async :as async]
|
| 6 | + [clojure.edn :as edn] |
5 | 7 | [docker]
|
6 |
| - [jsonrpc.logger :as logger])) |
| 8 | + [flatland.ordered.map :refer [ordered-map]] |
| 9 | + [jsonrpc.logger :as logger] |
| 10 | + [prompts.core :refer [get-prompts-dir]] |
| 11 | + repl)) |
7 | 12 |
|
8 | 13 | (def counter (atom 0))
|
9 | 14 |
|
10 |
| -(defn- mcp-stdio-stateless-server [container] |
| 15 | +(defn- mcp-stdio-stateless-server |
| 16 | + "create a running container with an attached socket-client |
| 17 | + and a running Thread that is reading messages from the socket (both stdout and stderr) |
| 18 | + - there is also one go process blocked waiting for the container to exit |
| 19 | + |
| 20 | + returns |
| 21 | + a map with the container info plus request and notification functions |
| 22 | + to write jsonrpc messages to the socket, |
| 23 | + and a dead-channel channel that will emit :stopped or :closed when the container stops for any reason |
| 24 | + the request function will return a promise channel for the matching response |
| 25 | + the notification function is just a side-effect |
| 26 | + " |
| 27 | + [container] |
11 | 28 | (docker/check-then-pull container)
|
12 | 29 | (let [x (docker/create (assoc container
|
13 | 30 | :opts {:StdinOnce true
|
|
41 | 58 |
|
42 | 59 | ;; real stdout message
|
43 | 60 | (and block (:stdout block))
|
44 |
| - |
45 | 61 | (let [message (try (json/parse-string (:stdout block) keyword) (catch Throwable _))]
|
46 | 62 | (if-let [p (get @response-promises (:id message))]
|
47 | 63 | (async/put! p message)
|
|
50 | 66 | c ([v _] v)
|
51 | 67 | (async/timeout 15000) :timeout)))
|
52 | 68 |
|
53 |
| -;; channel is closed |
| 69 | + ;; channel is closed |
54 | 70 | (nil? block)
|
55 |
| - (do |
56 |
| - (logger/info "channel closed") |
57 |
| - (async/put! dead-channel :closed)) |
| 71 | + (async/put! dead-channel :closed) |
58 | 72 |
|
59 | 73 | ;; non-stdout message probably
|
60 | 74 | :else
|
|
78 | 92 | :notification
|
79 | 93 | (fn [message]
|
80 | 94 | (try
|
81 |
| - (docker/write-to-stdin socket-channel (str (json/generate-string (assoc message :jsonrpc "2.0")) "\n\n")) |
82 |
| - (catch Throwable t |
83 |
| - (println "error closing " t)))) |
| 95 | + (docker/write-to-stdin socket-channel (str (json/generate-string (assoc message :jsonrpc "2.0")) "\n\n")) |
| 96 | + (catch Throwable t |
| 97 | + (println "error closing " t)))) |
84 | 98 | :dead-channel dead-channel)))))
|
85 | 99 |
|
86 | 100 | (defn with-running-mcp
|
87 |
| - "send a message to an mcp servers and then shut it down |
| 101 | + "send a message to an mcp server and then shuts it down |
88 | 102 | params
|
89 | 103 | container-definition - for the mcp server
|
90 | 104 | f - function to generate a jsonrpc request to send post initialize
|
|
111 | 125 | (request (f)) ([v _] v)
|
112 | 126 | dead-channel ([v _] v)
|
113 | 127 | (async/timeout 15000) :timeout)]
|
114 |
| - (logger/debug (format "%s response %s" (:image container-definition) response)) |
115 | 128 | (f1 response)))
|
116 | 129 | (do
|
117 | 130 | (logger/error (format
|
|
155 | 168 | {:name "create_customer"
|
156 | 169 | :arguments {:name "Jim Clark"}})))
|
157 | 170 |
|
| 171 | +(defn -get-tools [container-definition] |
| 172 | + (async/<!! |
| 173 | + (with-running-mcp |
| 174 | + (docker/inject-secret-transform container-definition) |
| 175 | + (fn [] {:method "tools/list" :params {}}) |
| 176 | + (fn [response] |
| 177 | + (->> (-> response :result :tools) |
| 178 | + (map #(assoc % :container (assoc container-definition :type :mcp))) |
| 179 | + (into [])))))) |
| 180 | + |
| 181 | +(defn mcp-metadata-cache-file [] (fs/file (get-prompts-dir) "mcp-metadata-cache.edn")) |
| 182 | +(def mcp-metadata-cache (atom {})) |
| 183 | +(def cache-channel (async/chan)) |
| 184 | +(defn initialize-cache [] |
| 185 | + (swap! mcp-metadata-cache (constantly |
| 186 | + (try |
| 187 | + (edn/read-string |
| 188 | + {:readers {'ordered/map (fn [pairs] (into (ordered-map pairs)))}} |
| 189 | + (slurp (mcp-metadata-cache-file))) |
| 190 | + (catch Throwable e |
| 191 | + (logger/error "error initializing cache " e) |
| 192 | + {}))))) |
| 193 | + |
| 194 | +(async/go-loop [] |
| 195 | + (let [[k v] (async/<! cache-channel) |
| 196 | + updated-cache (swap! mcp-metadata-cache assoc k v)] |
| 197 | + (spit |
| 198 | + (mcp-metadata-cache-file) |
| 199 | + (pr-str updated-cache)) |
| 200 | + (recur))) |
| 201 | + |
| 202 | +(defn inspect-image [container-definition] |
| 203 | + (:Id |
| 204 | + (docker/image-inspect |
| 205 | + (-> (docker/images {"reference" [(:image container-definition)]}) |
| 206 | + first)))) |
| 207 | + |
| 208 | +(defn add-digest [container-definition] |
| 209 | + (docker/check-then-pull container-definition) |
| 210 | + (assoc container-definition :digest (inspect-image container-definition))) |
| 211 | + |
| 212 | +(def cached-mcp-get-tools |
| 213 | + (memoize (fn [{:keys [digest] :as container-definition}] |
| 214 | + (if-let [m (get @mcp-metadata-cache digest)] |
| 215 | + m |
| 216 | + (let [m (-get-tools container-definition)] |
| 217 | + (async/>!! cache-channel [digest m]) |
| 218 | + m))))) |
| 219 | + |
158 | 220 | (defn get-tools [container-definition]
|
159 |
| - (with-running-mcp |
160 |
| - (docker/inject-secret-transform container-definition) |
161 |
| - (fn [] {:method "tools/list" :params {}}) |
162 |
| - (fn [response] |
163 |
| - (->> (-> response :result :tools) |
164 |
| - (map #(assoc % :container (assoc container-definition :type :mcp))) |
165 |
| - (into []))))) |
| 221 | + (cached-mcp-get-tools |
| 222 | + (add-digest container-definition))) |
166 | 223 |
|
167 | 224 | (defn get-mcp-tools-from-prompt
|
168 | 225 | [coll]
|
169 | 226 | (->> coll
|
170 |
| - (mapcat (comp async/<!! get-tools :container)) |
| 227 | + (mapcat (comp get-tools :container)) |
171 | 228 | (map (fn [tool]
|
172 | 229 | {:type "function"
|
173 | 230 | :function (-> tool
|
|
176 | 233 | (into [])))
|
177 | 234 |
|
178 | 235 | (comment
|
| 236 | + (repl/setup-stdout-logger) |
179 | 237 | (docker/inject-secret-transform {:image "mcp/stripe:latest"
|
180 | 238 | :secrets {:stripe.api_key "API_KEY"}
|
181 | 239 | :command ["--tools=all"
|
|
0 commit comments