* feat: add streamable http client * update StreamableHttpClientTransport * improve StreamableHttpClientTransport handling of headers, event parsing, and session termination * add simple tests for StreamableClient and fix send * add client mocking * add notification e2e tests --------- Co-authored-by: Zach Tang <zachtangoj@gmail.com>
369 lines
13 KiB
Kotlin
369 lines
13 KiB
Kotlin
package client
|
|
|
|
import io.ktor.client.HttpClient
|
|
import io.ktor.client.engine.mock.MockEngine
|
|
import io.ktor.client.engine.mock.MockRequestHandler
|
|
import io.ktor.client.engine.mock.respond
|
|
import io.ktor.client.plugins.sse.SSE
|
|
import io.ktor.http.ContentType
|
|
import io.ktor.http.HttpHeaders
|
|
import io.ktor.http.HttpMethod
|
|
import io.ktor.http.HttpStatusCode
|
|
import io.ktor.http.content.TextContent
|
|
import io.ktor.http.headersOf
|
|
import io.ktor.utils.io.ByteReadChannel
|
|
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
|
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
|
|
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
|
import io.modelcontextprotocol.kotlin.sdk.RequestId
|
|
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
|
|
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.test.runTest
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.JsonPrimitive
|
|
import kotlinx.serialization.json.buildJsonObject
|
|
import org.junit.jupiter.api.assertDoesNotThrow
|
|
import kotlin.test.Ignore
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertNull
|
|
import kotlin.test.assertTrue
|
|
import kotlin.time.Duration.Companion.seconds
|
|
|
|
class StreamableHttpClientTransportTest {
|
|
|
|
private fun createTransport(handler: MockRequestHandler): StreamableHttpClientTransport {
|
|
val mockEngine = MockEngine(handler)
|
|
val httpClient = HttpClient(mockEngine) {
|
|
install(SSE) {
|
|
reconnectionTime = 1.seconds
|
|
}
|
|
}
|
|
|
|
return StreamableHttpClientTransport(httpClient, url = "http://localhost:8080/mcp")
|
|
}
|
|
|
|
@Test
|
|
fun testSendJsonRpcMessage() = runTest {
|
|
val message = JSONRPCRequest(
|
|
id = RequestId.StringId("test-id"),
|
|
method = "test",
|
|
params = buildJsonObject { }
|
|
)
|
|
|
|
val transport = createTransport { request ->
|
|
assertEquals(HttpMethod.Post, request.method)
|
|
assertEquals("http://localhost:8080/mcp", request.url.toString())
|
|
assertEquals(ContentType.Application.Json, request.body.contentType)
|
|
|
|
val body = (request.body as TextContent).text
|
|
val decodedMessage = McpJson.decodeFromString<JSONRPCMessage>(body)
|
|
assertEquals(message, decodedMessage)
|
|
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.Accepted
|
|
)
|
|
}
|
|
|
|
transport.start()
|
|
transport.send(message)
|
|
transport.close()
|
|
}
|
|
|
|
@Test
|
|
fun testStoreSessionId() = runTest {
|
|
val initMessage = JSONRPCRequest(
|
|
id = RequestId.StringId("test-id"),
|
|
method = "initialize",
|
|
params = buildJsonObject {
|
|
put("clientInfo", buildJsonObject {
|
|
put("name", JsonPrimitive("test-client"))
|
|
put("version", JsonPrimitive("1.0"))
|
|
})
|
|
put("protocolVersion", JsonPrimitive("2025-06-18"))
|
|
}
|
|
)
|
|
|
|
val transport = createTransport { request ->
|
|
when (val msg = McpJson.decodeFromString<JSONRPCMessage>((request.body as TextContent).text)) {
|
|
is JSONRPCRequest if msg.method == "initialize" -> respond(
|
|
content = "", status = HttpStatusCode.OK,
|
|
headers = headersOf("mcp-session-id", "test-session-id")
|
|
)
|
|
|
|
is JSONRPCNotification if msg.method == "test" -> {
|
|
assertEquals("test-session-id", request.headers["mcp-session-id"])
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.Accepted
|
|
)
|
|
}
|
|
|
|
else -> error("Unexpected message: $msg")
|
|
}
|
|
}
|
|
|
|
transport.start()
|
|
transport.send(initMessage)
|
|
|
|
assertEquals("test-session-id", transport.sessionId)
|
|
|
|
transport.send(JSONRPCNotification(method = "test"))
|
|
|
|
transport.close()
|
|
}
|
|
|
|
@Test
|
|
fun testTerminateSession() = runTest {
|
|
// transport.sessionId = "test-session-id"
|
|
|
|
val transport = createTransport { request ->
|
|
assertEquals(HttpMethod.Delete, request.method)
|
|
assertEquals("test-session-id", request.headers["mcp-session-id"])
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.OK
|
|
)
|
|
}
|
|
|
|
transport.start()
|
|
transport.terminateSession()
|
|
|
|
assertNull(transport.sessionId)
|
|
transport.close()
|
|
}
|
|
|
|
@Test
|
|
fun testTerminateSessionHandle405() = runTest {
|
|
// transport.sessionId = "test-session-id"
|
|
|
|
val transport = createTransport { request ->
|
|
assertEquals(HttpMethod.Delete, request.method)
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.MethodNotAllowed
|
|
)
|
|
}
|
|
|
|
transport.start()
|
|
// Should not throw for 405
|
|
assertDoesNotThrow {
|
|
transport.terminateSession()
|
|
}
|
|
|
|
// Session ID should still be cleared
|
|
assertNull(transport.sessionId)
|
|
transport.close()
|
|
}
|
|
|
|
@Test
|
|
fun testProtocolVersionHeader() = runTest {
|
|
val transport = createTransport { request ->
|
|
assertEquals("2025-06-18", request.headers["mcp-protocol-version"])
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.Accepted
|
|
)
|
|
}
|
|
transport.protocolVersion = "2025-06-18"
|
|
|
|
transport.start()
|
|
transport.send(JSONRPCNotification(method = "test"))
|
|
transport.close()
|
|
}
|
|
|
|
@Ignore("Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support")
|
|
@Test
|
|
fun testNotificationSchemaE2E() = runTest {
|
|
val receivedMessages = mutableListOf<JSONRPCMessage>()
|
|
var sseStarted = false
|
|
|
|
val transport = createTransport { request ->
|
|
when (request.method) {
|
|
HttpMethod.Post if request.body.toString().contains("notifications/initialized") -> {
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.Accepted,
|
|
headers = headersOf("mcp-session-id", "notification-test-session")
|
|
)
|
|
}
|
|
|
|
// Handle SSE connection
|
|
HttpMethod.Get -> {
|
|
sseStarted = true
|
|
val sseContent = buildString {
|
|
// Server sends various notifications
|
|
appendLine("event: message")
|
|
appendLine("id: 1")
|
|
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""")
|
|
appendLine()
|
|
|
|
appendLine("event: message")
|
|
appendLine("id: 2")
|
|
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/resources/list_changed"}""")
|
|
appendLine()
|
|
|
|
appendLine("event: message")
|
|
appendLine("id: 3")
|
|
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()
|
|
)
|
|
)
|
|
}
|
|
|
|
// Handle regular notifications
|
|
HttpMethod.Post -> {
|
|
respond(
|
|
content = "",
|
|
status = HttpStatusCode.Accepted
|
|
)
|
|
}
|
|
|
|
else -> respond("", HttpStatusCode.OK)
|
|
}
|
|
}
|
|
|
|
transport.onMessage { message ->
|
|
receivedMessages.add(message)
|
|
}
|
|
|
|
transport.start()
|
|
|
|
// Test 1: Send initialized notification to trigger SSE
|
|
val initializedNotification = JSONRPCNotification(
|
|
method = "notifications/initialized",
|
|
params = buildJsonObject {
|
|
put("protocolVersion", JsonPrimitive("1.0"))
|
|
put("capabilities", buildJsonObject {
|
|
put("tools", JsonPrimitive(true))
|
|
put("resources", JsonPrimitive(true))
|
|
})
|
|
}
|
|
)
|
|
|
|
transport.send(initializedNotification)
|
|
|
|
// Verify SSE was triggered
|
|
assertTrue(sseStarted, "SSE should start after initialized notification")
|
|
|
|
// Test 2: Verify received notifications
|
|
assertEquals(3, receivedMessages.size)
|
|
assertTrue(receivedMessages.all { it is JSONRPCNotification })
|
|
|
|
val notifications = receivedMessages.filterIsInstance<JSONRPCNotification>()
|
|
|
|
// Verify progress notification
|
|
val progressNotif = notifications[0]
|
|
assertEquals("notifications/progress", progressNotif.method)
|
|
val progressParams = progressNotif.params as JsonObject
|
|
assertEquals("upload-123", (progressParams["progressToken"] as JsonPrimitive).content)
|
|
assertEquals(50, (progressParams["progress"] as JsonPrimitive).content.toInt())
|
|
|
|
// Verify list changed notifications
|
|
assertEquals("notifications/resources/list_changed", notifications[1].method)
|
|
assertEquals("notifications/tools/list_changed", notifications[2].method)
|
|
|
|
// Test 3: Send various client notifications
|
|
val clientNotifications = listOf(
|
|
JSONRPCNotification(
|
|
method = "notifications/progress",
|
|
params = buildJsonObject {
|
|
put("progressToken", JsonPrimitive("download-456"))
|
|
put("progress", JsonPrimitive(75))
|
|
}
|
|
),
|
|
JSONRPCNotification(
|
|
method = "notifications/cancelled",
|
|
params = buildJsonObject {
|
|
put("requestId", JsonPrimitive("req-789"))
|
|
put("reason", JsonPrimitive("user_cancelled"))
|
|
}
|
|
),
|
|
JSONRPCNotification(
|
|
method = "notifications/message",
|
|
params = buildJsonObject {
|
|
put("level", JsonPrimitive("info"))
|
|
put("message", JsonPrimitive("Operation completed"))
|
|
put("data", buildJsonObject {
|
|
put("duration", JsonPrimitive(1234))
|
|
})
|
|
}
|
|
)
|
|
)
|
|
|
|
// Send all client notifications
|
|
clientNotifications.forEach { notification ->
|
|
transport.send(notification)
|
|
}
|
|
|
|
// Verify session ID is maintained
|
|
assertEquals("notification-test-session", transport.sessionId)
|
|
transport.close()
|
|
}
|
|
|
|
@Ignore("Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support")
|
|
@Test
|
|
fun testNotificationWithResumptionToken() = runTest {
|
|
var resumptionTokenReceived: String? = null
|
|
var lastEventIdSent: String? = null
|
|
|
|
val transport = createTransport { request ->
|
|
// Capture Last-Event-ID header
|
|
lastEventIdSent = request.headers["Last-Event-ID"]
|
|
|
|
when (request.method) {
|
|
HttpMethod.Get -> {
|
|
val sseContent = buildString {
|
|
appendLine("event: message")
|
|
appendLine("id: resume-100")
|
|
appendLine("""data: {"jsonrpc":"2.0","method":"notifications/resumed","params":{"fromToken":"${lastEventIdSent}"}}""")
|
|
appendLine()
|
|
}
|
|
respond(
|
|
content = ByteReadChannel(sseContent),
|
|
status = HttpStatusCode.OK,
|
|
headers = headersOf(
|
|
HttpHeaders.ContentType, ContentType.Text.EventStream.toString()
|
|
)
|
|
)
|
|
}
|
|
|
|
else -> respond("", HttpStatusCode.Accepted)
|
|
}
|
|
}
|
|
|
|
transport.start()
|
|
|
|
// Send notification with resumption token
|
|
transport.send(
|
|
message = JSONRPCNotification(
|
|
method = "notifications/test",
|
|
params = buildJsonObject {
|
|
put("data", JsonPrimitive("test-data"))
|
|
}
|
|
),
|
|
resumptionToken = "previous-token-99",
|
|
onResumptionToken = { token ->
|
|
resumptionTokenReceived = token
|
|
}
|
|
)
|
|
|
|
// Wait for response
|
|
delay(1.seconds)
|
|
|
|
// Verify resumption token was sent in header
|
|
assertEquals("previous-token-99", lastEventIdSent)
|
|
|
|
// Verify new resumption token was received
|
|
assertEquals("resume-100", resumptionTokenReceived)
|
|
transport.close()
|
|
}
|
|
}
|