Skip to content

Commit bfaf54e

Browse files
authored
Fix Streamable handling to support JSON-only responses (#309)
quick fix #308
1 parent bc28e6c commit bfaf54e

File tree

2 files changed

+80
-3
lines changed

2 files changed

+80
-3
lines changed

kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,22 @@ public class StreamableHttpClientTransport(
235235
}
236236
logger.debug { "Client SSE session started successfully." }
237237
} catch (e: SSEClientException) {
238-
if (e.response?.status == HttpStatusCode.MethodNotAllowed) {
238+
val responseStatus = e.response?.status
239+
val responseContentType = e.response?.contentType()
240+
241+
// 405 means server doesn't support SSE at GET endpoint - this is expected and valid
242+
if (responseStatus == HttpStatusCode.MethodNotAllowed) {
239243
logger.info { "Server returned 405 for GET/SSE, stream disabled." }
240244
return
241245
}
246+
247+
// If server returns application/json, it means it doesn't support SSE for this session
248+
// This is valid per spec - server can choose to only use JSON responses
249+
if (responseContentType?.match(ContentType.Application.Json) == true) {
250+
logger.info { "Server returned application/json for GET/SSE, using JSON-only mode." }
251+
return
252+
}
253+
242254
_onError(e)
243255
throw e
244256
}

kotlin-sdk-client/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTest.kt

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.modelcontextprotocol.kotlin.sdk.client
22

33
import io.kotest.matchers.collections.shouldContain
4+
import io.ktor.http.ContentType
5+
import io.ktor.http.HttpMethod
46
import io.ktor.http.HttpStatusCode
57
import io.ktor.sse.ServerSentEvent
68
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
@@ -13,16 +15,19 @@ import kotlinx.serialization.json.buildJsonObject
1315
import kotlinx.serialization.json.put
1416
import kotlinx.serialization.json.putJsonObject
1517
import org.junit.jupiter.api.TestInstance
16-
import java.util.UUID
1718
import kotlin.test.Test
1819
import kotlin.time.Duration.Companion.milliseconds
20+
import kotlin.time.Duration.Companion.seconds
21+
import kotlin.uuid.ExperimentalUuidApi
22+
import kotlin.uuid.Uuid
1923

2024
/**
2125
* Integration tests for the `StreamableHttpClientTransport` implementation
2226
* using the [Mokksy](https://mokksy.dev) library
2327
* to simulate Streaming HTTP with server-sent events (SSE).
2428
* @author Konstantin Pavlov
2529
*/
30+
@OptIn(ExperimentalUuidApi::class)
2631
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
2732
@Suppress("LongMethod")
2833
internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
@@ -39,7 +44,7 @@ internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
3944
),
4045
)
4146

42-
val sessionId = UUID.randomUUID().toString()
47+
val sessionId = Uuid.random().toString()
4348

4449
mockMcp.onInitialize(
4550
clientName = "client1",
@@ -148,4 +153,64 @@ internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
148153

149154
client.close()
150155
}
156+
157+
@Test
158+
fun `handle MethodNotAllowed`() = runBlocking {
159+
checkSupportNonStreamingResponse(
160+
ContentType.Text.EventStream,
161+
HttpStatusCode.MethodNotAllowed,
162+
)
163+
}
164+
165+
@Test
166+
fun `handle non-streaming response`() = runBlocking {
167+
checkSupportNonStreamingResponse(
168+
ContentType.Application.Json,
169+
HttpStatusCode.OK,
170+
)
171+
}
172+
173+
private suspend fun checkSupportNonStreamingResponse(contentType: ContentType, statusCode: HttpStatusCode) {
174+
val sessionId = "SID_${Uuid.random().toHexString()}"
175+
val clientName = "client-${Uuid.random().toHexString()}"
176+
val client = Client(
177+
clientInfo = Implementation(name = clientName, version = "1.0.0"),
178+
options = ClientOptions(
179+
capabilities = ClientCapabilities(),
180+
),
181+
)
182+
183+
mockMcp.onInitialize(clientName = clientName, sessionId = sessionId)
184+
185+
mockMcp.handleJSONRPCRequest(
186+
jsonRpcMethod = "notifications/initialized",
187+
expectedSessionId = sessionId,
188+
sessionId = sessionId,
189+
statusCode = HttpStatusCode.Accepted,
190+
)
191+
192+
mockMcp.onSubscribe(
193+
httpMethod = HttpMethod.Get,
194+
sessionId = sessionId,
195+
) respondsWith {
196+
headers += MCP_SESSION_ID_HEADER to sessionId
197+
body = null
198+
httpStatus = statusCode
199+
this.contentType = contentType
200+
}
201+
202+
mockMcp.handleWithResult(jsonRpcMethod = "ping", sessionId = sessionId) {
203+
buildJsonObject {}
204+
}
205+
206+
mockMcp.mockUnsubscribeRequest(sessionId = sessionId)
207+
208+
connect(client)
209+
210+
delay(1.seconds)
211+
212+
client.ping() // connection is still alive
213+
214+
client.close()
215+
}
151216
}

0 commit comments

Comments
 (0)