Writing MCP servers in Clojure with Ring and Malli

  • icon Nov 10, 2025
  • by Paul Rutledge
  • icon 11 minutes read
  • icon 2176 Words

Introduction

Large language models, agents, and Model Context Protocol (MCP) are impossible to escape in today’s tech climate. At Latacora, we take a thoughtful and pragmatic approach towards new technologies like these. Are they going to solve all the world’s problems? No. Is it important that we understand them and be able to build software that integrates into emerging ecosystems? Yes!

Internally we’ve built a MCP server to query our Datomic databases using natural language, but now we’re open sourcing the underlying Clojure library so others can easily build robust MCP servers for the emerging LLM agent ecosystem too.

The new library is available on our GitHub: https://github.com/latacora/mcp-sdk

How does Latacora use MCP servers?

Latacora uses this library to build MCP servers that expose a few tools for running queries on our Datomic databases. Combined with a LLM agent this allows us to ask questions about ourselves or our customers in natural language and have the LLM write datalog queries to answer them. You can read more about the data we collect and how we store it in Datomic in these previous posts:

Combined, these things allow us to ask relevant security questions in natural language like this:

Does client X use any hardcoded secrets in their EC2 userdata? Summarize their purposes.

The remainder of the post will walk you through how we used the official Model Context Protocol Java SDK to build a compliant server that integrates naturally into modern Clojure applications.

Definitions

Model Context Protocol is a standardized set of APIs for exposing data to AI agents. By implementing the protocol your custom data sources and processes can participate in an agent’s analysis and decision loop. This is where value is produced — agents gain an ability to analyze your data in combination with a LLM’s recognition of language and general knowledge embedded in the model.

Ring is the de facto Clojure library for building http servers. It abstracts away the underlying socket handlers (servlets) and allows Clojure developers to treat requests and responses as Clojure data.

If you ask a Clojure programmer what they want the MCP server authoring experience to be, most would say they just want to expose it as a Ring handler and incorporate it into applications the same way they would anything else. We agree.

The MCP Java SDK and Ring Impedance Mismatch

The team behind the protocol maintains official SDKs for building MCP servers in a few languages. Clojure isn’t one of them, but Java is. Good Clojure programmers know they can leverage the vast Java ecosystem to avoid needing to build many things from scratch. There’s a problem though — the official Java SDK wants to help you build an entire servlet and take full responsibility for managing the lifecycle of the request and response. This responsibility is something that Ring expects exclusive control over too.

This leaves Clojure programmers at a crossroads — either they need to mount multiple servlets to a single server and deal with two different stacks of routing, logging, and middleware; or they start looking for “pure Clojure” implementations of MCP that fit into the Ring mold. We didn’t like either of the choices. We found it cumbersome to manage routing and other cross-cutting concerns across multiple servlets. At the time of writing no Clojure implementations supported the full MCP protocol (especially the somewhat complex Streamable HTTP Transport).

Having your cake and eating it too

What would it take to turn the MCP Servlet into a simple Ring handler?

First, a Servlet needs access to the original HttpServletRequest and HttpServletResponse. We’ll also need to opt-out of having Ring handle the response since the MCP Servlet is going to handle that for us.

We achieved this by writing our own run-jetty function that builds a slightly modified ServletHandler compared to the one bundled with Ring.

Our implementation of the ServletHandler deviates from the standard implementation in three important ways. None of the changes break compatibility with existing Ring handlers and could be considered reasonable upstream changes to the Ring library itself.

  1. Our servlet handler will attach the original request and response objects as metadata to the Ring request map so they can be accessed later in the request lifecycle by our MCP Servlet masquerading as a Ring handler.

  2. We’ll delay opening the real input stream on the request until something actually starts trying to read bytes from the stream. This is important for two reasons:

    • Ring spec says the input stream will be available at the :body key in the request map and existing handlers and middleware often depend on this.
    • Embedded handlers like the MCP Servlet try to do this themselves, but you can only open a stream once per request without encountering exceptions.
  3. We’ll have the servlet handler check if the request was fully handled by the Ring handler and if it was then we’ll skip the normal response handling. This makes it compatible with traditional Ring handlers and Ring handlers like ours that delegate the request/response to another Servlet.

(ns com.latacora.mcp.core
    (:require [clojure.tools.logging :as log]
      [jsonista.core :as jsonista]
      [malli.core :as m]
      [malli.error :as me]
      [malli.json-schema :as mjs]
      [malli.transform :as mt]
      [ring.adapter.jetty :as jetty]
      [ring.util.jakarta.servlet :as servlet]
      [ring.websocket :as ws])
    (:import (io.modelcontextprotocol.json.jackson JacksonMcpJsonMapper)
      (io.modelcontextprotocol.server McpServer McpServerFeatures$SyncToolSpecification)
      (io.modelcontextprotocol.server.transport HttpServletStreamableServerTransportProvider)
      (io.modelcontextprotocol.spec McpSchema$CallToolRequest McpSchema$CallToolResult McpSchema$ServerCapabilities McpSchema$Tool McpSchema$ToolAnnotations)
      (jakarta.servlet.http HttpServlet)
      (java.io PrintWriter StringWriter)
      (jakarta.servlet.http HttpServletRequest)
      (java.util List)
      (java.io InputStream)
      (java.time Duration)
      (java.util Locale)
      (java.util.function BiFunction)
      (org.eclipse.jetty.ee9.nested Request)
      (org.eclipse.jetty.ee9.servlet ServletContextHandler ServletHandler)
      (org.eclipse.jetty.server Server)))

(defn lazy-input-stream
  "Creates an input stream that defers opening the real input stream until
   an operation is performed against the stream."
  [stream]
  (proxy [InputStream] []
      (read
        ([] (.read ^InputStream @stream))
        ([b] (.read ^InputStream @stream b))
        ([b off len] (.read ^InputStream @stream b off len)))
      (available [] (.available ^InputStream @stream))
      (close [] (.close ^InputStream @stream))
      (mark [readlimit] (.mark ^InputStream @stream readlimit))
      (markSupported [] (.markSupported ^InputStream @stream))
      (reset [] (.reset ^InputStream @stream))
      (skip [n] (.skip ^InputStream @stream n)))))

(defn build-request-map
  "Create the request map from the HttpServletRequest object."
  [^HttpServletRequest request]
  {:server-port        (.getServerPort request)
   :server-name        (.getServerName request)
   :remote-addr        (.getRemoteAddr request)
   :uri                (.getRequestURI request)
   :query-string       (.getQueryString request)
   :scheme             (keyword (.getScheme request))
   :request-method     (keyword (.toLowerCase (.getMethod request) Locale/ENGLISH))
   :protocol           (.getProtocol request)
   :headers            (#'servlet/get-headers request)
   :content-type       (.getContentType request)
   :content-length     (#'servlet/get-content-length request)
   :character-encoding (.getCharacterEncoding request)
   :ssl-client-cert    (#'servlet/get-client-cert request)
   :body               (lazy-input-stream (delay (.getInputStream request)))})


(defn proxy-handler [handler options]
  (proxy [ServletHandler] []
    (doHandle [_ ^Request base-request request response]
      (let [request-map  (build-request-map request)
            response-map (handler (with-meta request-map {:request request :response response}))]
        (when-not (.isHandled request)
          (try
            (if (ws/websocket-response? response-map)
              (#'jetty/upgrade-to-websocket request response response-map options)
              (servlet/update-servlet-response response response-map))
            (finally
              (-> response .getOutputStream .close)
              (.setHandled base-request true))))))))

(defn run-jetty
  "Like Ring.adapter.jetty/run-jetty but passes the raw request and
   response along for embedded servlet handlers. The behavior is
   otherwise identical to ring.adapter.jetty/run-jetty."
  ^Server [handler options]
  (let [server (#'jetty/create-server (dissoc options :configurator))
        proxy  (proxy-handler handler options)]
    (.setHandler ^Server server ^ServletContextHandler (#'jetty/context-handler proxy))
    (when-let [configurator (:configurator options)]
      (configurator server))
    (try
      (.start server)
      (when (:join? options true)
        (.join server))
      server
      (catch Exception ex
        (.stop ^Server server)
        (throw ex)))))

Now that we have access to the raw servlet request and response objects in our Ring handlers we can proceed to build one that delegates the request/response lifecycle to the MCP Servlet.


(defn create-ring-handler
  "Creates a ring handler that functions as a complete streamable-http MCP endpoint. This is
   intended to be more convenient to incorporate into Clojure servers than the underlying Java
   library would otherwise support. It achieves this through cooperation with a custom jetty
   adapter that allows individual ring handlers to assume complete responsibility for the
   request/response lifecycle and therefore must be used with com.latacora.mcp.core/run-jetty
   when starting your jetty server."
  [{:keys [name
           version
           tools
           prompts
           resources
           resource-templates
           completions
           experimental
           logging
           instructions
           request-timeout
           keep-alive-interval]
    :or   {tools               []
           prompts             []
           resources           []
           resource-templates  []
           experimental        {}
           completions         []
           logging             true
           instructions        "Call these tools to assist the user."
           request-timeout     (Duration/ofMinutes 30)
           keep-alive-interval (Duration/ofSeconds 15)}}]
  (let [tp-provider
        (.build
          (doto (HttpServletStreamableServerTransportProvider/builder)
            (.jsonMapper mcp-mapper)
            (.keepAliveInterval keep-alive-interval)
            ; this is important because the MCP servlet verifies
            ; that the request URI ends with whatever value this
            ; is set to (defaults to /mcp). We set it to an empty
            ; string because (.endsWith "anything" "") is always
            ; true, meaning the handler doesn't care what endpoint
            ; the ring handler is mounted at.
            (.mcpEndpoint "")))
        server
        (.build
          (doto (McpServer/sync tp-provider)
            (.serverInfo name version)
            (.jsonMapper mcp-mapper)
            (.completions ^List completions)
            (.instructions instructions)
            (.tools ^List tools)
            (.resources ^List resources)
            (.resourceTemplates ^List resource-templates)
            (.prompts ^List prompts)
            (.requestTimeout request-timeout)
            (.capabilities
              (.build
                (cond-> (McpSchema$ServerCapabilities/builder)
                        (not-empty experimental)
                        (.experimental experimental)
                        (not-empty resources)
                        (.resources true true)
                        (not-empty tools)
                        (.tools true)
                        (not-empty prompts)
                        (.prompts true)
                        (not-empty completions)
                        (.completions)
                        logging
                        (.logging))))))]
    (fn handler [request]
      (let [{:keys [request response]} (meta request)]
        (try
          (.service ^HttpServlet tp-provider request response)
          (finally (.setHandled request true)))))))

That’s it. Now you can incorporate a fully compliant MCP server as a Ring handler into your applications while leveraging middleware and routing as you normally would. What we’ve shown so far is enough to go build compliant MCP servers in Clojure. Next we’ll show how we made building tool specifications easy and idiomatic.

Building Tool Specifications with Malli

The most commonly used feature of MCP servers involves exposing tools to LLM agents. Tools are simply functions that take structured input and return structured output plus some basic metadata to help the LLM understand when and how to use them properly.

Malli is a popular Clojure library for describing data schemas and validating / coercing data against those schemas. Conveniently, Malli also supports generating JSON schemas, which is exactly what MCP tools need to provide to describe their inputs and outputs.

With a small amount of interop we can write a function that creates MCP tool specifications from Malli schemas and Clojure functions. The function will handle coercing and validating inputs and outputs automatically so you can focus on writing the actual tool logic.

(defn create-tool-specification
   "Create MCP tool specification from clojure data / function / malli schemas."
   [{:keys [name
            title
            handler
            description
            input-schema
            output-schema
            read-only-hint
            destructive-hint
            idempotent-hint
            open-world-hint
            return-direct
            meta]
     :or   {read-only-hint   false
            destructive-hint false
            idempotent-hint  false
            open-world-hint  false
            return-direct    false
            meta             {}}}]
   (.build
     (doto (McpServerFeatures$SyncToolSpecification/builder)
           (.tool
             (.build
               (doto (McpSchema$Tool/builder)
                     (.name name)
                     (.title title)
                     (.description description)
                     (.inputSchema mcp-mapper (jsonista/write-value-as-string (mjs/transform input-schema)))
                     (.outputSchema mcp-mapper (jsonista/write-value-as-string (mjs/transform output-schema)))
                     (.annotations (McpSchema$ToolAnnotations. title read-only-hint destructive-hint idempotent-hint open-world-hint return-direct))
                     (.meta meta))))
           (.callHandler
             (let [request-coercer (m/decoder input-schema malli-transformer)
                   request-explainer (m/explainer input-schema)
                   response-coercer (m/decoder output-schema malli-transformer)
                   response-explainer (m/explainer output-schema)]
                  (reify BiFunction
                         (apply [this exchange request]
                                (try
                                  (let [request-data (.arguments ^McpSchema$CallToolRequest request)
                                        ; hack to convert java datastructures into clojure ones.
                                        ; this could be done more efficiently with a protocol
                                        ; instead of serialization roundtrip
                                        clojure-request-data (jsonista/read-value (jsonista/write-value-as-string request-data))
                                        coerced-request-data (request-coercer clojure-request-data)]
                                       (if-some [explanation (request-explainer coerced-request-data)]
                                                (do
                                                  (let [ex (ex-info "Invalid request for tool call."
                                                                    {:tool        name
                                                                     :request     clojure-request-data
                                                                     :explanation (me/humanize explanation)})]
                                                       (log/error ex (ex-message ex)))
                                                  (.build
                                                    (doto (McpSchema$CallToolResult/builder)
                                                          (.isError true)
                                                          (.addTextContent (jsonista/write-value-as-string (me/humanize explanation))))))
                                                (let [response-data (handler exchange coerced-request-data)
                                                      coerced-response-data (response-coercer response-data)]
                                                     (if-some [explanation (response-explainer coerced-response-data)]
                                                              (do
                                                                (let [ex (ex-info "Invalid response from tool call."
                                                                                  {:tool        name
                                                                                   :request     coerced-request-data
                                                                                   :response    coerced-response-data
                                                                                   :explanation (me/humanize explanation)})]
                                                                     (log/error ex (ex-message ex)))
                                                                (.build
                                                                  (doto (McpSchema$CallToolResult/builder)
                                                                        (.isError true)
                                                                        (.addTextContent (jsonista/write-value-as-string (me/humanize explanation))))))
                                                              (.build
                                                                (doto (McpSchema$CallToolResult/builder)
                                                                      (.structuredContent mcp-mapper (jsonista/write-value-as-string coerced-response-data))
                                                                      (.meta (or (meta response-data) {}))))))))
                                  (catch Throwable e
                                    (let [ex (ex-info "Exception calling tool." {:tool name :request request} e)]
                                         (log/error ex (ex-message ex)))
                                    (.build
                                      (doto (McpSchema$CallToolResult/builder)
                                            (.isError true)
                                            (.addTextContent (throwable->string e))
                                            (.meta (or (meta e) {})))))))))))))

The last step is to put it all together and run a simple MCP server that exposes a tool. Here we’ll write a simple tool that adds two numbers together and returns the result.

(def tool
  (mcp/create-tool-specification
    {:name          "add"
     :title         "Add two numbers"
     :description   "Adds two numbers together"
     :input-schema  [:map [:a int?] [:b int?]]
     :output-schema [:map [:result int?]]
     :handler       (fn [_exchange {:keys [a b]}]
                        {:result (+ a b)})}))

; when you construct the mcp handler you simply provide java objects
; defined by the underlying modelcontextprotocol sdk. this means
; you're free to build any of the other mcp server features using
; interop. at a future date we might provide clojure wrappers to
; make constructing them easier, but for now we only provide one for
; tools since they're the most common and tedious to construct.
(def handler
  (mcp/create-ring-handler {:name "hello-world" :version "1.0.0" :tools [tool]}))

; we use our custom run-jetty function that preserves
; the original servlet request/response objects in metadata so
; they can be passed to the embedded mcp servlet. it is a drop-in
; replacement for ring.adapter.jetty/run-jetty. here we perform no
; routing and so the mcp server is mounted at the root path, but you
; can embed the handler anywhere in your routing tree.
(def server
  (mcp/run-jetty handler {:port 3000 :join? false}))

Testing

You can use the MCP Inspector to interact with your server during development.

npx @modelcontextprotocol/inspector --server-url http://localhost:3000

Or add it to Claude Code to help you write prose and code while interacting with your MCP server:

claude mcp add --transport http hello-world http://localhost:3000

Interested in how these capabilities (and many more) can help your organization’s security program? Reach out at hello@latacora.com.