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
21 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()

View file

@ -52,8 +52,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/client/ClientKt {
public final class io/modelcontextprotocol/kotlin/sdk/client/ClientOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions {
public fun <init> ()V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;Z)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;ZLkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ClientCapabilities;
}

View file

@ -73,11 +73,13 @@ import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.cancellation.CancellationException
private val logger = KotlinLogging.logger {}
@ -87,11 +89,16 @@ private val logger = KotlinLogging.logger {}
*
* @property capabilities The capabilities this client supports.
* @param enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server.
* @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext].
*/
public class ClientOptions(
public val capabilities: ClientCapabilities = ClientCapabilities(),
enforceStrictCapabilities: Boolean = true,
) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities)
handlerCoroutineContext: CoroutineContext = Dispatchers.Default,
) : ProtocolOptions(
enforceStrictCapabilities = enforceStrictCapabilities,
handlerCoroutineContext = handlerCoroutineContext,
)
/**
* Initializes and connects an MCP client using the provided clientInfo [Implementation], client options,
@ -216,6 +223,7 @@ public open class Client(private val clientInfo: Implementation, options: Client
serverInstructions = result.instructions
notification(InitializedNotification())
enableConcurrentDispatch()
} catch (error: Throwable) {
logger.error(error) { "Failed to initialize client: ${error.message}" }
close()

View file

@ -59,6 +59,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public abstract fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V
public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
protected final fun enableConcurrentDispatch ()V
public final fun getFallbackNotificationHandler ()Lkotlin/jvm/functions/Function2;
public final fun getFallbackRequestHandler ()Lkotlin/jvm/functions/Function3;
public final fun getNotificationHandlers ()Ljava/util/Map;
@ -71,6 +72,7 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public static synthetic fun notification$default (Lio/modelcontextprotocol/kotlin/sdk/shared/Protocol;Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public fun onClose ()V
public fun onError (Ljava/lang/Throwable;)V
protected fun onInitializedNotification ()V
public final fun removeNotificationHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V
public final fun removeRequestHandler (Lio/modelcontextprotocol/kotlin/sdk/types/Method;)V
public final fun request (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@ -84,15 +86,18 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public final class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolKt {
public static final field IMPLEMENTATION_NAME Ljava/lang/String;
public static final fun currentRequestHandlerExtra (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun getDEFAULT_REQUEST_TIMEOUT ()J
}
public class io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions {
public synthetic fun <init> (ZJILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (ZJLkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (ZJLkotlin/coroutines/CoroutineContext;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (ZJLkotlin/coroutines/CoroutineContext;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getEnforceStrictCapabilities ()Z
public final fun getHandlerCoroutineContext ()Lkotlin/coroutines/CoroutineContext;
public final fun getTimeout-UwyO8pc ()J
public final fun setEnforceStrictCapabilities (Z)V
public final fun setHandlerCoroutineContext (Lkotlin/coroutines/CoroutineContext;)V
public final fun setTimeout-LRDsOJo (J)V
}
@ -107,8 +112,16 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/ReadBufferKt {
public static final fun serializeMessage (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;)Ljava/lang/String;
}
public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra {
public fun <init> ()V
public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra : kotlin/coroutines/AbstractCoroutineContextElement {
public static final field Key Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key;
public final fun getMethod ()Lio/modelcontextprotocol/kotlin/sdk/types/Method;
public final fun getRequestId ()Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;
public final fun sendNotification (Lio/modelcontextprotocol/kotlin/sdk/types/Notification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendRequest (Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun sendRequest$default (Lio/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra;Lio/modelcontextprotocol/kotlin/sdk/types/Request;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestHandlerExtra$Key : kotlin/coroutines/CoroutineContext$Key {
}
public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions {

View file

@ -27,15 +27,33 @@ import kotlinx.atomicfu.AtomicRef
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.ContinuationInterceptor
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@ -53,17 +71,26 @@ public typealias ProgressCallback = (Progress) -> Unit
/**
* Additional initialization options.
*
* @property enforceStrictCapabilities whether to restrict emitted requests to only those that the
* remote side has indicated it can handle through its advertised capabilities.
* This does NOT affect checking of _local_ side capabilities, as it is considered a logic error
* to mis-specify those.
* Currently defaults to `false` for backwards compatibility with SDK versions that did not advertise
* capabilities correctly; in the future, this will default to `true`.
* [handlerCoroutineContext] is read once when [Protocol.connect] attaches a transport and stays
* fixed until the next [Protocol.connect].
*
* @property enforceStrictCapabilities whether to restrict emitted requests to only those the remote
* side advertised support for. Local-side capability checks are unaffected. Defaults to `false` for
* backward compatibility with SDK versions that did not advertise capabilities correctly.
* @property timeout default timeout for outgoing requests
* @property handlerCoroutineContext coroutine context used to run inbound request and notification
* handlers once initialization has completed. Must contain a real dispatching
* [kotlin.coroutines.ContinuationInterceptor]. `Dispatchers.Unconfined` and unconfined test
* dispatchers are unsupported, since handler resumptions would then run on the transport read loop
* and reintroduce head-of-line blocking. Any [kotlinx.coroutines.Job] in this context is ignored.
*/
public open class ProtocolOptions(
public var enforceStrictCapabilities: Boolean = false,
public var timeout: Duration = DEFAULT_REQUEST_TIMEOUT,
public var handlerCoroutineContext: CoroutineContext = Dispatchers.Default,
// Internal handler-concurrency bounds, read once at connect and not part of the public surface.
internal var maxConcurrentHandlers: Int = DEFAULT_MAX_CONCURRENT_HANDLERS,
internal var maxInFlightHandlers: Int = DEFAULT_MAX_IN_FLIGHT_HANDLERS,
)
/**
@ -71,6 +98,12 @@ public open class ProtocolOptions(
*/
public val DEFAULT_REQUEST_TIMEOUT: Duration = 60.seconds
/** Default cap on concurrently executing inbound handlers per connection. */
internal const val DEFAULT_MAX_CONCURRENT_HANDLERS: Int = 64
/** Default cap on launched-but-not-completed inbound handlers per connection. */
internal const val DEFAULT_MAX_IN_FLIGHT_HANDLERS: Int = 256
/**
* Options that can be given per request.
*
@ -121,7 +154,7 @@ public class RequestOptions(
override fun hashCode(): Int {
var result = super.hashCode()
result = 31 * result + (onProgress?.hashCode() ?: 0)
result = 31 * result + onProgress.hashCode()
result = 31 * result + timeout.hashCode()
return result
}
@ -132,11 +165,70 @@ public class RequestOptions(
/**
* Extra data given to request handlers.
*
* One instance exists per in-flight inbound request. It is passed as the second parameter to
* low-level handlers registered via [Protocol.setRequestHandler] **and** installed into the
* handler coroutine's [CoroutineContext], so code nested anywhere under the handler can reach it
* via [currentRequestHandlerExtra] without parameter plumbing.
*
* @property requestId the JSON-RPC id of the request being handled
* @property method the request's method
*/
public class RequestHandlerExtra
public class RequestHandlerExtra internal constructor(
public val requestId: RequestId,
public val method: Method,
private val protocol: Protocol,
internal val capturedTransport: Transport,
) : AbstractCoroutineContextElement(Key) {
/** Context key for retrieving the extra from a handler coroutine's context. */
public companion object Key : CoroutineContext.Key<RequestHandlerExtra>
/**
* Sends a notification tagged with `relatedRequestId = `[requestId], letting transports
* associate it with the request being handled (e.g. route it onto the request's SSE stream).
*/
public suspend fun sendNotification(notification: Notification) {
protocol.notification(notification, relatedRequestId = requestId)
}
/**
* Sends a request to the peer tagged with `relatedRequestId = `[requestId].
*
* Mirrors [Protocol.request]; cancellation of the handler coroutine propagates into this call.
*/
public suspend fun <T : RequestResult> sendRequest(request: Request, options: RequestOptions? = null): T {
val base = options ?: RequestOptions()
return protocol.request(
request,
RequestOptions(
relatedRequestId = requestId,
resumptionToken = base.resumptionToken,
onResumptionToken = base.onResumptionToken,
onProgress = base.onProgress,
timeout = base.timeout,
),
)
}
}
/** The [RequestHandlerExtra] of the MCP request being handled by the current coroutine, or `null` outside a handler. */
public suspend fun currentRequestHandlerExtra(): RequestHandlerExtra? = currentCoroutineContext()[RequestHandlerExtra]
internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) }
/** Cap on how many recently-cancelled outbound request ids are remembered to quiet late responses/progress. */
private const val CANCELLED_REQUEST_IDS_REMEMBERED = 256
// Bypass set: exempt from both bound tiers so the connection cannot self-block.
// Responses are not listed — they are handled inline before dispatch.
private val CONTROL_METHODS: Set<String> = setOf(
Method.Defined.Ping.value,
Method.Defined.NotificationsCancelled.value,
Method.Defined.NotificationsProgress.value,
Method.Defined.NotificationsInitialized.value,
)
/**
* Implements MCP protocol framing on top of a pluggable transport, including
* features like request/response linking, notifications, and progress.
@ -144,9 +236,11 @@ internal val COMPLETED = CompletableDeferred(Unit).also { it.complete(Unit) }
* @property options protocol-level configuration; `null` falls back to defaults
*/
public abstract class Protocol(@PublishedApi internal val options: ProtocolOptions?) {
private val connectionRef: AtomicRef<Connection?> = atomic(null)
/** The active transport, or `null` if not connected. */
public var transport: Transport? = null
private set
public val transport: Transport?
get() = connectionRef.value?.transport
private val _requestHandlers:
AtomicRef<PersistentMap<String, suspend (JSONRPCRequest, RequestHandlerExtra) -> RequestResult?>> =
@ -184,6 +278,21 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
public val progressHandlers: Map<ProgressToken, ProgressCallback>
get() = _progressHandlers.value
private val recentlyCancelledRequestIds: AtomicRef<PersistentList<RequestId>> =
atomic(persistentListOf())
/** Remembers a locally-cancelled outbound request id so its late response/progress is quieted. */
private fun rememberCancelledRequestId(id: RequestId) {
recentlyCancelledRequestIds.update { current ->
val appended = current.adding(id)
if (appended.size > CANCELLED_REQUEST_IDS_REMEMBERED) appended.removingAt(0) else appended
}
}
/** Whether [id] was recently cancelled locally (see [rememberCancelledRequestId]). */
private fun isRecentlyCancelled(id: RequestId?): Boolean =
id != null && recentlyCancelledRequestIds.value.contains(id)
/**
* Callback for when the connection is closed for any reason.
*
@ -213,6 +322,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
public var fallbackNotificationHandler: (suspend (notification: JSONRPCNotification) -> Unit)? = null
init {
setNotificationHandler<CancelledNotification>(Method.Defined.NotificationsCancelled) { notification ->
handleCancelledNotification(notification)
COMPLETED
}
setNotificationHandler<ProgressNotification>(Method.Defined.NotificationsProgress) { notification ->
onProgress(notification)
COMPLETED
@ -223,15 +337,64 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
}
/**
* Enables concurrent dispatch of inbound messages on the current connection.
*
* Called by subclasses when the MCP initialization phase completes (the client after the
* initialize exchange; the server session on receiving `notifications/initialized`). Until
* then, inbound messages are handled inline in arrival order. Reset by [connect].
*/
protected fun enableConcurrentDispatch() {
val connection = connectionRef.value ?: return
connection.concurrentDispatchEnabled.value = true
}
/**
* Router hook invoked whenever `notifications/initialized` is received, before handler lookup.
*
* Runs regardless of which notification handler is registered for the method, so replacing the
* handler via [setNotificationHandler] cannot disable subclass initialization logic.
*/
protected open fun onInitializedNotification() {}
/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
* The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
* The Protocol object assumes ownership of the Transport, replacing any callbacks that have
* already been set, and expects that it is the only user of the Transport instance going forward.
*
* @throws IllegalStateException if this Protocol is already connected; call [close] first.
*/
public open suspend fun connect(transport: Transport) {
this.transport = transport
val maxConcurrent = options?.maxConcurrentHandlers ?: DEFAULT_MAX_CONCURRENT_HANDLERS
val maxInFlight = options?.maxInFlightHandlers ?: DEFAULT_MAX_IN_FLIGHT_HANDLERS
require(maxConcurrent > 0) { "maxConcurrentHandlers must be positive, but was $maxConcurrent" }
require(maxInFlight >= maxConcurrent) {
"maxInFlightHandlers ($maxInFlight) must be >= maxConcurrentHandlers ($maxConcurrent)"
}
val handlerContext = (options?.handlerCoroutineContext ?: Dispatchers.Default).minusKey(Job)
val interceptor = handlerContext[ContinuationInterceptor]
if (interceptor === Dispatchers.Unconfined ||
interceptor?.let { it::class.simpleName?.contains("Unconfined") } == true
) {
logger.warn {
"handlerCoroutineContext uses an unconfined dispatcher ($interceptor); " +
"handler resumptions will run on the transport read loop and may block unrelated messages"
}
}
val connection = Connection(
transport = transport,
handlerScope = CoroutineScope(SupervisorJob() + handlerContext + CoroutineName("McpProtocol")),
executionSemaphore = Semaphore(maxConcurrent),
maxInFlightHandlers = maxInFlight,
)
if (!connectionRef.compareAndSet(null, connection)) {
connection.handlerScope.cancel()
error("Protocol is already connected; close() the current connection before connecting a new transport")
}
transport.onClose {
doClose()
doClose(connection)
}
transport.onError {
@ -239,24 +402,42 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
transport.onMessage { message ->
if (connectionRef.value !== connection) {
logger.trace { "Dropping message received after close: ${message::class.simpleName}" }
return@onMessage
}
when (message) {
is JSONRPCResponse -> onResponse(message, null)
is JSONRPCRequest -> onRequest(message)
is JSONRPCNotification -> onNotification(message)
is JSONRPCRequest -> dispatchRequest(connection, message)
is JSONRPCNotification -> dispatchNotification(connection, message)
is JSONRPCError -> onResponse(null, message)
is JSONRPCEmptyMessage -> Unit
}
}
logger.info { "Starting transport" }
transport.start()
try {
transport.start()
} catch (cause: Throwable) {
// Roll back so the Protocol can be reconnected after a failed start; idempotent with
// a transport-initiated doClose (both CAS on the same connection).
if (connectionRef.compareAndSet(connection, null)) {
connection.handlerScope.cancel()
}
throw cause
}
}
private fun doClose() {
val handlersToNotify = _responseHandlers.value.values.toList()
_responseHandlers.getAndSet(persistentMapOf())
private fun doClose(connection: Connection) {
// A stale onClose from a previous transport must not tear down the successor connection.
if (!connectionRef.compareAndSet(connection, null)) {
logger.trace { "Ignoring close signal from a stale transport" }
return
}
val handlersToNotify = _responseHandlers.getAndSet(persistentMapOf()).values.toList()
_progressHandlers.getAndSet(persistentMapOf())
transport = null
connection.inFlightRequestJobs.getAndSet(persistentMapOf())
connection.handlerScope.cancel()
onClose()
val error = McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed")
@ -265,6 +446,110 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
}
private suspend fun dispatchRequest(connection: Connection, request: JSONRPCRequest) {
if (!connection.concurrentDispatchEnabled.value) {
// Serial phase (before the peer completes MCP initialization): inline, in arrival
// order — the delivering coroutine awaits the handler.
onRequest(request, connection, trackCancellation = false)
return
}
val bypass = request.method in CONTROL_METHODS
if (!bypass && !tryAdmit(connection)) {
rejectOverloadedRequest(connection, request)
return
}
// UNDISPATCHED starts the handler synchronously in the delivering coroutine, preserving
// arrival order, then moves to handlerCoroutineContext at its first suspension.
connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) {
// Register before any suspension point so a following notifications/cancelled can find
// and cancel even a handler still parked on admission.
registerInFlight(connection, request.id, coroutineContext.job)
try {
if (bypass) {
onRequest(request, connection, trackCancellation = true)
} else {
connection.executionSemaphore.withPermit {
onRequest(request, connection, trackCancellation = true)
}
}
} finally {
if (!bypass) connection.inFlightCount.decrementAndGet()
}
}
}
private suspend fun dispatchNotification(connection: Connection, notification: JSONRPCNotification) {
if (notification.method == Method.Defined.NotificationsInitialized.value) {
onInitializedNotification()
}
if (!connection.concurrentDispatchEnabled.value) {
onNotification(notification)
return
}
val bypass = notification.method in CONTROL_METHODS
if (!bypass && !tryAdmit(connection)) {
val dropped = IllegalStateException(
"Dropped notification ${notification.method}: too many in-flight messages",
)
logger.warn { dropped.message }
onError(dropped)
return
}
connection.handlerScope.launch(start = CoroutineStart.UNDISPATCHED) {
try {
if (bypass) {
onNotification(notification)
} else {
connection.executionSemaphore.withPermit { onNotification(notification) }
}
} finally {
if (!bypass) connection.inFlightCount.decrementAndGet()
}
}
}
private fun tryAdmit(connection: Connection): Boolean {
while (true) {
val current = connection.inFlightCount.value
if (current >= connection.maxInFlightHandlers) return false
if (connection.inFlightCount.compareAndSet(current, current + 1)) return true
}
}
private suspend fun rejectOverloadedRequest(connection: Connection, request: JSONRPCRequest) {
logger.warn {
"Rejecting request ${request.method} (id: ${request.id}): " +
"in-flight handler limit (${connection.maxInFlightHandlers}) reached"
}
try {
connection.transport.send(
JSONRPCError(
id = request.id,
error = RPCError(
code = RPCError.ErrorCode.INTERNAL_ERROR,
message = "Server is busy: too many in-flight messages",
),
),
)
} catch (e: CancellationException) {
throw e
} catch (cause: Throwable) {
logger.error(cause) { "Failed to send busy rejection for request id: ${request.id}" }
onError(cause)
}
}
private fun handleCancelledNotification(notification: CancelledNotification) {
val connection = connectionRef.value ?: return
val requestId = notification.params.requestId
val job = connection.inFlightRequestJobs.value[requestId]
if (job == null) {
logger.trace { "Ignoring cancellation for unknown or already-completed request: $requestId" }
return
}
job.cancel("Cancelled by peer: ${notification.params.reason ?: "unknown"}")
}
private suspend fun onNotification(notification: JSONRPCNotification) {
logger.trace { "Received notification: ${notification.method}" }
@ -284,15 +569,16 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
}
private suspend fun onRequest(request: JSONRPCRequest) {
private suspend fun onRequest(request: JSONRPCRequest, connection: Connection, trackCancellation: Boolean) {
logger.trace { "Received request: ${request.method} (id: ${request.id})" }
val handler = requestHandlers[request.method] ?: fallbackRequestHandler
val capturedTransport = connection.transport
if (handler === null) {
logger.trace { "No handler found for request: ${request.method}" }
try {
transport?.send(
capturedTransport.send(
JSONRPCError(
id = request.id,
error = RPCError(
@ -310,36 +596,77 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
return
}
try {
val result = handler(request, RequestHandlerExtra())
logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
val extra = RequestHandlerExtra(
requestId = request.id,
method = Method.from(request.method),
protocol = this,
capturedTransport = capturedTransport,
)
transport?.send(
JSONRPCResponse(
id = request.id,
result = result ?: EmptyResult(),
),
)
// Registration in the in-flight registry already happened in dispatchRequest's launch
// block (synchronously, pre-suspension). Here we only need the job for suppression checks.
val handlerJob = if (trackCancellation) currentCoroutineContext().job else null
withContext(extra) {
try {
val result = handler(request, extra)
logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
if (handlerJob?.isCancelled == true) {
logger.trace { "Suppressing response for request cancelled by peer (id: ${request.id})" }
return@withContext
}
capturedTransport.send(
JSONRPCResponse(
id = request.id,
result = result ?: EmptyResult(),
),
)
} catch (e: CancellationException) {
// Intentional exception to the always-rethrow-CE convention, only at this boundary.
if (handlerJob == null) throw e // serial phase: inline dispatch is not response-suppressed
if (handlerJob.isCancelled) throw e // genuine peer or close cancel: suppress the response
// CE escaped a live handler job (e.g. a leaked inner withTimeout). Answer
// INTERNAL_ERROR so the peer does not hang until its own timeout.
respondWithError(capturedTransport, request, e)
} catch (cause: Throwable) {
if (handlerJob?.isCancelled == true) {
logger.trace { "Suppressing error response for request cancelled by peer (id: ${request.id})" }
return@withContext
}
respondWithError(capturedTransport, request, cause)
}
}
}
private fun registerInFlight(connection: Connection, id: RequestId, job: Job) {
val previous = connection.inFlightRequestJobs.getAndUpdate { current -> current.putting(id, job) }[id]
if (previous != null) {
logger.warn { "Duplicate in-flight request id $id; replacing the previous handler job" }
}
job.invokeOnCompletion {
// Identity guard: never evict a successor job registered under a reused id.
connection.inFlightRequestJobs.update { current ->
if (current[id] === job) current.removing(id) else current
}
}
}
private suspend fun respondWithError(transport: Transport, request: JSONRPCRequest, cause: Throwable) {
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
try {
val rpcError = if (cause is McpException) {
RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
} else {
RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
}
transport.send(JSONRPCError(id = request.id, error = rpcError))
} catch (e: CancellationException) {
throw e
} catch (cause: Throwable) {
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
try {
val rpcError = if (cause is McpException) {
RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
} else {
RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
}
transport?.send(JSONRPCError(id = request.id, error = rpcError))
} catch (e: CancellationException) {
throw e
} catch (sendError: Throwable) {
logger.error(sendError) {
"Failed to send error response for request: ${request.method} (id: ${request.id})"
}
// Optionally implement fallback behavior here
} catch (sendError: Throwable) {
logger.error(sendError) {
"Failed to send error response for request: ${request.method} (id: ${request.id})"
}
onError(sendError)
}
}
@ -354,6 +681,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val handler = _progressHandlers.value[progressToken]
if (handler == null) {
if (isRecentlyCancelled(progressToken)) {
logger.trace { "Ignoring progress for a locally cancelled request: $progressToken" }
return
}
val error = Error(
"Received a progress notification for an unknown token: ${McpJson.encodeToString(notification)}",
)
@ -370,7 +701,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val oldResponseHandlers = _responseHandlers.getAndUpdate { current ->
if (messageId != null && messageId in current) {
current.remove(messageId)
current.removing(messageId)
} else {
current
}
@ -379,8 +710,12 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val handler = oldResponseHandlers[messageId]
if (handler != null) {
messageId?.let { msg -> _progressHandlers.update { it.remove(msg) } }
messageId?.let { msg -> _progressHandlers.update { it.removing(msg) } }
} else {
if (isRecentlyCancelled(messageId)) {
logger.trace { "Ignoring response for a locally cancelled request: $messageId" }
return
}
onError(
IllegalStateException(
"Received a response for an unknown message ID: ${McpJson.encodeToString(error ?: response)}",
@ -438,7 +773,8 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
public suspend fun <T : RequestResult> request(request: Request, options: RequestOptions? = null): T {
logger.trace { "Sending request: ${request.method}" }
val result = CompletableDeferred<T>()
val transport = transport ?: error("Not connected")
val connection = connectionRef.value ?: error("Not connected")
val transport = connection.transport
if (this@Protocol.options?.enforceStrictCapabilities == true) {
assertCapabilityForMethod(request.method)
@ -448,7 +784,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
options?.onProgress?.let { progressHandler ->
logger.trace { "Registering progress handler for request id: $id" }
_progressHandlers.update { current ->
current.put(id, progressHandler)
current.putting(id, progressHandler)
}
val paramsObject = (this.params as? JsonObject) ?: JsonObject(emptyMap())
@ -467,10 +803,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val jsonRpcRequestId = jsonRpcRequest.id
_responseHandlers.update { current ->
current.put(jsonRpcRequestId) { response, error ->
current.putting(jsonRpcRequestId) { response, error ->
if (error != null) {
result.completeExceptionally(error)
return@put
return@putting
}
try {
@ -482,42 +818,84 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
}
val cancel: suspend (Throwable) -> Unit = { reason: Throwable ->
_responseHandlers.update { current -> current.remove(jsonRpcRequestId) }
_progressHandlers.update { current -> current.remove(jsonRpcRequestId) }
if (connectionRef.value !== connection) {
// Connection closed or replaced during registration; nothing would complete this handler.
_responseHandlers.update { it.removing(jsonRpcRequestId) }
_progressHandlers.update { it.removing(jsonRpcRequestId) }
throw McpException(RPCError.ErrorCode.CONNECTION_CLOSED, "Connection closed")
}
val notification = CancelledNotification(
params = CancelledNotificationParams(
requestId = jsonRpcRequestId,
reason = reason.message ?: "Unknown",
),
)
val cancelPending: suspend (reason: Throwable, notifyPeer: Boolean) -> Unit = { reason, notifyPeer ->
// Remember before removing: a racing onResponse/onProgress that sees the handler gone
// must also see the id, or it reports a late message as unknown. Order is load-bearing.
rememberCancelledRequestId(jsonRpcRequestId)
_responseHandlers.update { current -> current.removing(jsonRpcRequestId) }
_progressHandlers.update { current -> current.removing(jsonRpcRequestId) }
val jsonRpcNotification = notification.toJSON()
transport.send(jsonRpcNotification, options)
if (notifyPeer) {
val notification = CancelledNotification(
params = CancelledNotificationParams(
requestId = jsonRpcRequestId,
reason = reason.message ?: "Unknown",
),
)
transport.send(notification.toJSON(), options)
}
result.completeExceptionally(reason)
}
// The MCP spec forbids cancelling `initialize`; local cleanup still runs,
// but no notifications/cancelled goes on the wire (timeout and cancel paths alike).
val notifyPeerOnCancel = request.method != Method.Defined.Initialize
val timeout = options?.timeout ?: DEFAULT_REQUEST_TIMEOUT
try {
withTimeout(timeout) {
val response = withTimeoutOrNull(timeout) {
logger.trace { "Sending request message with id: $jsonRpcRequestId" }
this@Protocol.transport?.send(jsonRpcRequest, options)
transport.send(jsonRpcRequest, options)
result.await()
}
return result.await()
} catch (cause: TimeoutCancellationException) {
logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" }
cancel(
McpException(
if (response == null) {
// Our own request timeout expired. An outer withTimeout's cancellation propagates
// through withTimeoutOrNull instead of returning null and is handled by the catch below.
logger.error { "Request timed out after ${timeout.inWholeMilliseconds}ms: ${request.method}" }
val timeoutError = McpException(
code = RPCError.ErrorCode.REQUEST_TIMEOUT,
message = "Request timed out",
data = JsonObject(mutableMapOf("timeout" to JsonPrimitive(timeout.inWholeMilliseconds))),
),
)
result.cancel(cause)
)
withContext(NonCancellable) {
try {
cancelPending(timeoutError, notifyPeerOnCancel)
} catch (e: Throwable) {
logger.warn(e) { "Failed to notify peer about timed-out request" }
onError(e)
}
}
result.cancel()
throw timeoutError
}
return response
} catch (cause: CancellationException) {
// Plain caller cancellation, or an outer withTimeout that propagated through
// withTimeoutOrNull (only our own deadline returns null there). NonCancellable so the
// cancellation notification still goes out even though the caller's job is cancelled.
// A secondary send failure must never replace the original CancellationException.
withContext(NonCancellable) {
try {
cancelPending(cause, notifyPeerOnCancel)
} catch (e: Throwable) {
logger.warn(e) { "Failed to notify peer about cancelled request" }
onError(e)
}
}
throw cause
} finally {
// Backstop for the failed-send path. The success, timeout and cancellation paths already
// removed these handlers, so this only bites when the outbound send failed.
_responseHandlers.update { it.removing(jsonRpcRequestId) }
_progressHandlers.update { it.removing(jsonRpcRequestId) }
}
}
@ -538,6 +916,11 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
* Registers a handler to invoke when this protocol object receives a request with the given method.
*
* Note that this will replace any previous request handler for the same method.
*
* Handlers may run concurrently after initialization and are cancelled cooperatively, so always
* re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`,
* `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow
* code degrades connection liveness.
*/
public inline fun <reified T : Request> setRequestHandler(
method: Method,
@ -556,7 +939,7 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val wrapped = wrapRequestHandler(method, block)
_requestHandlers.update { current ->
current.put(method.value) { jSONRPCRequest, extraHandler ->
current.putting(method.value) { jSONRPCRequest, extraHandler ->
val request = jSONRPCRequest.fromJSON()
val response = wrapped(request as T, extraHandler)
response
@ -582,17 +965,23 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
* Removes the request handler for the given method.
*/
public fun removeRequestHandler(method: Method) {
_requestHandlers.update { current -> current.remove(method.value) }
_requestHandlers.update { current -> current.removing(method.value) }
}
/**
* Registers a handler to invoke when this protocol object receives a notification with the given method.
*
* Note that this will replace any previous notification handler for the same method.
*
* Handlers may run concurrently after initialization and are cancelled cooperatively, so always
* re-throw [CancellationException]. Replacing a built-in control-method handler (`ping`,
* `notifications/cancelled`, `notifications/progress`, `notifications/initialized`) with slow
* code degrades connection liveness. Replacing `notifications/cancelled` in particular disables
* inbound request cancellation entirely, since that handler is what cancels the running job.
*/
public fun <T : Notification> setNotificationHandler(method: Method, handler: (notification: T) -> Deferred<Unit>) {
_notificationHandlers.update { current ->
current.put(method.value) {
current.putting(method.value) {
@Suppress("UNCHECKED_CAST")
handler(it.fromJSON() as T)
}
@ -603,6 +992,22 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
* Removes the notification handler for the given method.
*/
public fun removeNotificationHandler(method: Method) {
_notificationHandlers.update { current -> current.remove(method.value) }
_notificationHandlers.update { current -> current.removing(method.value) }
}
private class Connection(
val transport: Transport,
val handlerScope: CoroutineScope,
val executionSemaphore: Semaphore,
val maxInFlightHandlers: Int,
) {
/** Init gate: inbound dispatch is inline/serial until this flips. */
val concurrentDispatchEnabled = atomic(false)
/** Admission tier: launched-but-not-completed handler jobs (running + parked). */
val inFlightCount = atomic(0)
/** In-flight request handler jobs, for `notifications/cancelled`. */
val inFlightRequestJobs: AtomicRef<PersistentMap<RequestId, Job>> = atomic(persistentMapOf())
}
}

View file

@ -53,4 +53,13 @@ public sealed interface Method {
*/
@Serializable
public data class Custom(override val value: String) : Method
public companion object {
private val definedByValue: Map<String, Defined> by lazy {
Defined.entries.associateBy { it.value }
}
/** Resolves a wire method string to a [Defined] entry when known, [Custom] otherwise. */
internal fun from(value: String): Method = definedByValue[value] ?: Custom(value)
}
}

View file

@ -59,18 +59,11 @@ internal object MethodSerializer : KSerializer<Method> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("io.modelcontextprotocol.kotlin.sdk.types.Method", PrimitiveKind.STRING)
private val definedMethods: Map<String, Method.Defined> by lazy {
Method.Defined.entries.associateBy { it.value }
}
override fun serialize(encoder: Encoder, value: Method) {
encoder.encodeString(value.value)
}
override fun deserialize(decoder: Decoder): Method {
val decodedString = decoder.decodeString()
return definedMethods[decodedString] ?: Method.Custom(decodedString)
}
override fun deserialize(decoder: Decoder): Method = Method.from(decoder.decodeString())
}
// ============================================================================

View file

@ -0,0 +1,524 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
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.McpException
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class ProtocolConcurrencyTest {
private fun TestScope.newProtocol(): TestProtocol =
TestProtocol(ProtocolOptions(handlerCoroutineContext = StandardTestDispatcher(testScheduler)))
@Test
fun `a suspended slow handler does not block a later fast request`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/slow") delay(10.seconds)
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow"))
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast"))
runCurrent()
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L))
advanceTimeBy(11.seconds)
runCurrent()
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L), RequestId(1L))
}
@Test
fun `dispatch is serial until concurrent dispatch is enabled`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
// gate NOT flipped
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/slow") delay(10.seconds)
EmptyResult()
}
val delivery = launch {
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow"))
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast"))
}
runCurrent()
// inline handling: the delivering coroutine is parked inside the slow handler
delivery.isCompleted shouldBe false
responsesOn(transport) shouldBe emptyList()
advanceTimeBy(11.seconds)
runCurrent()
delivery.isCompleted shouldBe true
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L))
}
// the hook fires before handler lookup and cannot be disabled by a user handler
@Test
fun `initialized notification invokes the router hook even when a user handler is registered`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
var userHandlerCalled = false
protocol.setNotificationHandler<InitializedNotification>(Method.Defined.NotificationsInitialized) {
userHandlerCalled = true
COMPLETED
}
transport.deliver(JSONRPCNotification(method = Method.Defined.NotificationsInitialized.value))
runCurrent()
protocol.initializedNotificationCount shouldBe 1
userHandlerCalled shouldBe true
}
@Test
fun `cancelled notification cancels the in-flight handler and suppresses the response`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val entered = CompletableDeferred<Unit>()
val sawCancellation = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
entered.complete(Unit)
try {
delay(10.seconds)
} catch (e: CancellationException) {
sawCancellation.complete(Unit)
throw e
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(5L), method = "test/slow"))
runCurrent()
entered.await()
transport.deliver(cancelledNotification(requestId = RequestId(5L), reason = "user gave up"))
runCurrent()
sawCancellation.await()
advanceUntilIdle()
responsesOn(transport) shouldBe emptyList()
errorsOn(transport) shouldBe emptyList() // JSONRPCError messages on the wire
protocol.errors shouldBe emptyList()
}
@Test
fun `cancelled notification for an unknown id is a silent no-op`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
transport.deliver(cancelledNotification(requestId = RequestId(404L), reason = "nope"))
advanceUntilIdle()
protocol.errors shouldBe emptyList()
}
@Test
fun `handler leaking CancellationException without being cancelled produces INTERNAL_ERROR`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
protocol.fallbackRequestHandler = { _, _ -> throw CancellationException("leaked") }
transport.deliver(JSONRPCRequest(id = RequestId(6L), method = "test/leak"))
advanceUntilIdle()
val error = errorsOn(transport).single()
error.id shouldBe RequestId(6L)
error.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR
}
// suppression race variants
@Test
fun `handler that swallows cancellation and returns normally still gets no response`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val entered = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
entered.complete(Unit)
try {
delay(10.seconds)
} catch (_: CancellationException) {
// swallow (anti-pattern) and return normally
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "test/swallow"))
runCurrent()
entered.await()
transport.deliver(cancelledNotification(requestId = RequestId(7L), reason = "stop"))
advanceUntilIdle()
responsesOn(transport) shouldBe emptyList()
}
@Test
fun `handler throwing non-CE while cancelled gets no error response`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val entered = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
entered.complete(Unit)
try {
delay(10.seconds)
} catch (_: CancellationException) {
throw IllegalStateException("boom")
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(8L), method = "test/throw"))
runCurrent()
entered.await()
transport.deliver(cancelledNotification(requestId = RequestId(8L), reason = "stop"))
advanceUntilIdle()
errorsOn(transport) shouldBe emptyList()
protocol.errors shouldBe emptyList()
}
@Test
fun `suspension-free notification handlers observe strict arrival order`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val seen = mutableListOf<Int>()
protocol.fallbackNotificationHandler = { notification ->
seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int)
}
repeat(20) { i ->
transport.deliver(
JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) }),
)
}
advanceUntilIdle()
seen shouldBe (0 until 20).toList()
}
@Test
fun `reconnect resets the dispatch gate to serial`() = runTest {
val protocol = newProtocol()
val transport1 = RecordingTransport()
protocol.connect(transport1)
protocol.enableConcurrency()
protocol.close()
val transport2 = RecordingTransport()
protocol.connect(transport2)
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/slow") delay(10.seconds)
EmptyResult()
}
val delivery = launch {
transport2.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow"))
transport2.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast"))
}
runCurrent()
delivery.isCompleted shouldBe false // serial again: gate reset
advanceTimeBy(11.seconds)
runCurrent()
responsesOn(transport2).map { it.id } shouldBe listOf(RequestId(1L), RequestId(2L))
}
@Test
fun `close cancels in-flight handlers suppresses responses and fails pending outbound requests`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val entered = CompletableDeferred<Unit>()
val sawCancellation = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
entered.complete(Unit)
try {
delay(10.seconds)
} catch (e: CancellationException) {
sawCancellation.complete(Unit)
throw e
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(9L), method = "test/slow"))
runCurrent()
entered.await()
// shouldThrow inside the async so its failure does not cancel the (non-supervisor) test scope.
val pending = async { shouldThrow<McpException> { protocol.request<EmptyResult>(PingRequest()) } }
transport.awaitRequest() // outbound ping on the wire
protocol.close()
runCurrent()
sawCancellation.await()
responsesOn(transport) shouldBe emptyList()
val thrown = pending.await()
thrown.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED
}
@Test
fun `message delivered after close is dropped without crash or response`() = runTest {
val protocol = newProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
protocol.close()
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/anything"))
advanceUntilIdle()
responsesOn(transport) shouldBe emptyList()
errorsOn(transport) shouldBe emptyList()
}
@Test
fun `at most maxConcurrentHandlers handlers run concurrently while control messages bypass`() = runTest {
val protocol = TestProtocol(
ProtocolOptions(
handlerCoroutineContext = StandardTestDispatcher(testScheduler),
maxConcurrentHandlers = 2,
),
)
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
var entered = 0
val release = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
entered += 1
release.await()
EmptyResult()
}
repeat(4) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) }
runCurrent()
entered shouldBe 2 // exactly two running, two parked on the semaphore
// ping (bypass) is answered while saturated
transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value))
runCurrent()
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L))
// responses (bypass, inline) are processed while saturated
val pending = async { protocol.request<EmptyResult>(PingRequest()) }
runCurrent()
val outbound = transport.awaitRequest()
transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult()))
runCurrent()
pending.isCompleted shouldBe true
release.complete(Unit)
advanceUntilIdle()
entered shouldBe 4
responsesOn(transport) shouldHaveSize 5
}
@Test
fun `flood beyond maxInFlightHandlers is rejected fail-fast and the read loop never suspends`() = runTest {
val protocol = TestProtocol(
ProtocolOptions(
handlerCoroutineContext = StandardTestDispatcher(testScheduler),
maxConcurrentHandlers = 1,
maxInFlightHandlers = 4,
),
)
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val release = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
release.await()
EmptyResult()
}
// 10 requests delivered back-to-back; delivery must complete without advancing
// virtual time — i.e. the read loop was never suspended on admission.
val delivery = launch {
repeat(10) { i -> transport.deliver(JSONRPCRequest(id = RequestId(i.toLong()), method = "test/slow")) }
}
runCurrent()
delivery.isCompleted shouldBe true
// 4 admitted (1 running + 3 parked), 6 rejected immediately
val busyErrors = errorsOn(transport)
busyErrors shouldHaveSize 6
busyErrors.forEach {
it.error.code shouldBe RPCError.ErrorCode.INTERNAL_ERROR
it.error.message shouldBe "Server is busy: too many in-flight messages"
}
// ping still answered at full saturation
transport.deliver(JSONRPCRequest(id = RequestId(100L), method = Method.Defined.Ping.value))
runCurrent()
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(100L))
release.complete(Unit)
advanceUntilIdle()
responsesOn(transport) shouldHaveSize 5 // ping + the 4 admitted
}
@Test
fun `overflowing notifications are dropped and reported via onError`() = runTest {
val protocol = TestProtocol(
ProtocolOptions(
handlerCoroutineContext = StandardTestDispatcher(testScheduler),
maxConcurrentHandlers = 1,
maxInFlightHandlers = 2,
),
)
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
// parks one handler with a request: holds the sole execution permit and one in-flight slot
val release = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { _, _ ->
release.await()
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog"))
runCurrent()
// notification 1 is admitted (parked on the semaphore, behind the hogged permit);
// notification 2 overflows the admission cap and is dropped instead of parked.
val notificationRelease = CompletableDeferred<Unit>()
protocol.fallbackNotificationHandler = { notificationRelease.await() }
transport.deliver(JSONRPCNotification(method = "test/event"))
transport.deliver(JSONRPCNotification(method = "test/event"))
runCurrent()
protocol.errors shouldHaveSize 1
protocol.errors.single().message.orEmpty() shouldContain "too many in-flight messages"
errorsOn(transport) shouldBe emptyList() // no wire error for dropped notifications
release.complete(Unit)
notificationRelease.complete(Unit)
advanceUntilIdle()
}
@Test
fun `cancelling saturating handlers releases permits and lets parked handlers run`() = runTest {
val protocol = TestProtocol(
ProtocolOptions(
handlerCoroutineContext = StandardTestDispatcher(testScheduler),
maxConcurrentHandlers = 1,
),
)
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val secondCompleted = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/hog") {
delay(1000.seconds) // cancellable park
error("unreachable")
}
secondCompleted.complete(Unit)
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore
runCurrent()
secondCompleted.isCompleted shouldBe false
// cancel the hog THROUGH the bypass while saturated
transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room"))
advanceUntilIdle()
secondCompleted.isCompleted shouldBe true
responsesOn(transport).map { it.id } shouldBe listOf(RequestId(2L)) // hog suppressed, next answered
}
@Test
fun `a parked handler can itself be cancelled before it ever runs`() = runTest {
val protocol = TestProtocol(
ProtocolOptions(
handlerCoroutineContext = StandardTestDispatcher(testScheduler),
maxConcurrentHandlers = 1,
),
)
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/hog") {
delay(1000.seconds) // cancellable park
error("unreachable")
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/hog")) // holds the permit
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/next")) // parked on the semaphore
runCurrent()
// cancel the parked handler before it ever acquires the permit, then cancel the hog too
transport.deliver(cancelledNotification(requestId = RequestId(2L), reason = "give up while parked"))
transport.deliver(cancelledNotification(requestId = RequestId(1L), reason = "make room"))
advanceUntilIdle()
// no response for id=2 at all: its withPermit acquire was cancelled before onRequest ever ran
responsesOn(transport) shouldBe emptyList()
errorsOn(transport) shouldBe emptyList()
}
}

View file

@ -0,0 +1,16 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlin.test.Test
class ProtocolOptionsTest {
@Test
fun `defaults expose concurrency knobs`() {
val options = ProtocolOptions()
options.handlerCoroutineContext shouldBe Dispatchers.Default
options.maxConcurrentHandlers shouldBe 64
options.maxInFlightHandlers shouldBe 256
}
}

View file

@ -0,0 +1,167 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
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.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class ProtocolOutboundCancellationTest {
@Test
fun `request times out awaiting the response and sends CancelledNotification with timeout reason`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
val inFlight = async {
shouldThrow<McpException> {
protocol.request<EmptyResult>(PingRequest(), RequestOptions(timeout = 5.seconds))
}
}
val sent = transport.awaitRequest() // request is on the wire; peer never responds
advanceTimeBy(6.seconds)
runCurrent()
val thrown = inFlight.await()
thrown.code shouldBe RPCError.ErrorCode.REQUEST_TIMEOUT
thrown.data?.jsonObject?.get("timeout")?.jsonPrimitive?.long shouldBe 5.seconds.inWholeMilliseconds
val cancelledJson = transport.sentWithOptions
.map { it.first }
.filterIsInstance<JSONRPCNotification>()
.single { it.method == Method.Defined.NotificationsCancelled.value }
val cancelledParams = McpJson.decodeFromJsonElement<CancelledNotificationParams>(cancelledJson.params!!)
cancelledParams.requestId shouldBe sent.id
cancelledParams.reason.shouldNotBeNull() shouldContain "timed out"
}
@Test
fun `outer withTimeout around request propagates the original timeout exception and notifies the peer`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
val inFlight = async {
shouldThrow<TimeoutCancellationException> {
withTimeout(1.seconds) {
protocol.request<EmptyResult>(PingRequest())
}
}
}
transport.awaitRequest() // request is on the wire; peer never responds
advanceTimeBy(2.seconds)
runCurrent()
inFlight.await()
transport.sentWithOptions.map { it.first }.filterIsInstance<JSONRPCNotification>()
.filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 1
}
@Test
fun `caller cancellation sends CancelledNotification and rethrows the original exception`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
val cancelled = CompletableDeferred<CancellationException>()
val job = launch {
try {
protocol.request<EmptyResult>(PingRequest())
} catch (e: CancellationException) {
cancelled.complete(e)
throw e
}
}
transport.awaitRequest()
job.cancelAndJoin()
cancelled.await()
val wire = transport.sentWithOptions.map { it.first }.filterIsInstance<JSONRPCNotification>()
.filter { it.method == Method.Defined.NotificationsCancelled.value }
wire shouldHaveSize 1
}
@Test
fun `cancelling the initialize request performs local cleanup but sends no CancelledNotification`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
val job = launch {
protocol.request<EmptyResult>(
InitializeRequest(
InitializeRequestParams(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ClientCapabilities(),
clientInfo = Implementation(name = "t", version = "1"),
),
),
)
}
val sent = transport.awaitRequest()
sent.method shouldBe Method.Defined.Initialize.value
job.cancelAndJoin()
transport.sentWithOptions.map { it.first }.filterIsInstance<JSONRPCNotification>()
.filter { it.method == Method.Defined.NotificationsCancelled.value } shouldHaveSize 0
protocol.responseHandlers shouldBe emptyMap()
}
@Test
fun `late response for a cancelled request is ignored silently`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
val job = launch { protocol.request<EmptyResult>(PingRequest()) }
val sent = transport.awaitRequest()
job.cancelAndJoin()
transport.deliver(JSONRPCResponse(id = sent.id, result = EmptyResult()))
protocol.errors shouldHaveSize 0 // no "unknown message ID" onError
}
@Test
fun `response for a genuinely unknown id still reports onError`() = runTest {
val protocol = TestProtocol()
val transport = RecordingTransport()
protocol.connect(transport)
transport.deliver(JSONRPCResponse(id = RequestId(999L), result = EmptyResult()))
protocol.errors shouldHaveSize 1
}
}

View file

@ -5,6 +5,9 @@ import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.types.shouldBeInstanceOf
import io.modelcontextprotocol.kotlin.sdk.types.CustomRequest
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
@ -13,11 +16,16 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotification
import io.modelcontextprotocol.kotlin.sdk.types.ProgressNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.ProgressToken
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.RequestMeta
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonObjectBuilder
@ -30,6 +38,7 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class ProtocolTest {
private lateinit var protocol: TestProtocol
@ -185,64 +194,174 @@ class ProtocolTest {
transport.deliver(JSONRPCResponse(sent.id, EmptyResult()))
inFlight.await()
}
}
private class TestProtocol : Protocol(null) {
val errors = mutableListOf<Throwable>()
@Test
fun `request handler receives enriched extra and ambient context element`() = runTest {
protocol.connect(transport)
override fun onError(error: Throwable) {
errors.add(error)
var seenByParameter: RequestHandlerExtra? = null
var seenAmbient: RequestHandlerExtra? = null
protocol.fallbackRequestHandler = { _, extra ->
seenByParameter = extra
seenAmbient = currentRequestHandlerExtra()
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(7L), method = "custom/echo"))
seenByParameter shouldNotBe null
seenByParameter?.requestId shouldBe RequestId(7L)
seenByParameter?.method shouldBe Method.Custom("custom/echo")
// the SAME instance flows through both delivery paths
seenAmbient shouldBe seenByParameter
}
override fun assertCapabilityForMethod(method: Method) {
// noop
}
override fun assertNotificationCapability(method: Method) {
// noop
}
override fun assertRequestHandlerCapability(method: Method) {
// noop
}
}
@Test
fun `extra method resolves defined methods to Defined entries`() = runTest {
protocol.connect(transport)
private class RecordingTransport : Transport {
private val sentMessages = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null
private var onCloseCallback: (() -> Unit)? = null
var seenMethod: Method? = null
protocol.setRequestHandler<PingRequest>(Method.Defined.Ping) { _, extra ->
seenMethod = extra.method
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "ping"))
override suspend fun start() {
// noop
seenMethod shouldBe Method.Defined.Ping
}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
sentMessages.send(message)
@Test
fun `extra sendNotification stamps relatedRequestId`() = runTest {
protocol.connect(transport)
protocol.fallbackRequestHandler = { _, extra ->
extra.sendNotification(
ProgressNotification(
ProgressNotificationParams(progressToken = ProgressToken(1L), progress = 0.5),
),
)
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/progressing"))
val sent = transport.sentWithOptions.first { it.first is JSONRPCNotification }
sent.second?.relatedRequestId shouldBe RequestId(42L)
}
override suspend fun close() {
onCloseCallback?.invoke()
@Test
fun `extra sendRequest stamps relatedRequestId and preserves caller options`() = runTest {
protocol.connect(transport)
protocol.fallbackRequestHandler = { _, extra ->
extra.sendRequest<EmptyResult>(
PingRequest(),
RequestOptions(resumptionToken = "tok", onProgress = { }, timeout = 30.seconds),
)
EmptyResult()
}
// The serial phase runs the handler inline inside deliver(), and the handler suspends
// awaiting the nested request's response — drive delivery from a child coroutine.
val delivery = launch {
transport.deliver(JSONRPCRequest(id = RequestId(42L), method = "custom/nested"))
}
val outbound = transport.awaitRequest()
val recorded = transport.sentWithOptions
.first { (it.first as? JSONRPCRequest)?.id == outbound.id }
.second
val options = recorded.shouldBeInstanceOf<RequestOptions>()
options.relatedRequestId shouldBe RequestId(42L)
options.resumptionToken shouldBe "tok"
options.timeout shouldBe 30.seconds
options.onProgress shouldNotBe null
transport.deliver(JSONRPCResponse(id = outbound.id, result = EmptyResult()))
delivery.join()
}
override fun onClose(block: () -> Unit) {
onCloseCallback = block
@Test
fun `currentRequestHandlerExtra returns null outside handlers`() = runTest {
currentRequestHandlerExtra() shouldBe null
}
override fun onError(block: (Throwable) -> Unit) {
// noop
@Test
fun `connect while already connected throws IllegalStateException`() = runTest {
protocol.connect(transport)
shouldThrow<IllegalStateException> {
protocol.connect(RecordingTransport())
}
}
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
onMessageCallback = block
@Test
fun `stale onClose from a previous transport does not tear down the successor connection`() = runTest {
protocol.connect(transport)
val staleCloseCallback = transport.closeCallback ?: error("onClose not registered")
protocol.close() // disconnect first transport (fires its own doClose)
val second = RecordingTransport()
protocol.connect(second) // reconnect
staleCloseCallback() // late duplicate close signal from transport #1
// successor connection must still be alive
protocol.transport shouldBe second
}
suspend fun awaitRequest(): JSONRPCRequest {
val message = sentMessages.receive()
return message as? JSONRPCRequest
?: error("Expected JSONRPCRequest but received ${message::class.simpleName}")
@Test
fun `failed transport start rolls back the connection and allows reconnect`() = runTest {
val failing = object : Transport {
override suspend fun start(): Unit = error("boom")
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {}
override suspend fun close() {}
override fun onClose(block: () -> Unit) {}
override fun onError(block: (Throwable) -> Unit) {}
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {}
}
shouldThrow<IllegalStateException> { protocol.connect(failing) }
protocol.transport shouldBe null // rolled back
protocol.connect(transport) // reconnect succeeds, no "already connected"
protocol.transport shouldBe transport
}
suspend fun deliver(message: JSONRPCMessage) {
val callback = onMessageCallback ?: error("onMessage callback not registered")
callback(message)
@Test
fun `request cleans up its handlers when the transport send fails`() = runTest {
// Send fails without closing the connection, so the leak is not masked by doClose().
val failingSend = object : Transport {
override suspend fun start() {}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?): Unit =
throw IllegalStateException("send failed")
override suspend fun close() {}
override fun onClose(block: () -> Unit) {}
override fun onError(block: (Throwable) -> Unit) {}
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {}
}
protocol.connect(failingSend)
shouldThrow<IllegalStateException> {
protocol.request<EmptyResult>(PingRequest(), RequestOptions(onProgress = {}))
}
protocol.responseHandlers.size shouldBe 0
protocol.progressHandlers.size shouldBe 0
}
@Test
fun `connect rejects non-positive maxConcurrentHandlers`() = runTest {
val error = shouldThrow<IllegalArgumentException> {
TestProtocol(ProtocolOptions(maxConcurrentHandlers = 0)).connect(RecordingTransport())
}
error.message.shouldNotBeNull() shouldContain "maxConcurrentHandlers"
}
@Test
fun `connect rejects maxInFlightHandlers below maxConcurrentHandlers`() = runTest {
val error = shouldThrow<IllegalArgumentException> {
TestProtocol(ProtocolOptions(maxConcurrentHandlers = 4, maxInFlightHandlers = 0))
.connect(RecordingTransport())
}
error.message.shouldNotBeNull() shouldContain "maxInFlightHandlers"
}
}

View file

@ -0,0 +1,112 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
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.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.channels.Channel
import kotlinx.serialization.json.encodeToJsonElement
/**
* Shared [Protocol] test double: records [onError] calls, counts `notifications/initialized`
* router-hook invocations, and no-ops all capability assertions.
*
* The concurrency gate is flipped only via [enableConcurrency]; nothing in this double flips it
* implicitly, so tests keep today's serial semantics unless they opt in.
*/
internal class TestProtocol(options: ProtocolOptions? = null) : Protocol(options) {
val errors = mutableListOf<Throwable>()
var initializedNotificationCount = 0
fun enableConcurrency() {
enableConcurrentDispatch()
}
override fun onInitializedNotification() {
initializedNotificationCount++
// deliberately does NOT flip the gate — tests control the flip explicitly
}
override fun onError(error: Throwable) {
errors.add(error)
}
override fun assertCapabilityForMethod(method: Method) {
// noop
}
override fun assertNotificationCapability(method: Method) {
// noop
}
public override fun assertRequestHandlerCapability(method: Method) {
// noop
}
}
/** Shared [Transport] test double: records every sent message (with its options) and replays inbound ones. */
internal class RecordingTransport : Transport {
val sentMessages = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null
private var onCloseCallback: (() -> Unit)? = null
val sentWithOptions = mutableListOf<Pair<JSONRPCMessage, TransportSendOptions?>>()
var closeCallback: (() -> Unit)? = null
private set
override suspend fun start() {
// noop
}
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
sentWithOptions.add(message to options)
sentMessages.send(message)
}
override suspend fun close() {
onCloseCallback?.invoke()
}
override fun onClose(block: () -> Unit) {
closeCallback = block
onCloseCallback = block
}
override fun onError(block: (Throwable) -> Unit) {
// noop
}
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
onMessageCallback = block
}
suspend fun awaitRequest(): JSONRPCRequest {
while (true) {
val message = sentMessages.receive()
if (message is JSONRPCRequest) return message
}
}
/** Cross-thread-safe reader used by real-dispatcher tests; [sentWithOptions] is single-thread only. */
suspend fun awaitSent(): JSONRPCMessage = sentMessages.receive()
suspend fun deliver(message: JSONRPCMessage) {
val callback = onMessageCallback ?: error("onMessage callback not registered")
callback(message)
}
}
internal fun responsesOn(transport: RecordingTransport): List<JSONRPCResponse> =
transport.sentWithOptions.map { it.first }.filterIsInstance<JSONRPCResponse>()
internal fun errorsOn(transport: RecordingTransport): List<JSONRPCError> =
transport.sentWithOptions.map { it.first }.filterIsInstance<JSONRPCError>()
internal fun cancelledNotification(requestId: RequestId, reason: String): JSONRPCNotification = JSONRPCNotification(
method = Method.Defined.NotificationsCancelled.value,
params = McpJson.encodeToJsonElement(CancelledNotificationParams(requestId = requestId, reason = reason)),
)

View file

@ -0,0 +1,114 @@
package io.modelcontextprotocol.kotlin.sdk.shared
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
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.RequestId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
/**
* Real-dispatcher counterparts to [ProtocolConcurrencyTest].
*
* These run under [runBlocking] (real time, real threads on [Dispatchers.Default]) rather than
* `runTest`: a virtual-time scheduler auto-advances past the `withTimeout` guards and cannot
* observe genuine cross-thread parallelism, so the concurrency assertions could pass vacuously.
*/
class ProtocolConcurrencyJvmTest {
@Test
fun `fast request completes while a slow handler is parked on Dispatchers Default`() = runBlocking {
val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default))
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
val slowEntered = CompletableDeferred<Unit>()
val releaseSlow = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/slow") {
slowEntered.complete(Unit)
releaseSlow.await()
}
EmptyResult()
}
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow"))
withTimeout(5.seconds) { slowEntered.await() }
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast"))
val fast = withTimeout(5.seconds) { transport.awaitSent() }
(fast as JSONRPCResponse).id shouldBe RequestId(2L)
releaseSlow.complete(Unit)
val slow = withTimeout(5.seconds) { transport.awaitSent() }
(slow as JSONRPCResponse).id shouldBe RequestId(1L)
Unit
}
@Test
fun `dispatch stays serial before the gate flips on Dispatchers Default`() = runBlocking {
val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default))
val transport = RecordingTransport()
protocol.connect(transport)
// gate NOT flipped
val slowEntered = CompletableDeferred<Unit>()
val releaseSlow = CompletableDeferred<Unit>()
protocol.fallbackRequestHandler = { request, _ ->
if (request.method == "test/slow") {
slowEntered.complete(Unit)
releaseSlow.await()
}
EmptyResult()
}
// A single delivering coroutine models the transport read loop: the second deliver cannot
// start until the first returns, so a parked slow handler blocks the fast request.
val delivery = launch {
transport.deliver(JSONRPCRequest(id = RequestId(1L), method = "test/slow"))
transport.deliver(JSONRPCRequest(id = RequestId(2L), method = "test/fast"))
}
withTimeout(5.seconds) { slowEntered.await() }
// The delivering coroutine is parked inside the slow handler; fast was never delivered.
transport.sentMessages.tryReceive().isFailure shouldBe true
releaseSlow.complete(Unit)
val first = withTimeout(5.seconds) { transport.awaitSent() }
(first as JSONRPCResponse).id shouldBe RequestId(1L)
val second = withTimeout(5.seconds) { transport.awaitSent() }
(second as JSONRPCResponse).id shouldBe RequestId(2L)
delivery.join()
}
@Test
fun `suspension-free notification handlers keep arrival order on Dispatchers Default`() = runBlocking {
val protocol = TestProtocol(ProtocolOptions(handlerCoroutineContext = Dispatchers.Default))
val transport = RecordingTransport()
protocol.connect(transport)
protocol.enableConcurrency()
// safe: appended only from the delivering coroutine (UNDISPATCHED, suspension-free)
val seen = mutableListOf<Int>()
protocol.fallbackNotificationHandler = { notification ->
seen.add((notification.params as JsonObject).getValue("i").jsonPrimitive.int)
}
repeat(100) { i ->
transport.deliver(JSONRPCNotification(method = "test/event", params = buildJsonObject { put("i", i) }))
}
seen shouldBe (0 until 100).toList()
}
}

View file

@ -169,8 +169,8 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ServerOptions : io/
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;Z)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;ZLio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;Lkotlin/coroutines/CoroutineContext;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/types/ServerCapabilities;
public final fun getResourceTemplateMatcherFactory ()Lio/modelcontextprotocol/kotlin/sdk/utils/ResourceTemplateMatcherFactory;
}
@ -198,6 +198,7 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc
public fun onClose ()V
public final fun onClose (Lkotlin/jvm/functions/Function0;)V
public final fun onInitialized (Lkotlin/jvm/functions/Function0;)V
protected fun onInitializedNotification ()V
public final fun ping (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendElicitationComplete (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitationCompleteNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun sendLoggingMessage (Lio/modelcontextprotocol/kotlin/sdk/types/LoggingMessageNotification;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

View file

@ -53,9 +53,11 @@ import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcher
import io.modelcontextprotocol.kotlin.sdk.utils.ResourceTemplateMatcherFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.coroutines.CoroutineContext
import kotlin.jvm.JvmOverloads
import kotlin.time.ExperimentalTime
@ -69,12 +71,17 @@ private val logger = KotlinLogging.logger {}
* Forwarded to [ProtocolOptions.enforceStrictCapabilities].
* @property resourceTemplateMatcherFactory The factory used to create [ResourceTemplateMatcher] instances
* for matching resource URIs against registered templates. Defaults to [PathSegmentTemplateMatcher.factory].
* @param handlerCoroutineContext Coroutine context for inbound handlers. See [ProtocolOptions.handlerCoroutineContext].
*/
public class ServerOptions(
public val capabilities: ServerCapabilities,
enforceStrictCapabilities: Boolean = true,
public val resourceTemplateMatcherFactory: ResourceTemplateMatcherFactory = PathSegmentTemplateMatcher.factory,
) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities) {
handlerCoroutineContext: CoroutineContext = Dispatchers.Default,
) : ProtocolOptions(
enforceStrictCapabilities = enforceStrictCapabilities,
handlerCoroutineContext = handlerCoroutineContext,
) {
@JvmOverloads
public constructor(
capabilities: ServerCapabilities,

View file

@ -108,10 +108,18 @@ public open class ServerSession(
}
}
override fun onInitializedNotification() {
// Enable concurrency before the user's onInitialized callback, which runs synchronously.
enableConcurrentDispatch()
}
internal val clientConnection: ClientConnection = ClientConnectionImpl(this)
/**
* Registers a callback to be invoked when the server has completed initialization.
*
* The callback must be synchronous and fast: it runs on the message-dispatch path for
* `notifications/initialized`, after concurrent dispatch has been enabled for the session.
*/
public fun onInitialized(block: () -> Unit) {
val old = _onInitialized

View file

@ -15,10 +15,12 @@ import io.ktor.server.sse.ServerSSESession
import io.ktor.util.collections.ConcurrentMap
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.DEFAULT_NEGOTIATED_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
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.McpJson
@ -27,6 +29,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.REQUEST_TIMEOUT
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.job
@ -175,6 +178,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
private val requestToStreamMapping: ConcurrentMap<RequestId, String> = ConcurrentMap()
private val requestToResponseMapping: ConcurrentMap<RequestId, JSONRPCMessage> = ConcurrentMap()
/**
* JSON-response mode only: one entry per in-flight POST, completed by [send] with the collected
* responses once every request delivered by that POST has been answered. [handlePostRequest]
* awaits it so the HTTP exchange stays open while handlers run concurrently.
*/
private val pendingJsonResponses: ConcurrentMap<String, CompletableDeferred<List<JSONRPCMessage>>> = ConcurrentMap()
/**
* JSON-response mode only: request ids retired by an inbound cancellation while their POST is
* still awaiting. Treated as settled-without-response so the POST's "a response for every id"
* completion condition is not blocked by a request that will never be answered.
*/
private val cancelledRequestIds: ConcurrentMap<RequestId, Unit> = ConcurrentMap()
private val sessionMutex = Mutex()
private val streamMutex = Mutex()
@ -241,54 +258,117 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
return
}
val isTerminated = message is JSONRPCResponse || message is JSONRPCError
// In JSON mode request-related notifications have no per-request SSE stream. Route them to the
// standalone GET stream before the lookup below, so a notification for an already-retired
// request is delivered there instead of becoming a spurious error.
if (configuration.enableJsonResponse && !isTerminated) {
val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID]
standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) }
return
}
val streamId = requestToStreamMapping[routingRequestId]
?: error("No connection established for request id $routingRequestId")
if (streamId == null) {
// Request already retired (completed, cancelled, or the JSON POST disconnected). In JSON
// mode a late terminal response is undeliverable and dropped. In SSE mode an unknown id
// is a real routing error.
if (configuration.enableJsonResponse) return
error("No connection established for request id $routingRequestId")
}
val activeStream = streamsMapping[streamId]
if (!configuration.enableJsonResponse) {
activeStream?.let { stream ->
emitOnStream(streamId, stream.session, message)
}
}
val isTerminated = message is JSONRPCResponse || message is JSONRPCError
if (!isTerminated) {
if (configuration.enableJsonResponse) {
// In JSON response mode there is no per-request SSE stream, so route notifications
// that are logically associated with a request to the standalone GET SSE stream.
val standaloneStream = streamsMapping[STANDALONE_SSE_STREAM_ID]
standaloneStream?.let { emitOnStream(STANDALONE_SSE_STREAM_ID, it.session, message) }
}
return
if (!isTerminated) return
}
requestToResponseMapping[responseRequestId!!] = message
val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys
if (relatedIds.any { it !in requestToResponseMapping }) return
streamMutex.withLock {
if (activeStream == null) error("No connection established for request ID: $routingRequestId")
// The request may have been retired (disconnect or cancellation cleanup) between recording
// the response above and acquiring the lock. Reclaim the stray mapping and drop it.
if (routingRequestId !in requestToStreamMapping) {
requestToResponseMapping.remove(routingRequestId)
return
}
// Recomputed under the lock. Handlers may respond concurrently, and the sender that
// observes the last settled request also retires the ids, so a losing sender sees an
// empty set. A cancelled request counts as settled even though it produces no response.
val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys
if (relatedIds.isEmpty() ||
relatedIds.any { it !in requestToResponseMapping && it !in cancelledRequestIds }
) {
return
}
if (configuration.enableJsonResponse) {
activeStream.call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
sessionId?.let { activeStream.call.response.header(MCP_SESSION_ID_HEADER, it) }
val responses = relatedIds.mapNotNull { requestToResponseMapping[it] }
val payload = if (responses.size == 1) {
responses.first()
} else {
responses
// Hand the collected responses to the POST suspended in [handlePostRequest], which
// completes the HTTP exchange. A null pendingPost means the POST was already retired
// (disconnect or cancellation), so the responses are undeliverable and dropped quietly.
val pendingPost = pendingJsonResponses.remove(streamId)
if (pendingPost == null) {
cleanUpRequests(relatedIds)
return
}
activeStream.call.respond(payload)
val responses = relatedIds.mapNotNull { requestToResponseMapping[it] }
cleanUpRequests(relatedIds)
pendingPost.complete(responses)
} else {
activeStream.session?.close()
// A null stream means the per-request SSE connection closed before the response
// settled, so the ids are retired rather than leaked.
activeStream?.session?.close()
cleanUpRequests(relatedIds)
}
}
}
// Clean up
relatedIds.forEach { requestId ->
requestToResponseMapping.remove(requestId)
requestToStreamMapping.remove(requestId)
private fun cleanUpRequests(ids: Collection<RequestId>) {
ids.forEach { requestId ->
requestToResponseMapping.remove(requestId)
requestToStreamMapping.remove(requestId)
cancelledRequestIds.remove(requestId)
}
}
/**
* Scans a delivered POST body for `notifications/cancelled` and retires each referenced request
* that is awaiting on a JSON-mode POST. No-op unless [Configuration.enableJsonResponse].
*/
private suspend fun retireCancelledJsonRequests(messages: List<JSONRPCMessage>) {
if (!configuration.enableJsonResponse) return
val cancelledIds = messages.asSequence()
.filterIsInstance<JSONRPCNotification>()
.filter { it.method == Method.Defined.NotificationsCancelled.value }
.mapNotNull { notification ->
val params = notification.params ?: return@mapNotNull null
runCatching {
McpJson.decodeFromJsonElement<CancelledNotificationParams>(params).requestId
}.getOrNull()
}
.toList()
cancelledIds.forEach { retireCancelledJsonRequest(it) }
}
/**
* Marks a request cancelled by the peer as settled without a response and completes its POST once
* every request that POST delivered is settled (answered or cancelled). Without this, a cancelled
* request leaves the POST awaiting a response that, by design, is never sent.
*/
private suspend fun retireCancelledJsonRequest(requestId: RequestId) {
streamMutex.withLock {
val streamId = requestToStreamMapping[requestId] ?: return
cancelledRequestIds[requestId] = Unit
val relatedIds = requestToStreamMapping.filterValues { it == streamId }.keys
val allSettled = relatedIds.all { it in requestToResponseMapping || it in cancelledRequestIds }
if (!allSettled) return
val pendingPost = pendingJsonResponses.remove(streamId) ?: return
val responses = relatedIds.mapNotNull { requestToResponseMapping[it] }
cleanUpRequests(relatedIds)
pendingPost.complete(responses)
}
}
@ -304,6 +384,10 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
streamsMapping.clear()
requestToStreamMapping.clear()
requestToResponseMapping.clear()
cancelledRequestIds.clear()
// Release any POST calls suspended awaiting JSON responses.
pendingJsonResponses.values.forEach { it.cancel() }
pendingJsonResponses.clear()
invokeOnCloseCallback()
}
}
@ -339,7 +423,12 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
/**
* Handles POST requests containing JSON-RPC messages
* Handles POST requests containing JSON-RPC messages.
*
* In JSON-response mode this suspends until a response has been produced for every request in
* the POST body, then completes the HTTP exchange with the collected JSON (a single object for
* one request, an array for a batch). Bodies without requests are acknowledged with 202
* immediately. In SSE mode the responses are emitted onto the per-request SSE stream instead.
*/
public suspend fun handlePostRequest(session: ServerSSESession?, call: ApplicationCall) {
try {
@ -407,6 +496,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
if (!hasRequest) {
call.respondNullable(status = HttpStatusCode.Accepted, message = null)
messages.forEach { message -> _onMessage(message) }
// A cancellation may target a request still awaiting delivery on a JSON-mode POST;
// retire it so that POST completes instead of waiting for a response that never comes.
retireCancelledJsonRequests(messages)
return
}
@ -429,13 +521,50 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
maybeSendPrimingEvent(streamId, session, clientProtocolVersion)
}
val pendingPost = if (configuration.enableJsonResponse) {
CompletableDeferred<List<JSONRPCMessage>>()
} else {
null
}
streamMutex.withLock {
streamsMapping[streamId] = SessionContext(session, call)
messages.filterIsInstance<JSONRPCRequest>().forEach { requestToStreamMapping[it.id] = streamId }
pendingPost?.let { pendingJsonResponses[streamId] = it }
}
call.coroutineContext.job.invokeOnCompletion { streamsMapping.remove(streamId) }
messages.forEach { message -> _onMessage(message) }
if (pendingPost == null) {
// SSE mode: responses are emitted onto this call's SSE stream by send().
messages.forEach { message -> _onMessage(message) }
return
}
// JSON-response mode. Handlers may run concurrently, so returning from delivery no longer
// implies the responses were produced. Suspend until send() has collected a response for
// every request in this POST. A client disconnect or transport close cancels this call.
try {
messages.forEach { message -> _onMessage(message) }
retireCancelledJsonRequests(messages)
val responses = pendingPost.await()
sessionId?.let { call.response.header(MCP_SESSION_ID_HEADER, it) }
if (responses.isEmpty()) {
// Every request in this POST was cancelled before producing a response; there is
// nothing to return, so acknowledge with 202 rather than an empty JSON body.
call.respondNullable(status = HttpStatusCode.Accepted, message = null)
} else {
call.response.header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
val payload = if (responses.size == 1) responses.first() else responses
call.respond(payload)
}
} finally {
// On cancellation or failure, retire this POST's ids so a late response is dropped
// quietly at the send() boundary instead of leaking mappings. Idempotent no-ops on
// the success path (send() or retirement already cleaned up).
pendingJsonResponses.remove(streamId)
pendingPost.cancel()
cleanUpRequests(messages.filterIsInstance<JSONRPCRequest>().map { it.id })
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {

Some files were not shown because too many files have changed in this diff Show more