Skip to content

Commit 6f3038d

Browse files
committed
feat: add Java HTTP ring adapter
1 parent 303a7a1 commit 6f3038d

File tree

5 files changed

+172
-6
lines changed

5 files changed

+172
-6
lines changed

components/http-server/deps.edn

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
{:deps {ring/ring-core {:mvn/version "1.13.0"}
2+
ring-sse-middleware/ring-sse-middleware {:mvn/version "0.1.3"}}}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
(ns mcp-clj.http-server.ring-adapter
2+
"Ring adapter for Java's com.sun.net.httpserver.HttpServer with SSE support"
3+
(:require
4+
[clojure.string :as str]
5+
[ring.util.response :as response])
6+
(:import
7+
[com.sun.net.httpserver HttpServer HttpHandler HttpExchange]
8+
[java.net InetSocketAddress]
9+
[java.io InputStream]))
10+
11+
(defn- exchange->ring-request
12+
"Convert HttpExchange to Ring request map"
13+
[^HttpExchange exchange]
14+
{:server-port (.getPort (.getLocalAddress exchange))
15+
:server-name (.getHostName (.getLocalAddress exchange))
16+
:remote-addr (-> exchange .getRemoteAddress .getAddress .getHostAddress)
17+
:uri (.getPath (.getRequestURI exchange))
18+
:query-string (.getRawQuery (.getRequestURI exchange))
19+
:scheme :http
20+
:request-method (-> exchange .getRequestMethod .toLowerCase keyword)
21+
:headers (into {}
22+
(for [k (.keySet (.getRequestHeaders exchange))
23+
:let [vs (.get (.getRequestHeaders exchange) k)]]
24+
[(str/lower-case k) (str (first vs))]))
25+
:body (.getRequestBody exchange)})
26+
27+
(defn- send-streaming-response
28+
"Handle streaming response for SSE"
29+
[^HttpExchange exchange response]
30+
(let [{:keys [status headers body]} response]
31+
(doseq [[k v] headers]
32+
(.add (.getResponseHeaders exchange)
33+
(name k)
34+
(str v)))
35+
(.sendResponseHeaders exchange status 0)
36+
(with-open [os (.getResponseBody exchange)]
37+
(try
38+
(body os)
39+
(.flush os)
40+
(catch Exception e
41+
(.printStackTrace e))))))
42+
43+
(defn- send-ring-response
44+
"Send Ring response, detecting streaming vs normal response"
45+
[^HttpExchange exchange response]
46+
(if (fn? (:body response))
47+
(send-streaming-response exchange response)
48+
(let [{:keys [status headers body]}
49+
response
50+
body-bytes (cond
51+
(string? body) (.getBytes body)
52+
(instance? InputStream body) (with-open [is body]
53+
(.readAllBytes is))
54+
:else body)]
55+
(doseq [[k v] headers]
56+
(.add (.getResponseHeaders exchange)
57+
(name k)
58+
(str v)))
59+
(.sendResponseHeaders exchange status (count body-bytes))
60+
(with-open [os (.getResponseBody exchange)]
61+
(.write os body-bytes)
62+
(.flush os)))))
63+
64+
65+
(defn run-server
66+
"Start an HttpServer instance with the given Ring handler"
67+
[handler {:keys [port join?]
68+
:or {port 8080
69+
join? false}}]
70+
(let [server (HttpServer/create (InetSocketAddress. port) 0)
71+
handler-fn (reify HttpHandler
72+
(handle [_ exchange]
73+
(try
74+
(let [request (exchange->ring-request exchange)
75+
response (handler request)]
76+
(if (fn? (:body response))
77+
(send-streaming-response exchange response)
78+
(send-ring-response exchange response)))
79+
(catch Exception e
80+
(.printStackTrace e)
81+
(.sendResponseHeaders exchange 500 0))
82+
;; Removed exchange close from finally block
83+
)))]
84+
(.createContext server "/" handler-fn)
85+
(.setExecutor server nil)
86+
(.start server)
87+
(when join?
88+
(.awaitTermination (.getExecutor server) Long/MAX_VALUE java.util.concurrent.TimeUnit/SECONDS))
89+
server))
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
(ns mcp-clj.http-server.ring-adapter-test
2+
"Tests for Ring adapter for Java's HttpServer"
3+
(:require
4+
[clojure.test :refer [deftest testing is use-fixtures]]
5+
[mcp-clj.http-server.ring-adapter :as adapter]
6+
[ring.util.response :as response])
7+
(:import
8+
[java.net URL HttpURLConnection]))
9+
10+
(defn test-handler [request]
11+
(case (:uri request)
12+
"/" (-> (response/response "Hello World")
13+
(response/content-type "text/plain"))
14+
15+
"/stream" (-> (response/response
16+
#(doseq [n (range 3)]
17+
(.write % (.getBytes (str "data: " n "\n\n")))
18+
(.flush %)))
19+
(response/content-type "text/event-stream"))
20+
21+
"/echo-headers" (-> (response/response
22+
(pr-str {:headers (:headers request)}))
23+
(response/content-type "application/edn"))
24+
25+
"/error" {:status 500
26+
:headers {"Content-Type" "text/plain"}
27+
:body "Error"}))
28+
29+
(def ^:dynamic *server* nil)
30+
(def test-port 8899)
31+
32+
(defn server-fixture [f]
33+
(let [server (adapter/run-server test-handler {:port test-port})]
34+
(try
35+
(binding [*server* server]
36+
(f))
37+
(finally
38+
(.stop server 0)))))
39+
40+
(use-fixtures :each server-fixture)
41+
42+
(defn http-get [path]
43+
(let [url (URL. (str "http://localhost:" test-port path))
44+
conn ^HttpURLConnection (.openConnection url)]
45+
(.setRequestMethod conn "GET")
46+
conn))
47+
48+
(deftest basic-request-test
49+
(testing "basic GET request"
50+
(let [conn (http-get "/")
51+
response (slurp (.getInputStream conn))]
52+
(is (= 200 (.getResponseCode conn)))
53+
(is (= "text/plain" (.getHeaderField conn "Content-Type")))
54+
55+
(is (= "Hello World" response))))
56+
57+
(testing "streaming SSE response"
58+
(let [conn (http-get "/stream")
59+
response (slurp (.getInputStream conn))]
60+
(is (= 200 (.getResponseCode conn)))
61+
(is (= "text/event-stream" (.getHeaderField conn "Content-Type")))
62+
(is (= "data: 0\n\ndata: 1\n\ndata: 2\n\n" response))))
63+
64+
(testing "error response"
65+
(let [conn (http-get "/error")]
66+
(is (= 500 (.getResponseCode conn)))))
67+
68+
(testing "header passing"
69+
(let [conn (http-get "/echo-headers")]
70+
(.setRequestProperty conn "X-Test" "test-value")
71+
(let [response (read-string (slurp (.getInputStream conn)))]
72+
(is (= "test-value" (get-in response [:headers "x-test"])))))))

components/json-rpc/deps.edn

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{:paths ["src"]
2-
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
3-
org.clojure/data.json {:mvn/version "2.5.0"}
4-
aleph/aleph {:mvn/version "0.7.0"}} ; HTTP/TCP server
2+
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
3+
org.clojure/data.json {:mvn/version "2.5.0"}}
54
:aliases
65
{:test {:extra-paths ["test"]
7-
:extra-deps {}}}}
6+
:extra-deps {}}}}

deps.edn

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
{:dev
44
{:extra-paths ["development/src" "components/json-rpc/test"]
55
:extra-deps
6-
{poly/json-rpc {:local/root "components/json-rpc"}
6+
{poly/http-server {:local/root "components/http-server"}
7+
poly/json-rpc {:local/root "components/json-rpc"}
8+
poly/mcp-server {:local/root "components/mcp-server"}
79
lambdaisland/kaocha {:mvn/version "1.87.1366"}
810
lambdaisland/kaocha-cloverage {:mvn/version "1.1.89"}
911
org.clojure/clojure {:mvn/version "1.12.0"}}}
10-
:test {:extra-paths ["components/json-rpc/test"]
12+
:test {:extra-paths ["components/http-server/test"
13+
"components/json-rpc/test"
14+
"components/mcp-server/test"]
1115
:extra-deps
1216
{org.clojure/test.check {:mvn/version "1.1.1"}}
1317
:exec-fn kaocha.runner/exec-fn

0 commit comments

Comments
 (0)