A small Clojure MCP client and server for tool calling in agent runtimes. It targets MCP 2026-07-28 and provides local stdio and remote Streamable HTTP transports, tool discovery/execution, and an adapter for OpenAI-compatible function calling.
The project intentionally does not negotiate older MCP revisions. It implements only the tool-calling surface: it does not implement resources, prompts, sampling, elicitation, tasks, subscriptions, OAuth discovery, or the older initialization/session transports.
This tutorial walks through:
Clone the repository and run the test suite:
git clone https://github.com/pooriayousefi/mcp.clj.git
cd mcp.clj
clojure -M:test
The :test alias adds test/ to the classpath. Source namespaces are under
src/. To check that the library namespaces compile:
clojure -M -e "(require 'mcp.protocol 'mcp.client 'mcp.server 'mcp.openai)"
The project does not define an uberjar or native-image build. Run a server with the Clojure CLI, or package it using the build tooling of your application.
While developing another local Clojure application, add a local-root dependency
to that application's deps.edn (adjust the path to this checkout):
{:deps {mcp.clj/mcp.clj {:local/root "../mcp.clj"}}}
Your application can then require mcp.client, mcp.server, and
mcp.openai. A published artifact can be used instead once a version is
released.
A server is a map created with mcp.server/create-server. Each tool has a
unique name, an optional description, an object-shaped JSON Schema, and a
handler. The handler is called with:
:request, :client-info,
:client-capabilities, and :cancelled?.The handler may return a string or a result map. Use :content for ordinary
tool output; return :structuredContent when downstream code benefits from
structured data. Tool errors should normally be returned with :isError true.
Unhandled exceptions are also converted into tool-level error results.
(ns demo.math
(:require [mcp.server :as mcp]))
(def server
(mcp/create-server
{:name "math"
:version "1.0.0"
:tools
[{:name "add"
:description "Add two numbers."
:inputSchema
{:type "object"
:properties {:a {:type "number"}
:b {:type "number"}}
:required ["a" "b"]
:additionalProperties false}
:handler
(fn [{:keys [a b]} _request-context]
{:structuredContent {:sum (+ a b)}})}]}))
The schema is advertised to clients and models. This implementation checks that tool arguments are objects, but does not validate every argument against the full JSON Schema; validate important input in your handler.
Stdio is the usual choice when an agent runtime launches its MCP server as a child process:
(mcp/run-stdio server)
Create a -main in your application, for example in src/demo/math.clj:
(defn -main [& _args]
(mcp/run-stdio server))
Then launch it from a terminal or process manager:
clojure -M -m demo.math
The child process must write protocol messages only to stdout. Send diagnostic logs to stderr instead.
HTTP is useful when your runtime connects to a separately hosted server:
(def http-server (mcp/run-http server {:port 3000}))
;; During orderly application shutdown:
((:stop http-server))
The endpoint defaults to /mcp and binds to 127.0.0.1. To change the bind
address, endpoint path, or authentication:
(def http-server
(mcp/run-http server
{:ip "127.0.0.1"
:port 3000
:path "/mcp"
:bearer-token (System/getenv "MCP_BEARER_TOKEN")
:allowed-origins #{"https://agent.example.com"}}))
For custom authentication, supply :authenticate, a function that receives
the lower-case HTTP headers map and returns truthy only for authorized
requests. Do not bind to a public interface without deliberately configuring
authentication, origin checks, and network protections. The HTTP server emits
single JSON responses; the client also accepts request-scoped SSE responses
from other conforming servers.
The client supports a process command over stdio or a full HTTP endpoint URL.
mcp/connect is a convenience function for either configuration:
(require '[mcp.client :as mcp])
(def local
(mcp/connect {:command ["clojure" "-M" "-m" "demo.math"]
:timeout-ms 30000}))
(def remote
(mcp/connect {:url "http://127.0.0.1:3000/mcp"
:headers {"Authorization"
(str "Bearer " (System/getenv "MCP_BEARER_TOKEN"))}
:timeout-ms 30000}))
The same connections can be created explicitly with connect-stdio and
connect-http. For stdio, optional :cwd and :env configure the child
process. For either transport, :timeout-ms, :client-info, and
:capabilities customize requests.
Discover server information, list tools, and call a tool:
(mcp/discover local)
(mcp/list-tools local)
(mcp/call-tool local "add" {:a 20 :b 22})
;; => {:resultType "complete"
;; :structuredContent {:sum 42}
;; :content [{:type "text" :text "{\"sum\":42}"}]
;; ...}
list-tools follows pagination cursors and caches the result according to
the server's ttlMs. call-tool returns the MCP result map. JSON-RPC errors,
transport failures, and timeouts are thrown as ExceptionInfo; inspect
(ex-data e) for details.
Keep each connection under a distinct key. Those keys are used to namespace the functions exposed to the model:
(def connections
{"math" (mcp/connect-stdio ["clojure" "-M" "-m" "demo.math"])
"time" (mcp/connect-stdio ["clojure" "-M:time-server"])
"remote-search" (mcp/connect-http "https://tools.example.com/mcp")})
(try
(doseq [[server-name connection] connections]
(println server-name (mapv :name (mcp/list-tools connection))))
;; Use the connections below.
(finally
(doseq [connection (vals connections)]
(mcp/close! connection))))
Close stdio connections when finished; this closes their child processes. Closing an HTTP client connection releases local client state.
mcp.openai/tools lists the tools from each connection and converts them to
OpenAI-compatible function-tool definitions. It returns both the definitions
to send to the model and a routing table used to execute model-produced calls:
(require '[mcp.openai :as mcp-openai])
(def tool-set (mcp-openai/tools connections))
(def model-tools (:tools tool-set))
(def tool-routes (:routes tool-set))
Pass model-tools to your provider's chat-completions API along with the
conversation. Provider SDKs differ, so the following chat-completions! is a
placeholder for your OpenAI-compatible provider client:
(defn run-agent-turn
[messages]
(loop [messages messages]
(let [response
(chat-completions!
{:messages messages
:tools model-tools
:tool_choice "auto"})
choice (first (:choices response))
assistant-message (:message choice)]
(if (= "tool_calls" (:finish_reason choice))
(let [tool-messages
(mcp-openai/execute-tool-calls tool-routes assistant-message)]
;; Keep the assistant message and pair every tool result with its
;; original tool_call_id before asking the model for its next turn.
(recur (into (conj messages assistant-message) tool-messages)))
{:messages (conj messages assistant-message)
:response response}))))
Each result from execute-tool-calls has the OpenAI tool-message shape,
including the original :tool_call_id. Calls in one assistant message are
executed concurrently; returned results preserve the original call order.
The host remains responsible for authorization, tool allow-lists, user
approval, model limits, and deciding when to stop the agent loop.
Give each agent only the servers it is allowed to use. Build a separate tool set and route table from a subset of connections:
(def research-tools
(mcp-openai/tools (select-keys connections ["remote-search"])))
(def finance-tools
(mcp-openai/tools (select-keys connections ["math"])))
Do not pass a shared route table to an agent that should not be able to invoke those tools.
For HTTP calls, parameters in a tool schema can use :x-mcp-header to mirror
selected values into request headers. Annotated values must be strings,
integers, or booleans. The client encodes unsafe values, and the server checks
the header against the request body:
:inputSchema
{:type "object"
:properties {:tenant {:type "string" :x-mcp-header "Tenant"}}
:required ["tenant"]}
This feature is for HTTP-aware routing or intermediaries; it is not required for ordinary tool calls. Keep authorization decisions in authenticated server code rather than trusting a mirrored header by itself.
2026-07-28; each request includes version,
client identity, and client capabilities.server/discover, tools/list, and tools/call.Older MCP versions are rejected; there is no initialize handshake. Features outside tool calling are intentionally not implemented.
Run the unit and integration suite:
clojure -M:test
The suite exercises protocol metadata/version checks, tool dispatch, stdio and HTTP client/server round trips, HTTP header/origin validation, and OpenAI tool-call routing.
The release version is defined in build.clj. Before publishing, run the
test suite and build/deploy the library:
clojure -M:test
clojure -T:build jar
clojure -T:build deploy
Configure a Clojars deploy token in the environment before deploying:
export CLOJARS_USERNAME="your-clojars-username"
export CLOJARS_PASSWORD="your-clojars-deploy-token"
Create a deploy token at https://clojars.org/tokens. Use the token as
CLOJARS_PASSWORD; do not use your account password. The deploy task builds
the source JAR and Maven POM before uploading them.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |