Skip to content

Commit 3369f2b

Browse files
committed
feat: working with initialisation over /messages
1 parent 3b09462 commit 3369f2b

File tree

5 files changed

+152
-104
lines changed

5 files changed

+152
-104
lines changed

components/http-server/src/mcp_clj/http_server/ring_adapter.clj

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@
3535
(.sendResponseHeaders exchange status 0)
3636
(with-open [os (.getResponseBody exchange)]
3737
(try
38-
(body os)
39-
(.flush os)
38+
(try
39+
(body os)
40+
(.flush os)
41+
(catch sun.net.httpserver.StreamClosedException _
42+
;; Client disconnected - normal for SSE
43+
nil))
4044
(catch Exception e
45+
;; Log other exceptions
4146
(.printStackTrace e))))))
4247

4348
(defn- send-ring-response

components/json-rpc/src/mcp_clj/json_rpc/server.clj

Lines changed: 73 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -4,105 +4,94 @@
44
[clojure.data.json :as json]
55
[mcp-clj.http-server.ring-adapter :as http]
66
[mcp-clj.json-rpc.protocol :as protocol]
7-
[ring.util.response :as response])
8-
(:import
9-
[com.sun.net.httpserver HttpServer]
10-
[java.io OutputStreamWriter BufferedWriter]))
11-
12-
(defn- write-sse-message
13-
"Write a Server-Sent Event message"
14-
[^BufferedWriter writer message]
15-
(doto writer
16-
(.write (str "data: " message "\n\n"))
17-
(.flush)))
7+
[ring.util.response :as response]))
188

199
(defn- handle-json-rpc
20-
"Process a single JSON-RPC request and return response"
10+
"Process a JSON-RPC request and return response"
2111
[handlers request]
2212
(if-let [validation-error (protocol/validate-request request)]
23-
validation-error
13+
(assoc validation-error :id (:id request))
2414
(let [{:keys [method params id]} request
2515
handler (get handlers method)]
2616
(if handler
2717
(try
28-
(let [result (handler params)]
29-
(protocol/result-response id result))
18+
{:jsonrpc "2.0"
19+
:id id
20+
:result (handler params)}
3021
(catch Exception e
31-
(protocol/error-response
32-
(get protocol/error-codes :internal-error)
33-
(.getMessage e)
34-
{:id id})))
35-
(protocol/error-response
36-
(get protocol/error-codes :method-not-found)
37-
(str "Method not found: " method)
38-
{:id id})))))
22+
{:jsonrpc "2.0"
23+
:id id
24+
:error {:code (get protocol/error-codes :internal-error)
25+
:message (.getMessage e)}}))
26+
{:jsonrpc "2.0"
27+
:id id
28+
:error {:code (get protocol/error-codes :method-not-found)
29+
:message (str "Method not found: " method)}}))))
30+
31+
(defn- handle-post
32+
"Handle JSON-RPC POST request"
33+
[handlers {:keys [body] :as request}]
34+
(try
35+
(let [request-data (json/read-str (slurp body) :key-fn keyword)
36+
response (handle-json-rpc handlers request-data)
37+
response-str (json/write-str response)]
38+
(-> (response/response response-str)
39+
(response/status 200)
40+
(response/content-type "application/json")))
41+
(catch Exception e
42+
(-> (response/response
43+
(json/write-str
44+
{:jsonrpc "2.0"
45+
:error {:code (get protocol/error-codes :internal-error)
46+
:message (str "Unexpected error: " (ex-message e))}}))
47+
(response/status 500)
48+
(response/content-type "application/json")))))
3949

40-
(defn- handle-sse-request
41-
"Handle SSE stream setup and message processing"
42-
[handlers message-stream]
43-
(fn [^java.io.OutputStream output-stream]
44-
(with-open [writer (-> output-stream
45-
(OutputStreamWriter. "UTF-8")
46-
BufferedWriter.)]
50+
(defn- handle-sse
51+
"Handle SSE stream setup"
52+
[response-stream]
53+
(fn [output]
54+
(let [writer (java.io.OutputStreamWriter. output "UTF-8")]
4755
(try
48-
(let [control-ch message-stream]
49-
(loop []
50-
(when-let [message @control-ch]
51-
(let [[request parse-error] (protocol/parse-json message)
52-
response (if parse-error
53-
parse-error
54-
(handle-json-rpc handlers request))
55-
[json-response error] (protocol/write-json response)]
56-
(when json-response
57-
(write-sse-message writer json-response))
58-
(when error
59-
(write-sse-message writer
60-
(json/write-str
61-
(protocol/error-response
62-
(get protocol/error-codes :internal-error)
63-
"Response encoding error")))))
64-
(reset! control-ch nil)
65-
(recur))))
56+
(loop []
57+
(when-let [response @response-stream]
58+
(.write writer (str "data: " (json/write-str response) "\n\n"))
59+
(.flush writer)
60+
(reset! response-stream nil)
61+
(recur)))
6662
(catch Exception e
67-
(write-sse-message writer
68-
(json/write-str
69-
(protocol/error-response
70-
(get protocol/error-codes :internal-error)
71-
"Stream error"))))))))
72-
73-
(defn get-server-port
74-
"Get the actual port a server is listening on"
75-
[^HttpServer server]
76-
(.getPort (.getAddress server)))
63+
(.printStackTrace e))))))
7764

7865
(defn create-server
79-
"Create a new JSON-RPC SSE server.
66+
"Create JSON-RPC server with SSE support.
8067
8168
Configuration options:
82-
- :port Port number (default: 0 for auto-assignment)
83-
- :handlers Map of method names to handler functions
84-
- :message-stream Shared atom for message passing
85-
86-
Returns map with:
87-
- :server The server instance
88-
- :port The actual port the server is running on
89-
- :stop Function to stop the server"
90-
[{:keys [port handlers message-stream]
91-
:or {port 0}
92-
:as config}]
69+
- :port Port number (default: 0 for auto-assignment)
70+
- :handlers Map of method names to handler functions"
71+
[{:keys [port handlers]
72+
:or {port 0}}]
9373
(when-not (map? handlers)
94-
(throw (ex-info "Handlers must be a map" {:config config})))
95-
(when-not (instance? clojure.lang.Atom message-stream)
96-
(throw (ex-info "Message stream must be an atom" {:config config})))
74+
(throw (ex-info "Handlers must be a map" {:handlers handlers})))
75+
76+
(let [response-stream (atom nil)
77+
handler (fn [{:keys [request-method uri] :as request}]
78+
(case [request-method uri]
79+
[:post "/message"]
80+
(handle-post handlers request)
81+
82+
[:get "/sse"]
83+
(-> (response/response (handle-sse response-stream))
84+
(response/status 200)
85+
(response/content-type "text/event-stream")
86+
(response/header "Cache-Control" "no-cache")
87+
(response/header "Connection" "keep-alive"))
88+
89+
(-> (response/response "Not Found")
90+
(response/status 404)
91+
(response/content-type "text/plain"))))
9792

98-
(let [{:keys [server stop]} (http/run-server
99-
(fn [request]
100-
(-> (response/response
101-
(handle-sse-request handlers message-stream))
102-
(response/content-type "text/event-stream")
103-
(response/header "Cache-Control" "no-cache")
104-
(response/header "Connection" "keep-alive")))
105-
{:port port})]
106-
{:server server
107-
:port (get-server-port server)
108-
:stop stop}))
93+
{:keys [server stop]} (http/run-server handler {:port port})]
94+
{:server server
95+
:response-stream response-stream
96+
:port (.getPort (.getAddress server))
97+
:stop stop}))

components/mcp-server/src/mcp_clj/mcp_server/core.clj

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"Handle initialize request from client.
1313
Returns server info, protocol version and capabilities."
1414
[server {:keys [capabilities clientInfo protocolVersion] :as params}]
15+
(binding [*out* *err*] (prn "handle-initialize"))
1516
(when-not (= protocolVersion required-client-version)
1617
(throw (ex-info (str "Unsupported protocol version. Required " required-client-version)
1718
{:code -32001
@@ -36,16 +37,23 @@
3637
(defn handle-initialized
3738
"Handle initialized notification from client."
3839
[server _params]
40+
(println "handle-initialized server:" server)
41+
(println "handle-initialized state:" @(:state server))
3942
(swap! (:state server) assoc :initialized? true)
43+
(println "handle-initialized state after:" @(:state server))
4044
nil)
4145

4246
(defn ping
4347
"Handle ping request."
4448
[server _params]
49+
(println "ping server:" server)
50+
(println "ping state:" @(:state server))
4551
(when-not (:initialized? @(:state server))
52+
(println "ping throwing not initialized")
4653
(throw (ex-info "Server not initialized"
4754
{:code -32002
4855
:data {:state "uninitialized"}})))
56+
(println "ping returning empty map")
4957
{})
5058

5159
(defn create-handlers
@@ -63,11 +71,14 @@
6371
- :tools Optional. Map of tool implementations"
6472
[{:keys [port tools] :as options}]
6573
(let [state (atom {:initialized? false})
66-
handlers (create-handlers {:state state})
74+
message-stream (atom nil)
75+
server (->MCPServer nil state)
76+
handlers (create-handlers server)
6777
json-rpc-server (json-rpc/create-server
68-
{:port port
69-
:handlers handlers})
70-
server (->MCPServer json-rpc-server state)]
71-
(assoc server
78+
{:port port
79+
:handlers handlers
80+
:message-stream message-stream})]
81+
(assoc (assoc server :json-rpc-server json-rpc-server)
82+
:message-stream message-stream
7283
:stop #(do (reset! state {:initialized? false})
7384
((:stop json-rpc-server))))))

components/mcp-server/test/mcp_clj/mcp_server_test.clj

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(ns mcp-clj.mcp-server-test
22
(:require
33
[clojure.test :refer [deftest testing is use-fixtures]]
4+
[clojure.data.json :as json]
5+
[hato.client :as hato]
46
[mcp-clj.mcp-server.core :as mcp]))
57

68
(def valid-client-info
@@ -53,15 +55,55 @@
5355
(mcp/ping server nil))
5456
"Ping throws when not initialized"))))
5557

56-
(deftest server-creation-test
57-
(testing "create server"
58-
(let [server (mcp/create-server {:port 8080})]
59-
(is (instance? mcp_clj.mcp_server.core.MCPServer server)
60-
"Returns MCPServer instance")
61-
(is (fn? (:stop server))
62-
"Has stop function")
63-
(is (not (:initialized? @(:state server)))
64-
"Initial state is uninitialized")
65-
66-
;; Stop server
67-
((:stop server)))))
58+
(def valid-client-info
59+
{:clientInfo {:name "test-client" :version "1.0"}
60+
:protocolVersion "0.1"
61+
:capabilities {:tools {}}})
62+
63+
(defn send-request
64+
"Send a JSON-RPC request and get response"
65+
[url request]
66+
(let [response (hato/post (str url "/message")
67+
{:headers {"Content-Type" "application/json"}
68+
:body (json/write-str request)})
69+
body (json/read-str (:body response))]
70+
(println "sent:" request)
71+
(println "received:" body)
72+
body))
73+
74+
(deftest integration-test
75+
(testing "server request handling"
76+
(let [server (mcp/create-server {:port 0})
77+
port (get-in server [:json-rpc-server :port])
78+
base-url (format "http://localhost:%d" port)
79+
80+
initialize-request {:jsonrpc "2.0"
81+
:method "initialize"
82+
:id 1
83+
:params valid-client-info}
84+
85+
initialized-notification {:jsonrpc "2.0"
86+
:method "notifications/initialized"} ; No id for notification
87+
88+
ping-request {:jsonrpc "2.0"
89+
:method "ping"
90+
:id 2}]
91+
92+
(try
93+
(let [init-response (send-request base-url initialize-request)]
94+
(is (= 1 (get init-response "id"))
95+
"Initialize response has correct id")
96+
(is (get-in init-response ["result" "serverInfo"])
97+
"Initialize response contains server info"))
98+
99+
;; Send initialized notification
100+
(send-request base-url initialized-notification)
101+
102+
(let [ping-response (send-request base-url ping-request)]
103+
(is (= 2 (get ping-response "id"))
104+
"Ping response has correct id")
105+
(is (= {} (get ping-response "result"))
106+
"Ping response result is empty map"))
107+
108+
(finally
109+
((:stop server)))))))

deps.edn

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"components/json-rpc/test"
1414
"components/mcp-server/test"]
1515
:extra-deps
16-
{org.clojure/test.check {:mvn/version "1.1.1"}}
16+
{hato/hato {:mvn/version "1.0.0"}
17+
org.clojure/test.check {:mvn/version "1.1.1"}}
1718
:exec-fn kaocha.runner/exec-fn
1819
:exec-args {}
1920
:jvm-opts ["-XX:-OmitStackTraceInFastThrow"

0 commit comments

Comments
 (0)