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:
parent
7937390b47
commit
beb3400064
4 changed files with 2462 additions and 191 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue