Skip to content

Commit 82f4063

Browse files
authored
Ox streaming client and server transport (#182)
* feat: ox streaming transports * fix: examples compilation * feat: more examples * fix: fix GET SSE listener reconnect and add proper test, one testing connections cut and the other testing drop * fix: version updates * fix: improve GET SSE listener loop for ox * fix: implicit scope * fix: refactor atomic references to internal state wrapped in actor * fix: simpler type without identity wrapper * fix: compile and commit generated docs on release * fix: refactor atomic references in ox client stdio transport
1 parent aeed625 commit 82f4063

37 files changed

Lines changed: 1052 additions & 68 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
run: sbt -v scalafmtCheckAll
4040
- name: Compile
4141
run: sbt -v compile
42-
# TODO bring this step back after first release with new project structure (client + server)
42+
# TODO uncomment after release
4343
# - name: Verify that examples compile using Scala CLI
4444
# run: sbt -v "project examples" verifyExamplesCompileUsingScalaCli
4545
- name: Compile documentation

build.sbt

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,14 @@ val sttpClientV = "4.0.26"
1111
val zioV = "2.1.26"
1212
val zioProcessV = "0.8.0"
1313
val zioHttpV = "3.8.0"
14+
val oxV = "1.0.5"
1415
val testcontainersScalaV = "0.41.8"
1516

1617
lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI")
1718

1819
lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
1920
organization := "com.softwaremill.chimp",
2021
scalaVersion := "3.3.7",
21-
updateDocs := Def.taskDyn {
22-
val files = UpdateVersionInDocs(sLog.value, organization.value, version.value)
23-
Def.task {
24-
files
25-
}
26-
}.value,
2722
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s",
2823
Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s",
2924
Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "Integration"),
@@ -35,8 +30,18 @@ val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test
3530

3631
lazy val root = (project in file("."))
3732
.settings(commonSettings: _*)
38-
.settings(publishArtifact := false, name := "chimp")
39-
.aggregate(core, server, serverZio, client, clientZio, examples, serverConformance, clientConformance)
33+
.settings(
34+
publishArtifact := false,
35+
name := "chimp",
36+
updateDocs := Def.taskDyn {
37+
val files = UpdateVersionInDocs(sLog.value, organization.value, version.value)
38+
Def.task {
39+
(docs / mdoc).toTask("").value
40+
files ++ Seq(file("generated-docs/out"))
41+
}
42+
}.value
43+
)
44+
.aggregate(core, server, serverZio, serverOx, client, clientZio, clientOx, examples, serverConformance, clientConformance)
4045

4146
val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through")
4247

@@ -85,6 +90,18 @@ lazy val serverZio: Project = (project in file("server-streaming/server-zio"))
8590
)
8691
.dependsOn(server % "compile->compile;test->test", clientZio % "test->compile")
8792

93+
lazy val serverOx: Project = (project in file("server-streaming/server-ox"))
94+
.settings(commonSettings: _*)
95+
.settings(
96+
name := "chimp-server-ox",
97+
libraryDependencies ++= Seq(
98+
scalaTest,
99+
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
100+
"com.softwaremill.ox" %% "core" % oxV
101+
)
102+
)
103+
.dependsOn(server % "compile->compile;test->test", clientOx % "test->compile")
104+
88105
lazy val client: Project = (project in file("client"))
89106
.settings(commonSettings: _*)
90107
.settings(
@@ -113,6 +130,18 @@ lazy val clientZio: Project = (project in file("client-streaming/client-zio"))
113130
)
114131
.dependsOn(client % "compile->compile;test->test")
115132

133+
lazy val clientOx: Project = (project in file("client-streaming/client-ox"))
134+
.settings(commonSettings: _*)
135+
.settings(
136+
name := "chimp-client-ox",
137+
libraryDependencies ++= Seq(
138+
scalaTest,
139+
"com.softwaremill.sttp.client4" %% "core" % sttpClientV,
140+
"com.softwaremill.ox" %% "core" % oxV
141+
)
142+
)
143+
.dependsOn(client % "compile->compile;test->test")
144+
116145
lazy val examples = (project in file("examples"))
117146
.settings(commonSettings: _*)
118147
.settings(
@@ -126,7 +155,7 @@ lazy val examples = (project in file("examples"))
126155
),
127156
verifyExamplesCompileUsingScalaCli := VerifyExamplesCompileUsingScalaCli(sLog.value, sourceDirectory.value)
128157
)
129-
.dependsOn(server, client)
158+
.dependsOn(server, serverOx, client, clientOx)
130159

131160
import sbtassembly.AssemblyPlugin.autoImport.*
132161

@@ -266,4 +295,4 @@ lazy val docs: Project = (project in file("generated-docs"))
266295
publishArtifact := false,
267296
name := "docs"
268297
)
269-
.dependsOn(core, server, serverZio, client, clientZio)
298+
.dependsOn(core, server, serverZio, serverOx, client, clientZio, clientOx)
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package chimp.client.transport.ox
2+
3+
import chimp.client.internal.SyncPendingRequests
4+
import chimp.client.transport.ClientHttpTransport.HttpOutcome
5+
import chimp.client.transport.{ClientBidirectionalTransport, ClientHttpTransport, ClientTransport}
6+
import chimp.client.{McpProtocolException, McpSessionNotFoundException, McpTransportException}
7+
import chimp.protocol.{JSONRPCErrorCodes, JSONRPCErrorObject, JSONRPCMessage, ProtocolVersion, RequestId}
8+
import org.slf4j.LoggerFactory
9+
import ox.*
10+
import ox.channels.{Actor, ActorRef}
11+
import ox.resilience.{retry, RetryConfig}
12+
import ox.scheduling.Schedule
13+
import sttp.client4.{asInputStreamUnsafe, basicRequest, Response, SyncBackend}
14+
import sttp.model.sse.ServerSentEvent
15+
import sttp.model.{MediaType, StatusCode, Uri}
16+
import sttp.monad.{IdentityMonad, MonadError}
17+
import sttp.shared.Identity
18+
19+
import java.io.{BufferedReader, InputStream, InputStreamReader}
20+
import java.nio.charset.StandardCharsets
21+
import java.util.concurrent.{ConcurrentHashMap, CountDownLatch}
22+
import scala.concurrent.duration.{FiniteDuration, *}
23+
24+
final class OxClientHttpTransport private (
25+
backend: SyncBackend,
26+
uri: Uri,
27+
protocolVersion: ProtocolVersion,
28+
timeout: FiniteDuration,
29+
pending: SyncPendingRequests,
30+
sessionReady: CountDownLatch,
31+
openStreams: java.util.Set[InputStream]
32+
)(using Ox)
33+
extends ClientBidirectionalTransport[Identity]:
34+
35+
private val log = LoggerFactory.getLogger(classOf[OxClientHttpTransport])
36+
37+
private final class State:
38+
var sessionId: Option[String] = None
39+
var incoming: JSONRPCMessage => Unit = _ => ()
40+
var lastEventId: Option[String] = None
41+
var closing: Boolean = false
42+
43+
def beginClosing(): Boolean =
44+
if closing then false
45+
else
46+
closing = true
47+
true
48+
49+
def takeSessionId(): Option[String] =
50+
val id = sessionId
51+
sessionId = None
52+
id
53+
54+
private val state: ActorRef[State] = Actor.create(new State)
55+
56+
given monad: MonadError[Identity] = IdentityMonad
57+
58+
override def send(msg: JSONRPCMessage): Identity[Option[JSONRPCMessage]] =
59+
if state.ask(_.closing) then throw McpTransportException("HTTP transport is closed")
60+
msg match
61+
case request: JSONRPCMessage.Request =>
62+
val await = pending.register(request.id, timeout)
63+
try sendRequest(request, await)
64+
finally { val _ = pending.complete(request.id, cancelled(request.id)) }
65+
case other => sendNonRequest(other)
66+
67+
override def onIncoming(handler: JSONRPCMessage => Identity[Unit]): Identity[Unit] = state.tell(_.incoming = handler)
68+
69+
override def close(): Identity[Unit] =
70+
if state.ask(_.beginClosing()) then
71+
sessionReady.countDown()
72+
openStreams.forEach(closeQuietly)
73+
state.ask(_.takeSessionId()) match
74+
case Some(id) =>
75+
try drainBody(ClientHttpTransport.baseDeleteRequest(uri, protocolVersion, id).response(asInputStreamUnsafe).send(backend))
76+
catch case _: Exception => ()
77+
case None => ()
78+
pending.closeAll("Transport closed")
79+
80+
private def sendRequest(request: JSONRPCMessage.Request, await: () => JSONRPCMessage): Option[JSONRPCMessage] =
81+
val response = post(request)
82+
captureSession(response)
83+
ClientHttpTransport.resolveResponse(response, state.ask(_.sessionId)) match
84+
case Left(err: McpSessionNotFoundException) => state.tell(_.sessionId = None); throw err
85+
case Left(err) => throw err
86+
case Right(HttpOutcome.NoBody) =>
87+
drainBody(response)
88+
throw McpProtocolException("Server returned 202 Accepted for a Request")
89+
case Right(HttpOutcome.JsonBody) =>
90+
val message = decode(collectBody(response))
91+
routeMessage(message)
92+
Some(await())
93+
case Right(HttpOutcome.SseBody) =>
94+
response.body match
95+
case Right(stream) => forkSseDrain(stream, Some(request.id)); Some(await())
96+
case Left(err) => throw McpProtocolException(s"Expected SSE stream, got: $err")
97+
98+
private def sendNonRequest(msg: JSONRPCMessage): Option[JSONRPCMessage] =
99+
val response = post(msg)
100+
captureSession(response)
101+
ClientHttpTransport.resolveResponse(response, state.ask(_.sessionId)) match
102+
case Left(err: McpSessionNotFoundException) => state.tell(_.sessionId = None); throw err
103+
case Left(err) => throw err
104+
case Right(HttpOutcome.NoBody) => drainBody(response); None
105+
case Right(HttpOutcome.JsonBody) => drainBody(response); None
106+
case Right(HttpOutcome.SseBody) =>
107+
response.body match
108+
case Right(stream) => forkSseDrain(stream, None); None
109+
case Left(_) => None
110+
111+
private def post(msg: JSONRPCMessage): Response[Either[String, InputStream]] =
112+
ClientHttpTransport
113+
.basePostRequest(uri, protocolVersion, state.ask(_.sessionId), ClientTransport.encode(msg))
114+
.response(asInputStreamUnsafe)
115+
.send(backend)
116+
117+
private def captureSession(response: Response[?]): Unit =
118+
response.header("Mcp-Session-Id").foreach(id => state.tell(_.sessionId = Some(id)))
119+
sessionReady.countDown()
120+
121+
private def collectBody(response: Response[Either[String, InputStream]]): String =
122+
response.body match
123+
case Right(stream) =>
124+
try String(stream.readAllBytes(), StandardCharsets.UTF_8)
125+
finally closeQuietly(stream)
126+
case Left(err) => throw McpProtocolException(s"HTTP 200 with non-stream body: $err")
127+
128+
private def drainBody(response: Response[Either[String, InputStream]]): Unit =
129+
response.body match
130+
case Right(stream) =>
131+
try { val _ = stream.readAllBytes() }
132+
catch case _: Exception => ()
133+
finally closeQuietly(stream)
134+
case Left(_) => ()
135+
136+
private def decode(body: String): JSONRPCMessage =
137+
ClientTransport.decode(body) match
138+
case Right(message) => message
139+
case Left(err) => throw McpProtocolException(s"Failed to decode response body: ${err.getMessage}, payload $body")
140+
141+
private def forkSseDrain(stream: InputStream, requestId: Option[RequestId]): Unit =
142+
track(stream)
143+
forkDiscard:
144+
try drainSse(stream, _ => ())
145+
catch case e: Exception => if !state.ask(_.closing) then log.warn(s"SSE drain error: ${e.getMessage}")
146+
finally
147+
requestId.foreach { id =>
148+
val _ = pending.complete(id, sseEnded(id))
149+
}
150+
untrack(stream)
151+
152+
private def routeMessage(msg: JSONRPCMessage): Unit = msg match
153+
case response: JSONRPCMessage.Response => val _ = pending.complete(response.id, response)
154+
case err: JSONRPCMessage.Error => val _ = pending.complete(err.id, err)
155+
case other => state.ask(_.incoming)(other)
156+
157+
private[ox] def startGetListener(): Unit = forkDiscard(getListenerLoop())
158+
159+
private def getListenerLoop(): Unit =
160+
sessionReady.await()
161+
val onFailure: (Int, Either[Throwable, Unit]) => Unit = (_, result) =>
162+
result match
163+
case Left(t) => if !state.ask(_.closing) then log.warn(s"GET SSE listener error: ${t.getMessage}")
164+
case Right(_) => ()
165+
val schedule = Schedule.exponentialBackoff(100.millis).jitter().maxInterval(30.seconds)
166+
retry(RetryConfig[Throwable, Unit](schedule).afterAttempt(onFailure)):
167+
if !state.ask(_.closing) then
168+
openGetSseStream(state.ask(_.lastEventId)).foreach: stream =>
169+
try drainSse(stream, id => state.tell(_.lastEventId = Some(id)))
170+
finally untrack(stream)
171+
172+
private def openGetSseStream(lastEvent: Option[String]): Option[InputStream] =
173+
val base = basicRequest
174+
.get(uri)
175+
.header("Accept", MediaType.TextEventStream.toString)
176+
.header("MCP-Protocol-Version", protocolVersion.name)
177+
.response(asInputStreamUnsafe)
178+
val withSession = state.ask(_.sessionId).fold(base)(s => base.header("Mcp-Session-Id", s))
179+
val withLastEvent = lastEvent.fold(withSession)(id => withSession.header("Last-Event-ID", id))
180+
val response = withLastEvent.send(backend)
181+
response.code match
182+
case StatusCode.Ok =>
183+
response.body match
184+
case Right(stream) => track(stream); Some(stream)
185+
case Left(err) => log.warn(s"GET SSE stream returned non-stream body: $err"); None
186+
case StatusCode.MethodNotAllowed =>
187+
drainBody(response)
188+
log.info("Server does not support GET SSE stream")
189+
None
190+
case other =>
191+
drainBody(response)
192+
log.warn(s"GET SSE stream returned HTTP ${other.code}; not reconnecting")
193+
None
194+
195+
private def drainSse(stream: InputStream, onEventId: String => Unit): Unit =
196+
val reader = BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8))
197+
var buffered = List.empty[String]
198+
var line = reader.readLine()
199+
while line != null do
200+
if line.isEmpty then
201+
if buffered.nonEmpty then
202+
dispatchEvent(ServerSentEvent.parse(buffered.reverse), onEventId)
203+
buffered = Nil
204+
else buffered = line :: buffered
205+
line = reader.readLine()
206+
if buffered.nonEmpty then dispatchEvent(ServerSentEvent.parse(buffered.reverse), onEventId)
207+
208+
private def dispatchEvent(event: ServerSentEvent, onEventId: String => Unit): Unit =
209+
event.id.filter(_.nonEmpty).foreach(onEventId)
210+
event.data
211+
.filter(_.nonEmpty)
212+
.foreach: data =>
213+
ClientTransport.decode(data) match
214+
case Right(message) => routeMessage(message)
215+
case Left(_) => ()
216+
217+
private def track(stream: InputStream): Unit = { val _ = openStreams.add(stream) }
218+
219+
private def untrack(stream: InputStream): Unit =
220+
if openStreams.remove(stream) then closeQuietly(stream)
221+
222+
private def closeQuietly(stream: InputStream): Unit =
223+
try stream.close()
224+
catch case _: Exception => ()
225+
226+
private def cancelled(id: RequestId): JSONRPCMessage.Error =
227+
JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = JSONRPCErrorCodes.InvocationError.code, message = "Request cancelled"))
228+
229+
private def sseEnded(id: RequestId): JSONRPCMessage.Error =
230+
JSONRPCMessage.Error(
231+
id = id,
232+
error = JSONRPCErrorObject(code = JSONRPCErrorCodes.InvocationError.code, message = "SSE stream ended before response")
233+
)
234+
235+
object OxClientHttpTransport:
236+
237+
def apply(
238+
backend: SyncBackend,
239+
uri: Uri,
240+
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
241+
timeout: FiniteDuration = ClientTransport.defaultTimeout
242+
)(using Ox): OxClientHttpTransport =
243+
val transport = new OxClientHttpTransport(
244+
backend,
245+
uri,
246+
protocolVersion,
247+
timeout,
248+
SyncPendingRequests(),
249+
CountDownLatch(1),
250+
ConcurrentHashMap.newKeySet[InputStream]()
251+
)
252+
transport.startGetListener()
253+
transport

0 commit comments

Comments
 (0)