Affected version / component: io.modelcontextprotocol:kotlin-sdk 0.14.0 — shared/Transport.kt, shared/AbstractTransport.kt, server/ServerSession.kt, server/Server.kt
Every close callback in the server stack is a plain () -> Unit. Transport.onClose, ServerSession.onClose, and Server.onClose all take a non-suspending block, and Server.close() invokes the registered chain synchronously. That leaves nowhere to run suspending teardown — closing pooled resources, or cancelling and then joining a child CoroutineScope — without wrapping it in runBlocking, which can stall or deadlock the close path.
The contract is synchronous at each layer. AbstractTransport composes the registered blocks as () -> Unit, and invokeOnCloseCallback() runs them inline inside runCatching, swallowing exceptions. ServerSession.onClose chains the same way, and its onClose() hook fires the chain synchronously. Server.onClose does likewise, and Server.close() calls it directly:
public suspend fun close() {
notificationService.close()
sessions.forEach { (_, session) -> session.close() }
_onClose() // synchronous; no suspension point for teardown
}
close() is itself a suspend function, so the synchronous callback is a limitation of the API contract rather than something the implementation forces.
For context, #778 (shipped in 0.13.0) made the stdio transport's own close() suspending, with a graceful drain of in-flight outbound messages, and moved message handlers to Dispatchers.Default. It did not touch the user-facing close callbacks, which are still non-suspending everywhere, and there is no awaitClose() in 0.14.0.
Suggested fix
Offer a suspending close path — for example an onClose(block: suspend () -> Unit) overload, or a suspend close hook that Server.close() and session/transport teardown await. Either lets a server with suspending resources shut down deterministically instead of bridging through runBlocking.
Workaround
Keep the onClose block limited to synchronous work (flipping flags, cancelling a scope without joining it) and move all suspending teardown into a separate suspend function that callers invoke and await after close. Alternatively, treat the callback purely as a signal that resumes a coroutine, and run the suspending cleanup outside it:
suspend fun awaitClose(session: ServerSession) = suspendCoroutine { cont ->
session.onClose { cont.resume(Unit) }
}
// awaitClose(session); mySuspendingCleanup()
Both work, but they push lifecycle management onto every caller, which is easy to get wrong.
Affected version / component:
io.modelcontextprotocol:kotlin-sdk0.14.0 —shared/Transport.kt,shared/AbstractTransport.kt,server/ServerSession.kt,server/Server.ktEvery close callback in the server stack is a plain
() -> Unit.Transport.onClose,ServerSession.onClose, andServer.onCloseall take a non-suspending block, andServer.close()invokes the registered chain synchronously. That leaves nowhere to run suspending teardown — closing pooled resources, or cancelling and then joining a childCoroutineScope— without wrapping it inrunBlocking, which can stall or deadlock the close path.The contract is synchronous at each layer.
AbstractTransportcomposes the registered blocks as() -> Unit, andinvokeOnCloseCallback()runs them inline insiderunCatching, swallowing exceptions.ServerSession.onClosechains the same way, and itsonClose()hook fires the chain synchronously.Server.onClosedoes likewise, andServer.close()calls it directly:close()is itself a suspend function, so the synchronous callback is a limitation of the API contract rather than something the implementation forces.For context, #778 (shipped in 0.13.0) made the stdio transport's own
close()suspending, with a graceful drain of in-flight outbound messages, and moved message handlers toDispatchers.Default. It did not touch the user-facing close callbacks, which are still non-suspending everywhere, and there is noawaitClose()in 0.14.0.Suggested fix
Offer a suspending close path — for example an
onClose(block: suspend () -> Unit)overload, or asuspendclose hook thatServer.close()and session/transport teardown await. Either lets a server with suspending resources shut down deterministically instead of bridging throughrunBlocking.Workaround
Keep the
onCloseblock limited to synchronous work (flipping flags, cancelling a scope without joining it) and move all suspending teardown into a separatesuspendfunction that callers invoke and await after close. Alternatively, treat the callback purely as a signal that resumes a coroutine, and run the suspending cleanup outside it:Both work, but they push lifecycle management onto every caller, which is easy to get wrong.