mcpclient.clj
(ns agent.mcpclient
(:require [cheshire.core :as cheshire])
(: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"
[server]
(let [result (.listTools server)
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))))
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 client]
(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 parsed-args)
(agent.mcpclient/call-tool client name args))))
(defn- encode-tool-response
[response]
(str response))
(defn handle-tool-call
[response tools client]
(let [tool-calls (:tool_calls response)]
(mapv (fn [tc]
{:tool_call_id (:id tc)
:content (encode-tool-response (invoke-tool tools tc client))
: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 f)))
{})))
(defn- get-assistant-response
"Recursively gets assistant responses handling tool calls as needed"
[messages config tools tools-registry client]
(let [assistant-response (-> messages
(call-llm-api config tools)
extract-first-response)
tool-messages (handle-tool-call assistant-response tools-registry client)
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 client))
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)
client (mcpclient/make-client "npx" ["-y" "@modelcontextprotocol/server-filesystem" "."])
tool-registry (build-tool-registry 'agent.tools)
mcp-tools (into tools (mcpclient/get-tools client))]
(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 tool-registry client)
assistant-message (:content (last messages-including-response))]
(display-assistant-response! assistant-message)
(recur (read-user-input!) messages-including-response))))
(println "Exiting...")
(mcpclient/close-client client)))
(comment
(add-message-to-history [] {})
(-main))
Published: 2025-11-09
Tagged: clojure