feat(core): concurrent inbound dispatch behind an initialization gate (#884)

Make inbound message handling concurrent after the initialization
handshake, so a slow or peer-awaiting handler no longer blocks other
messages on the same connection

fixes #176, #572 

## Motivation and Context
Inbound messages were processed serially on the transport read loop. One
slow or peer-awaiting request handler (sampling, elicitation, roots)
blocked every later message on that connection, including the responses
and `notifications/cancelled` those handlers depend on which could
deadlock request/response flows.

## How Has This Been Tested?
New and old tests

## Breaking Changes
- after the handshake, inbound handlers may run concurrently (processing
stays serial during initialization). There is no opt-out flag by design,
concurrency is bounded by `maxConcurrentHandlers` /
`maxInFlightHandlers`.
- `ProtocolOptions` / `ClientOptions` / `ServerOptions` gain new
parameters with defaults
- `RequestHandlerExtra` is now a coroutine-context element and is no
longer user-constructible

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
This commit is contained in:
Pavel Gorgulov 2026-07-15 12:33:29 +02:00 • committed by GitHub
commit beb3400064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2462 additions and 191 deletions

View file

@ -24,6 +24,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
@ -52,6 +53,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
@ -71,6 +73,7 @@ import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds
class ClientTest {
@Test
@ -543,6 +546,15 @@ class ClientTest {
val serverSession = serverSessionResult.await()
// Register the tap after createSession so it chains onto the session's subscription instead
// of pre-empting the handshake.
val cancelledOnWire = CompletableDeferred<Unit>()
serverTransport.onMessage { message ->
if (message is JSONRPCNotification && message.method == Method.Defined.NotificationsCancelled.value) {
cancelledOnWire.complete(Unit)
}
}
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Simulate delay
def.complete(Unit)
@ -568,6 +580,7 @@ class ClientTest {
def.await()
runCatching { job.cancel("Cancelled by test") }
defCancel.await()
cancelledOnWire.await()
defTimeOut.await()
}
@ -606,17 +619,9 @@ class ClientTest {
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Simulate a delayed response
// Wait ~100ms unless canceled
try {
withTimeout(100L) {
// Just delay here, if timeout is 0 on the client side, this won't return in time
delay(100)
}
} catch (_: Exception) {
// If aborted, just rethrow or return early
}
ListResourcesResult(resources = emptyList())
// Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the
// virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic.
awaitCancellation()
}
// Request with 1 msec timeout should fail immediately
@ -895,8 +900,8 @@ class ClientTest {
),
)
// Track notifications
var rootListChangedNotificationReceived = false
// The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred.
val rootListChangedReceived = CompletableDeferred<Unit>()
val serverSessionResult = CompletableDeferred<ServerSession>()
@ -915,16 +920,13 @@ class ClientTest {
serverSession.setNotificationHandler<RootsListChangedNotification>(
Method.Defined.NotificationsRootsListChanged,
) {
rootListChangedNotificationReceived = true
rootListChangedReceived.complete(Unit)
CompletableDeferred(Unit)
}
client.sendRootsListChanged()
assertTrue(
rootListChangedNotificationReceived,
"Notification should be sent when sendRootsListChanged is called",
)
withTimeout(5.seconds) { rootListChangedReceived.await() }
}
@Test

View file

@ -0,0 +1,306 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.shared.currentRequestHandlerExtra
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.time.Duration.Companion.seconds
/**
* End-to-end tests for concurrent inbound dispatch enabled after the MCP handshake.
*
* Handlers run on a real [kotlinx.coroutines.Dispatchers.Default], so these use [runBlocking] with
* explicit [withTimeout] guards. A virtual test clock would false-fire timeouts against cross-thread
* real dispatchers.
*/
@OptIn(ExperimentalMcpApi::class)
class ProtocolConcurrencyIntegrationTest {
private fun rootsClient(): Client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = null)),
),
)
private fun toolsServer(): Server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = null)),
),
)
// ChannelTransport delivers inbound messages as soon as its read loop starts, which is before
// start() marks it operational. Creating the session first keeps the client's initialize from
// landing on a transport that would reject the session's response.
private suspend fun connect(
client: Client,
clientTransport: ChannelTransport,
server: Server,
serverTransport: ChannelTransport,
): ServerSession {
val serverSession = server.createSession(serverTransport)
client.connect(clientTransport)
return serverSession
}
// The motivating deadlock over real serial read loops: a server tool handler makes a
// server-to-client request (roots/list) while still handling the client's tools/call.
@Test
fun `server tool handler can call roots list mid-handling without deadlock`() = runBlocking {
val client = rootsClient()
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val serverSession = connect(client, clientTransport, server, serverTransport)
// Register the tools/call handler directly on the session so it can call back to the client
// mid-handling. Before concurrent dispatch this would deadlock: the server read loop is
// blocked by the tool handler and can never deliver the roots/list response.
serverSession.setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { _, _ ->
val roots = serverSession.request<ListRootsResult>(ListRootsRequest())
CallToolResult(content = listOf(TextContent(text = "roots=${roots.roots.size}")))
}
val result = withTimeout(10.seconds) {
client.callTool(CallToolRequest(CallToolRequestParams("needs-roots")))
}
val text = (result as CallToolResult).content.filterIsInstance<TextContent>().single().text
text shouldBe "roots=0"
client.close()
server.close()
}
// Client-side mirror: a client sampling handler makes a client-to-server request (tools/list)
// while still handling the server's sampling/createMessage.
@Test
fun `client sampling handler can call the server mid-handling without deadlock`() = runBlocking {
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = ClientCapabilities.Sampling()),
),
)
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { _, _ ->
val tools = client.request<ListToolsResult>(ListToolsRequest())
CreateMessageResult(
role = Role.Assistant,
content = TextContent(text = "tools=${tools.tools.size}"),
model = "test-model",
)
}
val serverSession = connect(client, clientTransport, server, serverTransport)
val result = withTimeout(10.seconds) {
serverSession.request<CreateMessageResult>(
CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 100,
messages = listOf(SamplingMessage(role = Role.User, content = TextContent(text = "hi"))),
),
),
)
}
val text = result.content.filterIsInstance<TextContent>().single().text
text shouldBe "tools=0"
client.close()
server.close()
}
// Nested-cancel cascade: cancelling the client's tools/call must reach the server tool handler
// over the wire and cascade into its nested roots/list request, cancelling the parked
// client-side roots handler. No tools/call response is ever sent.
@Test
fun `cancelling a tool call cascades into its nested roots list request`() = runBlocking {
val client = rootsClient()
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val capturedToolCallId = AtomicReference<RequestId?>(null)
val toolHandlerCancelled = CompletableDeferred<CancellationException>()
val rootsHandlerEntered = CompletableDeferred<Unit>()
val rootsHandlerCancelled = CompletableDeferred<CancellationException>()
val parkForever = CompletableDeferred<ListRootsResult>()
val unexpectedToolCallResponse = CompletableDeferred<Unit>()
// Tap the server->client direction (client transport inbound) to prove no tools/call
// response is ever delivered once the call is cancelled.
clientTransport.onMessage { message ->
if (message is JSONRPCResponse && message.id == capturedToolCallId.get()) {
unexpectedToolCallResponse.complete(Unit)
}
}
val serverSession = connect(client, clientTransport, server, serverTransport)
// Client roots handler parks until cancelled and records the cancellation.
client.setRequestHandler<ListRootsRequest>(Method.Defined.RootsList) { _, _ ->
rootsHandlerEntered.complete(Unit)
try {
parkForever.await()
} catch (e: CancellationException) {
rootsHandlerCancelled.complete(e)
throw e
}
}
// Tool handler makes a nested server->client request and records its own cancellation.
serverSession.setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { _, _ ->
val extra = currentRequestHandlerExtra()!!
capturedToolCallId.set(extra.requestId)
try {
extra.sendRequest<ListRootsResult>(ListRootsRequest())
} catch (e: CancellationException) {
toolHandlerCancelled.complete(e)
throw e
}
CallToolResult(content = listOf(TextContent(text = "unreachable")))
}
val call = launch { client.callTool(CallToolRequest(CallToolRequestParams("nested"))) }
withTimeout(10.seconds) { rootsHandlerEntered.await() }
call.cancel(CancellationException("user aborted"))
withTimeout(10.seconds) { toolHandlerCancelled.await() }
withTimeout(10.seconds) { rootsHandlerCancelled.await() }
// The server suppresses the response for a cancelled request, so none is ever sent.
assertFalse(
unexpectedToolCallResponse.isCompleted,
"no tools/call response should be delivered after the call was cancelled",
)
client.close()
server.close()
}
// Init gate plus context element in a high-level handler: initialize and tools/list are
// pipelined before notifications/initialized and answered serially in order. After the
// handshake a high-level addTool handler can observe currentRequestHandlerExtra().
@Test
fun `pipelined initialize and tools list are processed serially then context element is available`(): Unit =
runBlocking {
val server = toolsServer()
server.addTool(name = "ctx-tool", description = "returns its request id") {
val extra = currentRequestHandlerExtra()
checkNotNull(extra) { "currentRequestHandlerExtra() must be non-null inside a tool handler" }
CallToolResult(content = listOf(TextContent(text = extra.requestId.asText())))
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val arrivalOrder = CopyOnWriteArrayList<RequestId>()
val pending = ConcurrentHashMap<RequestId, CompletableDeferred<JSONRPCMessage>>()
clientTransport.onMessage { message ->
val id = when (message) {
is JSONRPCResponse -> message.id
is JSONRPCError -> message.id
else -> null
}
if (id != null) {
arrivalOrder.add(id)
pending[id]?.complete(message)
}
}
// Drive the client side manually over the raw transport.
server.createSession(serverTransport)
val initRequest = InitializeRequest(
InitializeRequestParams(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ClientCapabilities(),
clientInfo = Implementation(name = "raw-client", version = "1.0"),
),
).toJSON()
val toolsListRequest = ListToolsRequest().toJSON()
val initAnswered = pending.expect(initRequest.id)
val toolsListAnswered = pending.expect(toolsListRequest.id)
// Pipelined before notifications/initialized: both handled in the serial dispatch phase.
clientTransport.send(initRequest)
clientTransport.send(toolsListRequest)
withTimeout(10.seconds) { initAnswered.await() }
withTimeout(10.seconds) { toolsListAnswered.await() }
// Deterministic serial ordering: initialize answered before tools/list.
assertEquals(
listOf(initRequest.id, toolsListRequest.id),
arrivalOrder.toList(),
"pipelined requests must be answered in arrival order",
)
// Complete the handshake, then call the high-level tool.
clientTransport.send(InitializedNotification().toJSON())
val toolCallRequest = CallToolRequest(CallToolRequestParams("ctx-tool")).toJSON()
val toolCallAnswered = pending.expect(toolCallRequest.id)
clientTransport.send(toolCallRequest)
val toolCallResponse = withTimeout(10.seconds) { toolCallAnswered.await() } as JSONRPCResponse
val text = (toolCallResponse.result as CallToolResult).content.filterIsInstance<TextContent>().single().text
// The handler echoed its request id, proving currentRequestHandlerExtra() was available.
text shouldBe toolCallRequest.id.asText()
}
private fun ConcurrentHashMap<RequestId, CompletableDeferred<JSONRPCMessage>>.expect(
id: RequestId,
): CompletableDeferred<JSONRPCMessage> = CompletableDeferred<JSONRPCMessage>().also { put(id, it) }
private fun RequestId.asText(): String = when (this) {
is RequestId.StringId -> value
is RequestId.NumberId -> value.toString()
}
}

View file

@ -1,5 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.kotest.assertions.nondeterministic.continually
import io.kotest.assertions.nondeterministic.eventually
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
@ -17,6 +19,9 @@ import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
@ -56,7 +61,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
@ParameterizedTest
@EnumSource(LoggingLevel::class)
fun `notification should filter logging messages below level`(minLevel: LoggingLevel): Unit = runBlocking {
val receivedMessages = mutableListOf<LoggingMessageNotification>()
val receivedMessages = CopyOnWriteArrayList<LoggingMessageNotification>()
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
receivedMessages.add(it)
CompletableDeferred(Unit)
@ -79,8 +84,10 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
client.callTool(CallToolRequest(CallToolRequestParams("test-notification-filtered")))
// Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count
// before asserting order.
val expectedLevels = LoggingLevel.entries.filter { it >= minLevel }
receivedMessages shouldHaveSize expectedLevels.size
eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size }
receivedMessages.map { it.params.level } shouldBe expectedLevels
}
@ -115,7 +122,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
@ParameterizedTest
@EnumSource(LoggingLevel::class)
fun `sendLoggingMessage should filter messages below level`(minLevel: LoggingLevel): Unit = runBlocking {
val receivedMessages = mutableListOf<LoggingMessageNotification>()
val receivedMessages = CopyOnWriteArrayList<LoggingMessageNotification>()
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
receivedMessages.add(it)
CompletableDeferred(Unit)
@ -138,14 +145,16 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
client.callTool(CallToolRequest(CallToolRequestParams("test-logging-level")))
// Handlers dispatch on Dispatchers.Default after the handshake, so await the expected count
// before asserting order.
val expectedLevels = LoggingLevel.entries.filter { it >= minLevel }
receivedMessages shouldHaveSize expectedLevels.size
eventually(5.seconds) { receivedMessages shouldHaveSize expectedLevels.size }
receivedMessages.map { it.params.level } shouldBe expectedLevels
}
@Test
fun `sendLoggingMessage should send no messages when level is set to highest`(): Unit = runBlocking {
val receivedMessages = mutableListOf<LoggingMessageNotification>()
val receivedMessages = CopyOnWriteArrayList<LoggingMessageNotification>()
client.setNotificationHandler<LoggingMessageNotification>(Method.Defined.NotificationsMessage) {
receivedMessages.add(it)
CompletableDeferred(Unit)
@ -167,6 +176,7 @@ class ClientConnectionLoggingTest : AbstractServerFeaturesTest() {
}
client.callTool(CallToolRequest(CallToolRequestParams("test-logging-highest")))
receivedMessages.shouldBeEmpty()
// Handlers dispatch asynchronously, so hold for a window to confirm nothing ever arrives.
continually(500.milliseconds) { receivedMessages.shouldBeEmpty() }
}
}

View file

@ -65,6 +65,8 @@ class ServerSessionInitializeTest {
assertEquals("test-client", session.clientVersion?.name)
}
// Both initialize requests arrive before notifications/initialized, in the serial dispatch
// phase, so processing and response order stay deterministic.
@Test
fun `should reject duplicate initialize request`() = runTest {
val session = createSession()