Code Listing - Multiple MCP Servers

core.clj

(ns agent.core
  (:require
   [cheshire.core :as json]
   [clojure.edn :as edn]
   [clojure.java.io :as io]
   [clojure.pprint :as pprint]
   [clojure.string :as str]
   [wkok.openai-clojure.api :as openai]
   [agent.tools]
   [com.bunimo.clansi :as clansi]
   [agent.mcpclient :as mcpclient]
   [cheshire.core :as cheshire]))

(defn- label
  [text & attrs]
  (print (apply clansi/style text attrs) ": "))

(defn- read-user-input!
  []
  (label "You" :blue)
  (flush)
  (when-let [raw (read-line)]
    (let [message (str/trim raw)]
      (when-not (or (str/blank? message) (= message "quit"))
        {:role "user" :content message}))))

(defn-
  dbg-print
  [args]
  (label "Debug" :bright :black)
  (println args))

(defn- display-assistant-response!
  [content]
  (label "LLM" :yellow)
  (println content))

(defn- call-llm-api
  [messages config tools]
  (try
    (openai/create-chat-completion {:model (:model config)
                                    :messages messages
                                    :tools tools
                                    :tool_choice "auto"}
                                   (select-keys config [:api-key :api-endpoint :impl]))
    (catch Exception e
      (throw (ex-info "LLM API call failed" {:cause (.getMessage e)
                                             :messages messages}
                      e)))))

(defn- extract-first-response
  "The LLM call can return multiple responses. Extract the first one and print a warning if there are more than one responses"
  [response]
  (let [choices (:choices response)
        responses (mapv :message choices)]
    (when-not (= 1 (count responses))
      (pprint/pprint {:warning "Multiple responses received" :responses responses}))
    (first responses)))

(defn- add-message-to-history
  ([history message]
   (conj history message)))

(defn- parse-json-arguments
  [args]
  (json/parse-string args true))

(defn- invoke-tool [tools tc]
  (let [name (get-in tc [:function :name])
        tool-fn (get tools name)
        args (get-in tc [:function :arguments])
        parsed-args (parse-json-arguments args)]
    (label "Tool" :green)
    (println name)
    (pprint/pprint parsed-args)
    (if (some? tool-fn)
      (tool-fn args)
      (dbg-print (str "Unknown tool" name)))))

(defn- encode-tool-response
  [response]
  (str response))

(defn handle-tool-call
  [response tools]
  (let [tool-calls (:tool_calls response)]
    (mapv (fn [tc]
            {:tool_call_id (:id tc)
             :content (encode-tool-response (invoke-tool tools tc))
             :role "tool"}) tool-calls)))

(defn- read-config!
  []
  (with-open [r (io/reader "llm.edn")]
    (edn/read {:eof nil} (java.io.PushbackReader. r))))

(defn- get-tool-list
  "Discovers all functions in provided namespace ns with :tool metadata"
  [ns]
  (->> (ns-publics ns)
       vals
       (filter #(:tool (meta %)))
       (mapv #(hash-map :type "function" :function (:tool (meta %))))))

(defn build-tool-registry
  "Builds a map of tool names to their corresponding functions"
  [ns]
  (->> (ns-publics ns)
       vals
       (filter #(:tool (meta %)))
       (reduce (fn [acc f]
                 (let [name (get-in (meta f) [:tool :name])]
                   (assoc acc name (fn [args] (f (parse-json-arguments args))))))
               {})))

(defn- get-assistant-response
  "Recursively gets assistant responses handling tool calls as needed"
  [messages config tools tools-registry]
  (let [assistant-response (-> messages
                               (call-llm-api config tools)
                               extract-first-response)
        tool-messages (handle-tool-call assistant-response tools-registry)
        new-messages (add-message-to-history messages assistant-response)]
    (if (seq tool-messages)
      (let [tool-message-history (reduce add-message-to-history new-messages tool-messages)]
        (recur tool-message-history config tools tools-registry))
      new-messages)))

(defn- get-system-prompt
  []
  {:role "developer"
   :content "You are an expert developer and can utilize tools to write programs and execute them. Please utilize the tools available to you to create and modify files as necessary."})

(defn -main
  []
  (let [config (read-config!)
        tools (get-tool-list 'agent.tools)
        servers (mcpclient/get-servers "./mcp.json")
        tool-registry (build-tool-registry 'agent.tools)
        mcp-tools (into tools (mcpclient/get-combined-tools servers))
        combined-registry (merge tool-registry (mcpclient/get-combined-tool-registry servers))]
    (doseq [tool mcp-tools]
      (dbg-print (str "Registering tool " (get-in tool [:function :name]))))
    (loop [user-message (read-user-input!)
           messages [(get-system-prompt)]]
      (when (some? user-message)
        (let [new-messages (add-message-to-history messages user-message)
              messages-including-response (get-assistant-response new-messages config mcp-tools combined-registry)
              assistant-message (:content (last messages-including-response))]
          (display-assistant-response! assistant-message)
          (recur (read-user-input!) messages-including-response))))
    (println "Exiting...")
    (doseq [server servers]
      (println "Closing " (:name server))
      (mcpclient/close-client (:client server)))))

(comment
  (add-message-to-history [] {})
  (-main))

mcpclient.clj

(ns agent.mcpclient
  (:require [cheshire.core :as cheshire]
            [clojure.pprint :as pprint])
  (:import (io.modelcontextprotocol.client.transport ServerParameters StdioClientTransport)
           (io.modelcontextprotocol.json McpJsonMapper)
           (io.modelcontextprotocol.spec McpSchema$CallToolRequest)
           (io.modelcontextprotocol.client McpClient)))

(defn- make-transport
  [program args]
  (-> (ServerParameters/builder program)
      (.args (into-array String args))
      (.build)
      (StdioClientTransport. (McpJsonMapper/getDefault))))

(defn make-client
  "Create a client to the MCP server specified"
  [program args]
  (-> (make-transport program args)
      McpClient/sync
      (.build)))

(defn close-client
  "Stop the MCP server gracefully"
  [client]
  (.closeGracefully client))

(defn- tool-result->json
  [tool-result]
  (let [mapper (McpJsonMapper/getDefault)]
    (try
      (.writeValueAsString mapper tool-result)
      (catch Exception e
        (throw (ex-info "Failed to serialize ToolResult to JSON" {:cause e}))))))

(defn- tool-result->clj
  [tool-result]
  (let [json (tool-result->json tool-result)]
    (cheshire/parse-string json true)))

(defn- mcp-tool->openai-tool
  "Convert the MCP tool metadata to a OpenAI compatible metadata"
  [{:keys [name description inputSchema] :as mcp-tool}]
  (when-not name
    (throw (ex-info "tool missing :name" {:tool mcp-tool})))
  {:type "function"
   :function {:name (str name)
              :description (or description "")
              :parameters (or inputSchema {})}})

(defn- get-tools
  "Get the list of tools exposed by the MCP server in a format which is compatible to the OpenAI endpoint"
  [client]
  (let [result (.listTools client)
        tools (.tools result)]
    (mapv #(mcp-tool->openai-tool (tool-result->clj %)) tools)))

(defn call-tool
  "Invoke an MCP tool with the given params"
  [client tool params]
  (let [request (McpSchema$CallToolRequest. (McpJsonMapper/getDefault) tool params)]
    (tool-result->clj (.callTool client request))))

(defn- read-mcp-json
  [path]
  (let [content (slurp path)
        config (cheshire/parse-string content true)]
    (or (:mcpServers config) [])))

(defn get-servers
  "Get a vector of clients connected to the MCP servers specified in the config file passed in"
  [path]
  (let [servers (read-mcp-json path)]
    (mapv (fn [[server-name server-config]]
            (let [command (:command server-config)
                  args (or (:args server-config) [])]
              (println "Connecting " server-name)
              {:name server-name
               :client (make-client command args)}))
          servers)))

(defn- build-tool-registry
  [client]
  (let [result (.listTools client)
        tools (.tools result)
        tool-metadata (mapv #(mcp-tool->openai-tool (tool-result->clj %)) tools)]
    (reduce (fn [acc f]
              (let [name (get-in f [:function :name])]
                (assoc acc name (fn [args] (call-tool client name args)))))
            {} tool-metadata)))

(defn get-combined-tools
  "Given a list of servers, generate a combined list of tools from all servers"
  [servers]
  (mapcat (fn [{:keys [client]}]
            (get-tools client))
          servers))

(defn get-combined-tool-registry
  "Given a list of servers, generate a combined tool registry from all servers"
  [servers]
  (apply merge {} (map (comp build-tool-registry :client) servers)))

(comment
  (let [client (make-client "npx" ["-y" "@modelcontextprotocol/server-filesystem" "."])]
    (clojure.pprint/pprint (build-tool-registry client))
    (close-client client))
  (read-mcp-json "./mcp.json"))

tools.clj

(ns agent.tools
  (:require
   [clojure.java.io :as io]
   [clojure.java.shell :as shell]
   [clojure.string :as str]))

#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]}
(defn
  ^{:tool {:name "run_shell_command"
           :description "Run the provided bash shell command and get the output"
           :parameters {:type "object"
                        :properties {:command {:type "string"
                                               :description "Command to be executed"}}
                        :required ["command"]}}}
  run-shell-command
  [{:keys [command]}]
  (try
    (let [{:keys [out err exit]} (shell/sh "bash" "-c" command)]
      (if (zero? exit)
        out
        (str "Error: " err)))
    (catch Exception e
      (str "Exception occurred: " (.getMessage e)))))

Published: 2025-11-11

Tagged: clojure

Archive