Skip to content

fix(server): run onInitialized callbacks registered after initialization - #1019

Merged
devcrocod merged 3 commits into
modelcontextprotocol:mainfrom
taekop:fix/920-late-oninitialized
Oct 15, 2026
Merged

devcrocod merged 3 commits into
modelcontextprotocol:mainfrom
taekop:fix/920-late-oninitialized

Conversation

@taekop

@taekop taekop commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #920

ServerSession.onInitialized put callbacks together in a plain var, and the notifications/initialized handler invoked it. That caused two problems:

  • If you register a callback after the client has sent notifications/initialized, it never runs.
  • The var is read and written without synchronization. When onInitialized is called from several threads while the handshake is in progress, two registrations can read the same old value, and one of the callbacks is then dropped.

Pending callbacks are now stored in an AtomicRef<PersistentList<() -> Unit>?>, using the same atomicfu + kotlinx-collections-immutable pattern as FeatureRegistry:

  • onInitialized adds the callback with getAndUpdate. If the ref was already null, initialization has finished, so the callback runs right away on the caller's thread.
  • The initialized handler calls getAndSet(null) and runs the list it gets back once, in registration order.

Each callback now runs at most once, and callbacks pending when notifications/initialized arrives run in registration order. (A callback never runs if the client doesn't send notifications/initialized, and if one throws, the ones after it don't run.) There is one behavior change: before, a duplicate notifications/initialized ran all callbacks again. Now it runs nothing. No public API changes (apiCheck passes).

This follows up #938, which targeted the same issue and was closed by its author.

Tests (in ServerSessionInitializeTest):

  • should run onInitialized callback registered after initialization: a callback registered after the handshake runs. Before the fix it failed with [early] instead of [early, late].
  • should run every onInitialized callback registered concurrently: 200 callbacks are registered concurrently on Dispatchers.Default before the handshake, and each must run once. Before the fix it failed in 5 out of 5 runs, with 2 to 16 callbacks dropped.
./gradlew :integration-test:jvmTest --tests "*ServerSessionInitializeTest*"   # 6 tests, 0 failures
./gradlew :kotlin-sdk-server:jvmTest                                          # 228 tests, 0 failures
./gradlew apiCheck ktlintCheck detekt                                         # pass

I haven't run the full integration-test suite (the TypeScript interop tests) or the non-JVM targets.

I used Claude Code to write this change and the PR description.

@e5l
e5l requested a review from devcrocod October 13, 2026 07:51
taekop and others added 2 commits October 14, 2026 18:24
…ion (modelcontextprotocol#920)

ServerSession.onInitialized composed callbacks into a plain var, so a
callback registered after notifications/initialized was never invoked,
and concurrent registration could drop callbacks.

Keep pending callbacks in an AtomicRef<PersistentList?>. The initialized
handler swaps it to null and runs the list in registration order;
registration after that runs the callback immediately. Each callback
now runs at most once.

Fixes modelcontextprotocol#920
@devcrocod
devcrocod force-pushed the fix/920-late-oninitialized branch from 1fecc81 to 51124e6 Compare October 14, 2026 16:36

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the blocking finding inline — happy to take another look once it's addressed!

Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=10a56e9c-9299-4705-9f96-488644fbf0ab

}
setNotificationHandler<InitializedNotification>(Defined.NotificationsInitialized) {
_onInitialized()
pendingInitializedCallbacks.getAndSet(null)?.forEach { callback -> callback() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the flush loop has no per-callback error isolation, unlike the immediate path added in onInitialized below, and the callbacks it skips can never be replayed because the list has already been swapped for null.

Failure scenario: two components register onInitialized before the handshake and the first one throws. forEach aborts; Protocol.onNotification catches the exception and reports it to onError, but every callback registered after the throwing one never runs, and nothing will ever run it. So one misbehaving callback (e.g. from a library) silently disables the application's readiness callback — the same silent "never runs" failure mode as #920. Verified locally against this branch: registering { calls += "first" }, { error("boom") }, { calls += "third" } and then completing the handshake yields calls == [first] with boom reported to onError.

Suggested fix: route both paths through one guarded helper, so a failing callback cannot drop the others:

private fun runInitializedCallback(block: () -> Unit) {
    try {
        block()
    } catch (e: CancellationException) {
        throw e
    } catch (cause: Throwable) {
        logger.error(cause) { "Error in onInitialized callback" }
        onError(cause)
    }
}

and call it from this forEach as well as from onInitialized.

devcrocod
devcrocod previously approved these changes Oct 14, 2026

@devcrocod devcrocod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — the callback isolation now covers both paths, so this looks good to merge.


Produced by Air Automations. Name: Code review / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/409567ec-d3a7-40ac-ba95-8518ec577ac2?run=baa4e459-5f75-4c57-8c1f-ba008cb2336d

@devcrocod
devcrocod merged commit b78de5c into modelcontextprotocol:main Oct 15, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ServerSession.onInitialized has no state replay, so late registrants silently miss the event

2 participants