Affected version / component: io.modelcontextprotocol:kotlin-sdk 0.14.0 — server/ServerSession.kt
ServerSession.onInitialized { } only appends to a callback chain; it has no notion of whether initialization has already happened. If notifications/initialized has already been processed by the time a block is registered, that block never runs, and anything waiting on it hangs forever with no error, timeout, or diagnostic.
The session keeps the callback in a plain field and invokes it once, from the InitializedNotification handler:
private var _onInitialized: (() -> Unit) = {}
setNotificationHandler<InitializedNotification>(Defined.NotificationsInitialized) {
_onInitialized()
CompletableDeferred(Unit)
}
public fun onInitialized(block: () -> Unit) {
val old = _onInitialized
_onInitialized = { old(); block() }
}
There is no "already initialized" flag and no replay, so a registration that lands after the handshake is dropped silently. This is easy to hit, because Server.createSession() returns as soon as the transport starts, with no ordering guarantee relative to the client handshake — any code that registers onInitialized after other setup is racing the client.
The only observable side effects of the handshake are ServerSession.clientCapabilities and clientVersion, which stay null until the initialize request is processed. There is no awaitInitialized() and no StateFlow/property exposing initialization state in 0.14.0.
Suggested fix
Expose initialization state to late registrants: replay it on registration (invoke the block immediately when the session is already initialized), and/or add a suspend fun awaitInitialized() or a StateFlow of the state so callers can wait idempotently regardless of timing.
Workaround
Register onInitialized before the client can complete the handshake — immediately after createSession()/connect() returns, in the same coroutine, and only then start the client:
val session = server.connect(serverTransport)
session.onInitialized { ready.complete(Unit) }
// only now start the client
This wins the race for an in-process pair, but it is fragile: if the session were already initialized when the block is registered, the wait would suspend forever. Polling clientCapabilities/clientVersion for a non-null value can detect "already initialized", but it busy-waits and gives you no callback.
Affected version / component:
io.modelcontextprotocol:kotlin-sdk0.14.0 —server/ServerSession.ktServerSession.onInitialized { }only appends to a callback chain; it has no notion of whether initialization has already happened. Ifnotifications/initializedhas already been processed by the time a block is registered, that block never runs, and anything waiting on it hangs forever with no error, timeout, or diagnostic.The session keeps the callback in a plain field and invokes it once, from the
InitializedNotificationhandler:There is no "already initialized" flag and no replay, so a registration that lands after the handshake is dropped silently. This is easy to hit, because
Server.createSession()returns as soon as the transport starts, with no ordering guarantee relative to the client handshake — any code that registersonInitializedafter other setup is racing the client.The only observable side effects of the handshake are
ServerSession.clientCapabilitiesandclientVersion, which staynulluntil theinitializerequest is processed. There is noawaitInitialized()and noStateFlow/property exposing initialization state in 0.14.0.Suggested fix
Expose initialization state to late registrants: replay it on registration (invoke the block immediately when the session is already initialized), and/or add a
suspend fun awaitInitialized()or aStateFlowof the state so callers can wait idempotently regardless of timing.Workaround
Register
onInitializedbefore the client can complete the handshake — immediately aftercreateSession()/connect()returns, in the same coroutine, and only then start the client:This wins the race for an in-process pair, but it is fragile: if the session were already initialized when the block is registered, the wait would suspend forever. Polling
clientCapabilities/clientVersionfor a non-null value can detect "already initialized", but it busy-waits and gives you no callback.