refactor SseClientTransport (#142)

* refactor `SseClientTransport`

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* replace `Exception` with `Throwable` in `SseClientTransport` error handling and try/catch `cancel` in closeResources

---------

Co-authored-by: Leonid Stashevsky <e5l@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Pavel Gorgulov 2025-07-08 15:27:03 +02:00 • committed by GitHub
commit bd3cc4e1d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 97 deletions

View file

@ -16,6 +16,7 @@ import io.ktor.http.protocolWithAuthority
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -24,10 +25,11 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.properties.Delegates
import kotlin.time.Duration import kotlin.time.Duration
@Deprecated("Use SseClientTransport instead", ReplaceWith("SseClientTransport"), DeprecationLevel.WARNING) @Deprecated("Use SseClientTransport instead", ReplaceWith("SseClientTransport"), DeprecationLevel.WARNING)
@ -44,97 +46,59 @@ public class SseClientTransport(
private val reconnectionTime: Duration? = null, private val reconnectionTime: Duration? = null,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : AbstractTransport() { ) : AbstractTransport() {
private val scope by lazy {
CoroutineScope(session.coroutineContext + SupervisorJob())
}
private val initialized: AtomicBoolean = AtomicBoolean(false) private val initialized: AtomicBoolean = AtomicBoolean(false)
private var session: ClientSSESession by Delegates.notNull()
private val endpoint = CompletableDeferred<String>() private val endpoint = CompletableDeferred<String>()
private lateinit var session: ClientSSESession
private lateinit var scope: CoroutineScope
private var job: Job? = null private var job: Job? = null
private val baseUrl by lazy { private val baseUrl: String by lazy {
val requestUrl = session.call.request.url.toString() session.call.request.url.let { url ->
val url = Url(requestUrl) val path = url.encodedPath
var path = url.encodedPath when {
if (path.isEmpty()) { path.isEmpty() -> url.protocolWithAuthority
url.protocolWithAuthority path.endsWith("/") -> url.protocolWithAuthority + path.removeSuffix("/")
} else if (path.endsWith("/")) { else -> url.protocolWithAuthority + path.take(path.lastIndexOf("/"))
url.protocolWithAuthority + path.removeSuffix("/") }
} else {
// the last item is not a directory, so will not be taken into account
path = path.substring(0, path.lastIndexOf("/"))
url.protocolWithAuthority + path
} }
} }
override suspend fun start() { override suspend fun start() {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) { check(initialized.compareAndSet(expectedValue = false, newValue = true)) {
error( "SSEClientTransport already started! If using Client class, note that connect() calls start() automatically."
"SSEClientTransport already started! " +
"If using Client class, note that connect() calls start() automatically.",
)
} }
session = urlString?.let { try {
client.sseSession( session = urlString?.let {
urlString = it, client.sseSession(
urlString = it,
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
} ?: client.sseSession(
reconnectionTime = reconnectionTime, reconnectionTime = reconnectionTime,
block = requestBuilder, block = requestBuilder,
) )
} ?: client.sseSession( scope = CoroutineScope(session.coroutineContext + SupervisorJob())
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
job = scope.launch(CoroutineName("SseMcpClientTransport.collect#${hashCode()}")) { job = scope.launch(CoroutineName("SseMcpClientTransport.connect#${hashCode()}")) {
session.incoming.collect { event -> collectMessages()
when (event.event) {
"error" -> {
val e = IllegalStateException("SSE error: ${event.data}")
_onError(e)
throw e
}
"open" -> {
// The connection is open, but we need to wait for the endpoint to be received.
}
"endpoint" -> {
try {
val eventData = event.data ?: ""
// check url correctness
val maybeEndpoint = Url("$baseUrl/${if (eventData.startsWith("/")) eventData.substring(1) else eventData}")
endpoint.complete(maybeEndpoint.toString())
} catch (e: Exception) {
_onError(e)
close()
error(e)
}
}
else -> {
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(event.data ?: "")
_onMessage(message)
} catch (e: Exception) {
_onError(e)
}
}
}
} }
}
endpoint.await() endpoint.await()
} catch (e: Exception) {
closeResources()
initialized.store(false)
throw e
}
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
override suspend fun send(message: JSONRPCMessage) { override suspend fun send(message: JSONRPCMessage) {
if (!endpoint.isCompleted) { check(initialized.load()) { "SseClientTransport is not initialized!" }
error("Not connected") check(job?.isActive == true) { "SseClientTransport is closed!" }
} check(endpoint.isCompleted) { "Not connected!" }
try { try {
val response = client.post(endpoint.getCompleted()) { val response = client.post(endpoint.getCompleted()) {
@ -147,19 +111,80 @@ public class SseClientTransport(
val text = response.bodyAsText() val text = response.bodyAsText()
error("Error POSTing to endpoint (HTTP ${response.status}): $text") error("Error POSTing to endpoint (HTTP ${response.status}): $text")
} }
} catch (e: Exception) { } catch (e: Throwable) {
_onError(e) _onError(e)
throw e throw e
} }
} }
override suspend fun close() { override suspend fun close() {
if (!initialized.load()) { check(initialized.load()) { "SseClientTransport is not initialized!" }
error("SSEClientTransport is not initialized!") closeResources()
}
private suspend fun CoroutineScope.collectMessages() {
try {
session.incoming.collect { event ->
ensureActive()
when (event.event) {
"error" -> {
val error = IllegalStateException("SSE error: ${event.data}")
_onError(error)
throw error
}
"open" -> {
// The connection is open, but we need to wait for the endpoint to be received.
}
"endpoint" -> handleEndpoint(event.data.orEmpty())
else -> handleMessage(event.data.orEmpty())
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_onError(e)
throw e
} finally {
closeResources()
}
}
private fun handleEndpoint(eventData: String) {
try {
val path = if (eventData.startsWith("/")) eventData.substring(1) else eventData
val endpointUrl = Url("$baseUrl/$path")
endpoint.complete(endpointUrl.toString())
} catch (e: Throwable) {
_onError(e)
endpoint.completeExceptionally(e)
throw e
}
}
private suspend fun handleMessage(data: String) {
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(data)
_onMessage(message)
} catch (e: SerializationException) {
_onError(e)
}
}
private suspend fun closeResources() {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return
job?.cancelAndJoin()
try {
if (::session.isInitialized) session.cancel()
if (::scope.isInitialized) scope.cancel()
endpoint.cancel()
} catch (e: Throwable) {
_onError(e)
} }
session.cancel()
_onClose() _onClose()
job?.cancelAndJoin()
} }
} }

View file

@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE import io.ktor.client.plugins.sse.SSE
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.route import io.ktor.server.routing.route
@ -17,10 +18,12 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
class SseTransportTest : BaseTransportTest() { class SseTransportTest : BaseTransportTest() {
private suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().first().port
@Test @Test
fun `should start then close cleanly`() = runTest { fun `should start then close cleanly`() = runTest {
val port = 8080 val server = embeddedServer(CIO, port = 0) {
val server = embeddedServer(CIO, port = port) {
install(io.ktor.server.sse.SSE) install(io.ktor.server.sse.SSE)
val transports = ConcurrentMap<String, SseServerTransport>() val transports = ConcurrentMap<String, SseServerTransport>()
routing { routing {
@ -34,24 +37,27 @@ class SseTransportTest : BaseTransportTest() {
} }
}.startSuspend(wait = false) }.startSuspend(wait = false)
val actualPort = server.actualPort()
val client = HttpClient { val client = HttpClient {
install(SSE) install(SSE)
}.mcpSseTransport { }.mcpSseTransport {
url { url {
host = "localhost" host = "localhost"
this.port = port this.port = actualPort
} }
} }
testClientOpenClose(client) try {
testClientOpenClose(client)
server.stopSuspend() } finally {
server.stopSuspend()
}
} }
@Test @Test
fun `should read messages`() = runTest { fun `should read messages`() = runTest {
val port = 3003 val server = embeddedServer(CIO, port = 0) {
val server = embeddedServer(CIO, port = port) {
install(io.ktor.server.sse.SSE) install(io.ktor.server.sse.SSE)
val transports = ConcurrentMap<String, SseServerTransport>() val transports = ConcurrentMap<String, SseServerTransport>()
routing { routing {
@ -71,23 +77,27 @@ class SseTransportTest : BaseTransportTest() {
} }
}.startSuspend(wait = false) }.startSuspend(wait = false)
val actualPort = server.actualPort()
val client = HttpClient { val client = HttpClient {
install(SSE) install(SSE)
}.mcpSseTransport { }.mcpSseTransport {
url { url {
host = "localhost" host = "localhost"
this.port = port this.port = actualPort
} }
} }
testClientRead(client) try {
server.stopSuspend() testClientRead(client)
} finally {
server.stopSuspend()
}
} }
@Test @Test
fun `test sse path not root path`() = runTest { fun `test sse path not root path`() = runTest {
val port = 3007 val server = embeddedServer(CIO, port = 0) {
val server = embeddedServer(CIO, port = port) {
install(io.ktor.server.sse.SSE) install(io.ktor.server.sse.SSE)
val transports = ConcurrentMap<String, SseServerTransport>() val transports = ConcurrentMap<String, SseServerTransport>()
routing { routing {
@ -109,17 +119,22 @@ class SseTransportTest : BaseTransportTest() {
} }
}.startSuspend(wait = false) }.startSuspend(wait = false)
val actualPort = server.actualPort()
val client = HttpClient { val client = HttpClient {
install(SSE) install(SSE)
}.mcpSseTransport { }.mcpSseTransport {
url { url {
host = "localhost" host = "localhost"
this.port = port this.port = actualPort
pathSegments = listOf("sse") pathSegments = listOf("sse")
} }
} }
testClientRead(client) try {
server.stopSuspend() testClientRead(client)
} finally {
server.stopSuspend()
}
} }
} }

View file

@ -26,11 +26,15 @@ class SseIntegrationTest {
@Test @Test
fun `client should be able to connect to sse server`() = runTest { fun `client should be able to connect to sse server`() = runTest {
val serverEngine = initServer() val serverEngine = initServer()
var client: Client? = null
try { try {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
assertDoesNotThrow { initClient() } assertDoesNotThrow { client = initClient() }
} }
} catch (e: Exception) {
fail("Failed to connect client: $e")
} finally { } finally {
client?.close()
// Make sure to stop the server // Make sure to stop the server
serverEngine.stopSuspend(1000, 2000) serverEngine.stopSuspend(1000, 2000)
} }