feat!: add mcp conformance test infra (#585); add SSE reconnection with retry support (#596) (#585)

Adds a comprehensive conformance test suite for the Kotlin MCP SDK,
covering core protocol operations, tool calls, elicitation, resources,
prompts, and 20 OAuth/auth scenarios

- Conformance server and client implementations
- OAuth/auth test scenarios: JWT, authorization code flow, client
credentials, PKCE, scope handling, cross-app access, client registration
- CI workflow
- Baseline file for tracking expected failures
- Shell script

fixes:
- #592
- #593
- #596


## Remaining known failures (tracked issues, will be fixed directly in
`main`)

- [x] `tools-call-with-logging`, `tools-call-with-progress`,
`tools-call-sampling`, `tools-call-elicitation`,
`elicitation-sep1034-defaults`-  see #599,
- [x] `elicitation-sep1330-enums` - #587 #600
- [x] `initialize` - #588 
- [x] `tools_call`, `auth/scope-step-up`, `auth/scope-retry-limit` -
#589
- [ ] `elicitation-sep1034-client-defaults` - #414 
- [x] `sse-retry` - #590 
- [ ] `resources-templates-read` - #591 

## Breaking Changes
from #596 
- `StreamableHttpClientTransport` and
`mcpStreamableHttp`/`mcpStreamableHttpTransport`: old constructors
accepting `Duration` timeout are now `@Deprecated` — use the new
overloads with `ReconnectionOptions` instead
- `StreamableHttpClientTransport.close()` no longer calls
`terminateSession()` automatically

## 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

---------

Co-authored-by: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com>
This commit is contained in:
Pavel Gorgulov 2026-03-12 14:44:59 +01:00 • committed by GitHub
commit e88b34170c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 3186 additions and 1122 deletions

View file

@ -0,0 +1,44 @@
package io.modelcontextprotocol.kotlin.sdk.client
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Options for controlling SSE reconnection behavior.
*
* @property initialReconnectionDelay The initial delay before the first reconnection attempt.
* @property maxReconnectionDelay The maximum delay between reconnection attempts.
* @property reconnectionDelayMultiplier The factor by which the delay grows on each attempt.
* @property maxRetries The maximum number of reconnection attempts per disconnect.
*/
public class ReconnectionOptions(
public val initialReconnectionDelay: Duration = 1.seconds,
public val maxReconnectionDelay: Duration = 30.seconds,
public val reconnectionDelayMultiplier: Double = 1.5,
public val maxRetries: Int = 2,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as ReconnectionOptions
if (reconnectionDelayMultiplier != other.reconnectionDelayMultiplier) return false
if (maxRetries != other.maxRetries) return false
if (initialReconnectionDelay != other.initialReconnectionDelay) return false
if (maxReconnectionDelay != other.maxReconnectionDelay) return false
return true
}
override fun hashCode(): Int {
var result = reconnectionDelayMultiplier.hashCode()
result = 31 * result + maxRetries
result = 31 * result + initialReconnectionDelay.hashCode()
result = 31 * result + maxReconnectionDelay.hashCode()
return result
}
override fun toString(): String =
"ReconnectionOptions(initialReconnectionDelay=$initialReconnectionDelay, maxReconnectionDelay=$maxReconnectionDelay, reconnectionDelayMultiplier=$reconnectionDelayMultiplier, maxRetries=$maxRetries)"
}

View file

@ -38,9 +38,13 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.pow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private const val MCP_SESSION_ID_HEADER = "mcp-session-id"
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
@ -52,31 +56,58 @@ private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
public class StreamableHttpError(public val code: Int? = null, message: String? = null) :
Exception("Streamable HTTP error: $message")
private sealed interface ConnectResult {
data class Success(val session: ClientSSESession) : ConnectResult
data object NonRetryable : ConnectResult
data object Failed : ConnectResult
}
/**
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
* for receiving messages.
*/
@OptIn(ExperimentalAtomicApi::class)
@Suppress("TooManyFunctions")
public class StreamableHttpClientTransport(
private val client: HttpClient,
private val url: String,
private val reconnectionTime: Duration? = null,
private val reconnectionOptions: ReconnectionOptions = ReconnectionOptions(),
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : AbstractClientTransport() {
@Deprecated(
"Use constructor with ReconnectionOptions",
replaceWith = ReplaceWith(
"StreamableHttpClientTransport(client, url, " +
"ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)",
"kotlin.time.Duration.Companion.seconds",
"io.modelcontextprotocol.kotlin.sdk.client.ReconnectionOptions",
),
)
public constructor(
client: HttpClient,
url: String,
reconnectionTime: Duration?,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : this(client, url, ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)
override val logger: KLogger = KotlinLogging.logger {}
public var sessionId: String? = null
private set
public var protocolVersion: String? = null
private var sseSession: ClientSSESession? = null
private var sseJob: Job? = null
private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
private var lastEventId: String? = null
/** Result of an SSE stream collection. Reconnect when [hasPrimingEvent] is true and [receivedResponse] is false. */
private data class SseStreamResult(
val hasPrimingEvent: Boolean,
val receivedResponse: Boolean,
val lastEventId: String? = null,
val serverRetryDelay: Duration? = null,
)
override suspend fun initialize() {
logger.debug { "Client transport is starting..." }
@ -85,7 +116,7 @@ public class StreamableHttpClientTransport(
/**
* Sends a single message with optional resumption support
*/
@Suppress("ReturnCount", "CyclomaticComplexMethod")
@Suppress("ReturnCount", "CyclomaticComplexMethod", "LongMethod", "TooGenericExceptionCaught", "ThrowsCount")
override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) {
logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" }
@ -133,18 +164,25 @@ public class StreamableHttpClientTransport(
}
}
ContentType.Text.EventStream -> handleInlineSse(
response,
onResumptionToken = options?.onResumptionToken,
replayMessageId = if (message is JSONRPCRequest) message.id else null,
)
ContentType.Text.EventStream -> {
val replayMessageId = if (message is JSONRPCRequest) message.id else null
val result = handleInlineSse(response, replayMessageId, options?.onResumptionToken)
if (result.hasPrimingEvent && !result.receivedResponse) {
startSseSession(
resumptionToken = result.lastEventId,
replayMessageId = replayMessageId,
onResumptionToken = options?.onResumptionToken,
initialServerRetryDelay = result.serverRetryDelay,
)
}
}
else -> {
val body = response.bodyAsText()
if (response.contentType() == null && body.isBlank()) return
val ct = response.contentType()?.toString() ?: "<none>"
val error = StreamableHttpError(-1, "Unexpected content type: $$ct")
val error = StreamableHttpError(-1, "Unexpected content type: $ct")
_onError(error)
throw error
}
@ -169,11 +207,6 @@ public class StreamableHttpClientTransport(
override suspend fun closeResources() {
logger.debug { "Client transport closing." }
// Try to terminate session if we have one
terminateSession()
sseSession?.cancel()
sseJob?.cancelAndJoin()
scope.cancel()
}
@ -201,55 +234,120 @@ public class StreamableHttpClientTransport(
}
sessionId = null
lastEventId = null
logger.debug { "Session terminated successfully" }
}
private suspend fun startSseSession(
private fun startSseSession(
resumptionToken: String? = null,
replayMessageId: RequestId? = null,
onResumptionToken: ((String) -> Unit)? = null,
initialServerRetryDelay: Duration? = null,
) {
sseSession?.cancel()
sseJob?.cancelAndJoin()
// Cancel-and-replace: cancel() signals the previous job, join() inside
// the new coroutine ensures it completes before we start collecting.
// This is intentionally non-suspend to avoid blocking performSend.
val previousJob = sseJob
previousJob?.cancel()
sseJob = scope.launch(CoroutineName("StreamableHttpTransport.collect#${hashCode()}")) {
previousJob?.join()
var lastEventId = resumptionToken
var serverRetryDelay = initialServerRetryDelay
var attempt = 0
var needsDelay = initialServerRetryDelay != null
@Suppress("LoopWithTooManyJumpStatements")
while (isActive) {
// Delay before (re)connection: skip only for first fresh SSE connection
if (needsDelay) {
delay(getNextReconnectionDelay(attempt, serverRetryDelay))
}
needsDelay = true
// Connect
val session = when (val cr = connectSse(lastEventId)) {
is ConnectResult.Success -> {
attempt = 0
cr.session
}
ConnectResult.NonRetryable -> return@launch
ConnectResult.Failed -> {
// Give up after maxRetries consecutive failed connection attempts
if (++attempt >= reconnectionOptions.maxRetries) {
_onError(StreamableHttpError(null, "Maximum reconnection attempts exceeded"))
return@launch
}
continue
}
}
// Collect
val result = collectSse(session, replayMessageId, onResumptionToken)
lastEventId = result.lastEventId ?: lastEventId
serverRetryDelay = result.serverRetryDelay ?: serverRetryDelay
if (result.receivedResponse) break
}
}
}
@Suppress("TooGenericExceptionCaught")
private suspend fun connectSse(lastEventId: String?): ConnectResult {
logger.debug { "Client attempting to start SSE session at url: $url" }
try {
sseSession = client.sseSession(
urlString = url,
reconnectionTime = reconnectionTime,
) {
return try {
val session = client.sseSession(urlString = url, showRetryEvents = true) {
method = HttpMethod.Get
applyCommonHeaders(this)
// sseSession will add ContentType.Text.EventStream automatically
accept(ContentType.Application.Json)
(resumptionToken ?: lastEventId)?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) }
lastEventId?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) }
requestBuilder()
}
logger.debug { "Client SSE session started successfully." }
} catch (e: SSEClientException) {
val responseStatus = e.response?.status
val responseContentType = e.response?.contentType()
// 405 means server doesn't support SSE at GET endpoint - this is expected and valid
if (responseStatus == HttpStatusCode.MethodNotAllowed) {
logger.info { "Server returned 405 for GET/SSE, stream disabled." }
return
}
// If server returns application/json, it means it doesn't support SSE for this session
// This is valid per spec - server can choose to only use JSON responses
if (responseContentType?.match(ContentType.Application.Json) == true) {
logger.info { "Server returned application/json for GET/SSE, using JSON-only mode." }
return
}
_onError(e)
ConnectResult.Success(session)
} catch (e: CancellationException) {
throw e
} catch (e: SSEClientException) {
if (isNonRetryableSseError(e)) {
ConnectResult.NonRetryable
} else {
logger.debug { "SSE connection failed: ${e.message}" }
ConnectResult.Failed
}
} catch (e: Exception) {
logger.debug { "SSE connection failed: ${e.message}" }
ConnectResult.Failed
}
}
sseJob = scope.launch(CoroutineName("StreamableHttpTransport.collect#${hashCode()}")) {
sseSession?.let { collectSse(it, replayMessageId, onResumptionToken) }
private fun getNextReconnectionDelay(attempt: Int, serverRetryDelay: Duration?): Duration {
// Per SSE specification, the server-sent `retry` field sets the reconnection time
// for all subsequent attempts, taking priority over exponential backoff.
serverRetryDelay?.let { return it }
val delay = reconnectionOptions.initialReconnectionDelay *
reconnectionOptions.reconnectionDelayMultiplier.pow(attempt)
return delay.coerceAtMost(reconnectionOptions.maxReconnectionDelay)
}
/**
* Checks if an SSE session error is non-retryable (404, 405, JSON-only).
* Returns `true` if non-retryable (should stop trying), `false` otherwise.
*/
private fun isNonRetryableSseError(e: SSEClientException): Boolean {
val responseStatus = e.response?.status
val responseContentType = e.response?.contentType()
return when {
responseStatus == HttpStatusCode.NotFound || responseStatus == HttpStatusCode.MethodNotAllowed -> {
logger.info { "Server returned ${responseStatus.value} for GET/SSE, stream disabled." }
true
}
responseContentType?.match(ContentType.Application.Json) == true -> {
logger.info { "Server returned application/json for GET/SSE, using JSON-only mode." }
true
}
else -> false
}
}
@ -265,11 +363,17 @@ public class StreamableHttpClientTransport(
session: ClientSSESession,
replayMessageId: RequestId?,
onResumptionToken: ((String) -> Unit)?,
) {
): SseStreamResult {
var hasPrimingEvent = false
var receivedResponse = false
var localLastEventId: String? = null
var localServerRetryDelay: Duration? = null
try {
session.incoming.collect { event ->
event.retry?.let { localServerRetryDelay = it.milliseconds }
event.id?.let {
lastEventId = it
localLastEventId = it
hasPrimingEvent = true
onResumptionToken?.invoke(it)
}
logger.trace { "Client received SSE event: event=${event.event}, data=${event.data}, id=${event.id}" }
@ -278,6 +382,7 @@ public class StreamableHttpClientTransport(
event.data?.takeIf { it.isNotEmpty() }?.let { json ->
runCatching { McpJson.decodeFromString<JSONRPCMessage>(json) }
.onSuccess { msg ->
if (msg is JSONRPCResponse) receivedResponse = true
if (replayMessageId != null && msg is JSONRPCResponse) {
_onMessage(msg.copy(id = replayMessageId))
} else {
@ -295,6 +400,7 @@ public class StreamableHttpClientTransport(
} catch (t: Throwable) {
_onError(t)
}
return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay)
}
@Suppress("CyclomaticComplexMethod")
@ -302,17 +408,22 @@ public class StreamableHttpClientTransport(
response: HttpResponse,
replayMessageId: RequestId?,
onResumptionToken: ((String) -> Unit)?,
) {
): SseStreamResult {
logger.trace { "Handling inline SSE from POST response" }
val channel = response.bodyAsChannel()
var hasPrimingEvent = false
var receivedResponse = false
var localLastEventId: String? = null
var localServerRetryDelay: Duration? = null
val sb = StringBuilder()
var id: String? = null
var eventName: String? = null
suspend fun dispatch(id: String?, eventName: String?, data: String) {
id?.let {
lastEventId = it
localLastEventId = it
hasPrimingEvent = true
onResumptionToken?.invoke(it)
}
if (data.isBlank()) {
@ -321,6 +432,7 @@ public class StreamableHttpClientTransport(
if (eventName == null || eventName == "message") {
runCatching { McpJson.decodeFromString<JSONRPCMessage>(data) }
.onSuccess { msg ->
if (msg is JSONRPCResponse) receivedResponse = true
if (replayMessageId != null && msg is JSONRPCResponse) {
_onMessage(msg.copy(id = replayMessageId))
} else {
@ -351,9 +463,16 @@ public class StreamableHttpClientTransport(
}
when {
line.startsWith("id:") -> id = line.substringAfter("id:").trim()
line.startsWith("event:") -> eventName = line.substringAfter("event:").trim()
line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim())
line.startsWith("retry:") -> line.substringAfter("retry:").trim().toLongOrNull()?.let {
localServerRetryDelay = it.milliseconds
}
}
}
return SseStreamResult(hasPrimingEvent, receivedResponse, localLastEventId, localServerRetryDelay)
}
}

View file

@ -6,6 +6,22 @@ import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Returns a new Streamable HTTP transport for the Model Context Protocol using the provided HttpClient.
*
* @param url URL of the MCP server.
* @param reconnectionOptions Options for controlling SSE reconnection behavior.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [StreamableHttpClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpStreamableHttpTransport(
url: String,
reconnectionOptions: ReconnectionOptions = ReconnectionOptions(),
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): StreamableHttpClientTransport =
StreamableHttpClientTransport(this, url, reconnectionOptions, requestBuilder = requestBuilder)
/**
* Returns a new Streamable HTTP transport for the Model Context Protocol using the provided HttpClient.
@ -15,12 +31,42 @@ import kotlin.time.Duration
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [StreamableHttpClientTransport] configured for MCP communication.
*/
@Deprecated(
"Use overload with ReconnectionOptions",
replaceWith = ReplaceWith(
"mcpStreamableHttpTransport(url, " +
"ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)",
),
)
public fun HttpClient.mcpStreamableHttpTransport(
url: String,
reconnectionTime: Duration? = null,
reconnectionTime: Duration?,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): StreamableHttpClientTransport =
StreamableHttpClientTransport(this, url, reconnectionTime, requestBuilder = requestBuilder)
): StreamableHttpClientTransport = StreamableHttpClientTransport(
this,
url,
ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds),
requestBuilder = requestBuilder,
)
/**
* Creates and connects an MCP client over Streamable HTTP using the provided HttpClient.
*
* @param url URL of the MCP server.
* @param reconnectionOptions Options for controlling SSE reconnection behavior.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
public suspend fun HttpClient.mcpStreamableHttp(
url: String,
reconnectionOptions: ReconnectionOptions = ReconnectionOptions(),
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpStreamableHttpTransport(url, reconnectionOptions, requestBuilder)
val client = Client(Implementation(name = IMPLEMENTATION_NAME, version = LIB_VERSION))
client.connect(transport)
return client
}
/**
* Creates and connects an MCP client over Streamable HTTP using the provided HttpClient.
@ -30,12 +76,23 @@ public fun HttpClient.mcpStreamableHttpTransport(
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
@Deprecated(
"Use overload with ReconnectionOptions",
replaceWith = ReplaceWith(
"mcpStreamableHttp(url, " +
"ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds), requestBuilder)",
),
)
public suspend fun HttpClient.mcpStreamableHttp(
url: String,
reconnectionTime: Duration? = null,
reconnectionTime: Duration?,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpStreamableHttpTransport(url, reconnectionTime, requestBuilder)
val transport = mcpStreamableHttpTransport(
url,
ReconnectionOptions(initialReconnectionDelay = reconnectionTime ?: 1.seconds),
requestBuilder,
)
val client = Client(Implementation(name = IMPLEMENTATION_NAME, version = LIB_VERSION))
client.connect(transport)
return client

View file

@ -23,9 +23,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.Implementation
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.McpException
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@ -657,6 +659,203 @@ class StreamableHttpClientTransportTest {
receivedErrors shouldHaveSize 0
}
@Test
fun testInlineSseRetryParsing() = runTest {
val transport = createTransport { request ->
if (request.method == HttpMethod.Post) {
val sseContent = buildString {
appendLine("retry: 5000")
appendLine("id: ev-1")
appendLine("event: message")
appendLine("""data: {"jsonrpc":"2.0","id":"req-1","result":{"tools":[]}}""")
appendLine()
}
respond(
content = ByteReadChannel(sseContent),
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentType,
ContentType.Text.EventStream.toString(),
),
)
} else {
respond("", HttpStatusCode.OK)
}
}
val receivedMessages = mutableListOf<JSONRPCMessage>()
val responseReceived = CompletableDeferred<Unit>()
transport.onMessage { message ->
receivedMessages.add(message)
if (message is JSONRPCResponse && !responseReceived.isCompleted) {
responseReceived.complete(Unit)
}
}
transport.start()
transport.send(
JSONRPCRequest(
id = "req-1",
method = "test",
params = buildJsonObject { },
),
)
eventually {
responseReceived.await()
}
receivedMessages shouldHaveSize 1
val response = receivedMessages[0] as JSONRPCResponse
response.id shouldBe RequestId.StringId("req-1")
transport.close()
}
@Test
fun testInlineSseHasPrimingEventTracking() = runTest {
val transport = createTransport { request ->
if (request.method == HttpMethod.Post) {
val sseContent = buildString {
// Event with id = priming event
appendLine("id: priming-1")
appendLine("event: message")
appendLine(
"""data: {"jsonrpc":"2.0","method":"notifications/progress",""" +
""""params":{"progressToken":"t1","progress":50}}""",
)
appendLine()
// Notification without id
appendLine("event: message")
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}""")
appendLine()
}
respond(
content = ByteReadChannel(sseContent),
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentType,
ContentType.Text.EventStream.toString(),
),
)
} else {
respond("", HttpStatusCode.OK)
}
}
val receivedMessages = mutableListOf<JSONRPCMessage>()
val twoMessagesReceived = CompletableDeferred<Unit>()
transport.onMessage { message ->
receivedMessages.add(message)
if (receivedMessages.size >= 2 && !twoMessagesReceived.isCompleted) {
twoMessagesReceived.complete(Unit)
}
}
transport.start()
transport.send(
JSONRPCRequest(
id = "test-1",
method = "test",
params = buildJsonObject { },
),
)
eventually {
twoMessagesReceived.await()
}
receivedMessages shouldHaveSize 2
// Both should be notifications (no JSONRPCResponse → POST-to-GET reconnect would be triggered)
receivedMessages[0].shouldBeInstanceOf<JSONRPCNotification>()
receivedMessages[1].shouldBeInstanceOf<JSONRPCNotification>()
transport.close()
}
@Test
fun testInlineSseResponseStopsReconnection() = runTest {
val transport = createTransport { request ->
if (request.method == HttpMethod.Post) {
val sseContent = buildString {
appendLine("id: ev-1")
appendLine("event: message")
appendLine("""data: {"jsonrpc":"2.0","id":"req-1","result":{"tools":[]}}""")
appendLine()
}
respond(
content = ByteReadChannel(sseContent),
status = HttpStatusCode.OK,
headers = headersOf(
HttpHeaders.ContentType,
ContentType.Text.EventStream.toString(),
),
)
} else {
respond("", HttpStatusCode.OK)
}
}
val receivedMessages = mutableListOf<JSONRPCMessage>()
val responseReceived = CompletableDeferred<Unit>()
transport.onMessage { message ->
receivedMessages.add(message)
if (message is JSONRPCResponse && !responseReceived.isCompleted) {
responseReceived.complete(Unit)
}
}
transport.start()
transport.send(
JSONRPCRequest(
id = "req-1",
method = "tools/list",
params = buildJsonObject { },
),
)
eventually {
responseReceived.await()
}
receivedMessages shouldHaveSize 1
// Response received → no reconnection triggered (hasPrimingEvent=true, receivedResponse=true)
val response = receivedMessages[0] as JSONRPCResponse
response.id shouldBe RequestId.StringId("req-1")
transport.close()
}
@Suppress("DEPRECATION")
@Test
fun testDeprecatedConstructorStillWorks() = runTest {
val mockEngine = MockEngine { _ ->
respond(
content = "",
status = HttpStatusCode.Accepted,
)
}
val httpClient = HttpClient(mockEngine) {
install(SSE)
}
val transport =
StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp", reconnectionTime = 2.seconds)
transport.start()
transport.send(JSONRPCNotification(method = "test"))
transport.close()
}
private suspend fun setupTransportAndCollectMessages(
transport: StreamableHttpClientTransport,
): Pair<MutableList<JSONRPCMessage>, MutableList<Throwable>> {

View file

@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
@ -203,7 +204,31 @@ internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
meta = EmptyJsonObject,
)
client.close()
}
@Test
fun `terminateSession sends DELETE request`() = runBlocking {
val client = Client(
clientInfo = Implementation(name = "client1", version = "1.0.0"),
options = ClientOptions(capabilities = ClientCapabilities()),
)
val sessionId = Uuid.random().toString()
mockMcp.onInitialize(clientName = "client1", sessionId = sessionId)
mockMcp.handleJSONRPCRequest(
jsonRpcMethod = "notifications/initialized",
expectedSessionId = sessionId,
sessionId = sessionId,
statusCode = HttpStatusCode.Accepted,
)
mockMcp.handleSubscribeWithGet(sessionId) { emptyFlow() }
connect(client)
mockMcp.mockUnsubscribeRequest(sessionId = sessionId)
(client.transport as StreamableHttpClientTransport).terminateSession()
(client.transport as StreamableHttpClientTransport).sessionId shouldBe null
client.close()
}
@ -257,8 +282,6 @@ internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
buildJsonObject {}
}
mockMcp.mockUnsubscribeRequest(sessionId = sessionId)
connect(client)
delay(1.seconds)