Add test with new schema types (#389)
Add tests with new schema types ## Motivation and Context <!-- Why is this change needed? What problem does it solve? --> ## How Has This Been Tested? The repository currently contains duplicated tests: - tests using the new schema - tests using the old schema for compatibility checks. they will be removed once the deprecation level is raised ## Breaking Changes No ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [x] Testing ## 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:
parent
4aeed923f6
commit
666cfd6b82
10 changed files with 7286 additions and 55 deletions
|
|
@ -1,7 +1,7 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Comprehensive test suite for MCP Client meta parameter functionality
|
||||
*
|
||||
* Tests cover:
|
||||
* - Meta key validation according to MCP specification
|
||||
* - JSON type conversion for various data types
|
||||
* - Error handling for invalid meta keys
|
||||
* - Integration with callTool method
|
||||
*/
|
||||
class OldSchemaClientMetaParameterTest {
|
||||
|
||||
private lateinit var client: Client
|
||||
private lateinit var mockTransport: OldSchemaMockTransport
|
||||
private val clientInfo = Implementation("test-client", "1.0.0")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() = runTest {
|
||||
mockTransport = OldSchemaMockTransport()
|
||||
client = Client(clientInfo = clientInfo)
|
||||
mockTransport.setupInitializationResponse()
|
||||
client.connect(mockTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should accept valid meta keys without throwing exception`() = runTest {
|
||||
val validMeta = buildMap {
|
||||
put("simple-key", "value1")
|
||||
put("api.example.com/version", "1.0")
|
||||
put("com.company.app/setting", "enabled")
|
||||
put("retry_count", 3)
|
||||
put("user.preference", true)
|
||||
put("valid123", "alphanumeric")
|
||||
put("multi.dot.name", "multiple-dots")
|
||||
put("under_score", "underscore")
|
||||
put("hyphen-dash", "hyphen")
|
||||
put("org.apache.kafka/consumer-config", "complex-valid-prefix")
|
||||
}
|
||||
|
||||
val result = runCatching {
|
||||
client.callTool("test-tool", mapOf("arg" to "value"), validMeta)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess, "Valid meta keys should not cause exceptions")
|
||||
mockTransport.lastJsonRpcRequest()?.let { request ->
|
||||
val params = request.params as JsonObject
|
||||
assertTrue(params.containsKey("_meta"), "Request should contain _meta field")
|
||||
val metaField = params["_meta"] as JsonObject
|
||||
|
||||
// Verify all meta keys are present
|
||||
assertEquals(validMeta.size, metaField.size, "All meta keys should be included")
|
||||
|
||||
// Verify specific key-value pairs
|
||||
assertEquals("value1", metaField["simple-key"]?.jsonPrimitive?.content)
|
||||
assertEquals("1.0", metaField["api.example.com/version"]?.jsonPrimitive?.content)
|
||||
assertEquals("enabled", metaField["com.company.app/setting"]?.jsonPrimitive?.content)
|
||||
assertEquals(3, metaField["retry_count"]?.jsonPrimitive?.int)
|
||||
assertEquals(true, metaField["user.preference"]?.jsonPrimitive?.boolean)
|
||||
assertEquals("alphanumeric", metaField["valid123"]?.jsonPrimitive?.content)
|
||||
assertEquals("multiple-dots", metaField["multi.dot.name"]?.jsonPrimitive?.content)
|
||||
assertEquals("underscore", metaField["under_score"]?.jsonPrimitive?.content)
|
||||
assertEquals("hyphen", metaField["hyphen-dash"]?.jsonPrimitive?.content)
|
||||
assertEquals("complex-valid-prefix", metaField["org.apache.kafka/consumer-config"]?.jsonPrimitive?.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should accept edge case valid prefixes and names`() = runTest {
|
||||
val edgeCaseValidMeta = buildMap {
|
||||
put("a/", "single-char-prefix-empty-name") // empty name is allowed
|
||||
put("a1-b2/test", "alphanumeric-hyphen-prefix")
|
||||
put("long.domain.name.here/config", "long-prefix")
|
||||
put("x/a", "minimal-valid-key")
|
||||
put("test123", "alphanumeric-name-only")
|
||||
}
|
||||
|
||||
val result = runCatching {
|
||||
client.callTool("test-tool", emptyMap(), edgeCaseValidMeta)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess, "Edge case valid meta keys should be accepted")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject mcp reserved prefix`() = runTest {
|
||||
val invalidMeta = mapOf("mcp/internal" to "value")
|
||||
|
||||
val exception = assertFailsWith<IllegalArgumentException> {
|
||||
client.callTool("test-tool", emptyMap(), invalidMeta)
|
||||
}
|
||||
|
||||
assertContains(
|
||||
charSequence = exception.message ?: "",
|
||||
other = "Invalid _meta key",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject modelcontextprotocol reserved prefix`() = runTest {
|
||||
val invalidMeta = mapOf("modelcontextprotocol/config" to "value")
|
||||
|
||||
val exception = assertFailsWith<IllegalArgumentException> {
|
||||
client.callTool("test-tool", emptyMap(), invalidMeta)
|
||||
}
|
||||
|
||||
assertContains(
|
||||
charSequence = exception.message ?: "",
|
||||
other = "Invalid _meta key",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject nested reserved prefixes`() = runTest {
|
||||
val invalidKeys = listOf(
|
||||
"api.mcp.io/setting",
|
||||
"com.modelcontextprotocol.test/value",
|
||||
"example.mcp/data",
|
||||
"subdomain.mcp.com/config",
|
||||
"app.modelcontextprotocol.dev/setting",
|
||||
"test.mcp/value",
|
||||
"service.modelcontextprotocol/data",
|
||||
)
|
||||
|
||||
invalidKeys.forEach { key ->
|
||||
val exception = assertFailsWith<IllegalArgumentException>(
|
||||
message = "Should reject nested reserved key: $key",
|
||||
) {
|
||||
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
|
||||
}
|
||||
assertContains(
|
||||
charSequence = exception.message ?: "",
|
||||
other = "Invalid _meta key",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject case-insensitive reserved prefixes`() = runTest {
|
||||
val invalidKeys = listOf(
|
||||
"MCP/internal",
|
||||
"Mcp/config",
|
||||
"mCp/setting",
|
||||
"MODELCONTEXTPROTOCOL/data",
|
||||
"ModelContextProtocol/value",
|
||||
"modelContextProtocol/test",
|
||||
)
|
||||
|
||||
invalidKeys.forEach { key ->
|
||||
val exception = assertFailsWith<IllegalArgumentException>(
|
||||
message = "Should reject case-insensitive reserved key: $key",
|
||||
) {
|
||||
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
|
||||
}
|
||||
assertContains(
|
||||
charSequence = exception.message ?: "",
|
||||
other = "Invalid _meta key",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reject invalid key formats`() = runTest {
|
||||
val invalidKeys = listOf(
|
||||
"", // empty key - not allowed at key level
|
||||
"/invalid", // starts with slash
|
||||
"-invalid", // starts with hyphen
|
||||
".invalid", // starts with dot
|
||||
"in valid", // contains space
|
||||
"api../test", // consecutive dots
|
||||
"api./test", // label ends with dot
|
||||
)
|
||||
|
||||
invalidKeys.forEach { key ->
|
||||
assertFailsWith<IllegalArgumentException>(
|
||||
message = "Should reject invalid key format: '$key'",
|
||||
) {
|
||||
client.callTool("test-tool", emptyMap(), mapOf(key to "value"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should convert various data types to JSON correctly`() = runTest {
|
||||
val complexMeta = createComplexMetaData()
|
||||
|
||||
val result = runCatching {
|
||||
client.callTool(
|
||||
"test-tool",
|
||||
emptyMap(),
|
||||
complexMeta,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess, "Complex data type conversion should not throw exceptions")
|
||||
|
||||
mockTransport.lastJsonRpcRequest()?.let { request ->
|
||||
assertEquals("tools/call", request.method)
|
||||
val params = request.params as JsonObject
|
||||
assertTrue(params.containsKey("_meta"), "Request should contain _meta field")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle nested map structures correctly`() = runTest {
|
||||
val nestedMeta = buildNestedConfiguration()
|
||||
|
||||
val result = runCatching {
|
||||
client.callTool("test-tool", emptyMap(), nestedMeta)
|
||||
}
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
|
||||
mockTransport.lastJsonRpcRequest()?.let { request ->
|
||||
val params = request.params as JsonObject
|
||||
val metaField = params["_meta"] as JsonObject
|
||||
assertTrue(metaField.containsKey("config"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should include empty meta object when meta parameter not provided`() = runTest {
|
||||
client.callTool("test-tool", mapOf("arg" to "value"))
|
||||
|
||||
mockTransport.lastJsonRpcRequest()?.let { request ->
|
||||
val params = request.params as JsonObject
|
||||
val metaField = params["_meta"] as JsonObject
|
||||
assertTrue(metaField.isEmpty(), "Meta field should be empty when not provided")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createComplexMetaData(): Map<String, Any?> = buildMap {
|
||||
put("string", "text")
|
||||
put("number", 42)
|
||||
put("boolean", true)
|
||||
put("null_value", null)
|
||||
put("list", listOf(1, 2, 3))
|
||||
put("map", mapOf("nested" to "value"))
|
||||
put("enum", "STRING")
|
||||
put("int_array", intArrayOf(1, 2, 3))
|
||||
}
|
||||
|
||||
private fun buildNestedConfiguration(): Map<String, Any> = buildMap {
|
||||
put(
|
||||
"config",
|
||||
buildMap {
|
||||
put(
|
||||
"database",
|
||||
buildMap {
|
||||
put("host", "localhost")
|
||||
put("port", 5432)
|
||||
},
|
||||
)
|
||||
put("features", listOf("feature1", "feature2"))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun OldSchemaMockTransport.lastJsonRpcRequest(): JSONRPCRequest? =
|
||||
getSentMessages().lastOrNull() as? JSONRPCRequest
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class OldSchemaMockTransport : Transport {
|
||||
private val _sentMessages = mutableListOf<JSONRPCMessage>()
|
||||
private val _receivedMessages = mutableListOf<JSONRPCMessage>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun getSentMessages() = mutex.withLock { _sentMessages.toList() }
|
||||
suspend fun getReceivedMessages() = mutex.withLock { _receivedMessages.toList() }
|
||||
|
||||
private var onMessageBlock: (suspend (JSONRPCMessage) -> Unit)? = null
|
||||
private var onCloseBlock: (() -> Unit)? = null
|
||||
private var onErrorBlock: ((Throwable) -> Unit)? = null
|
||||
|
||||
override suspend fun start() = Unit
|
||||
|
||||
override suspend fun send(message: JSONRPCMessage) {
|
||||
mutex.withLock {
|
||||
_sentMessages += message
|
||||
}
|
||||
|
||||
// Auto-respond to initialization and tool calls
|
||||
when (message) {
|
||||
is JSONRPCRequest -> {
|
||||
when (message.method) {
|
||||
"initialize" -> {
|
||||
val initResponse = JSONRPCResponse(
|
||||
id = message.id,
|
||||
result = InitializeResult(
|
||||
protocolVersion = "2024-11-05",
|
||||
capabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = null),
|
||||
),
|
||||
serverInfo = Implementation("mock-server", "1.0.0"),
|
||||
),
|
||||
)
|
||||
onMessageBlock?.invoke(initResponse)
|
||||
}
|
||||
|
||||
"tools/call" -> {
|
||||
val toolResponse = JSONRPCResponse(
|
||||
id = message.id,
|
||||
result = CallToolResult(
|
||||
content = listOf(),
|
||||
isError = false,
|
||||
),
|
||||
)
|
||||
onMessageBlock?.invoke(toolResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Handle other message types if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
onCloseBlock?.invoke()
|
||||
}
|
||||
|
||||
override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
|
||||
onMessageBlock = { message ->
|
||||
mutex.withLock {
|
||||
_receivedMessages += message
|
||||
}
|
||||
block(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClose(block: () -> Unit) {
|
||||
onCloseBlock = block
|
||||
}
|
||||
|
||||
override fun onError(block: (Throwable) -> Unit) {
|
||||
onErrorBlock = block
|
||||
}
|
||||
|
||||
fun setupInitializationResponse() {
|
||||
// This method helps set up the mock for proper initialization
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.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.Implementation
|
||||
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.shared.McpJson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlin.test.Ignore
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class OldSchemaStreamableHttpClientTransportTest {
|
||||
|
||||
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
|
||||
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()
|
||||
}
|
||||
|
||||
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
|
||||
@Ignore
|
||||
@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()
|
||||
}
|
||||
|
||||
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
|
||||
@Ignore
|
||||
@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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClientConnectWithInvalidJson() = runTest {
|
||||
// Transport under test: respond with invalid JSON for the initialize request
|
||||
val transport = createTransport { _ ->
|
||||
respond(
|
||||
"this is not valid json",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
}
|
||||
|
||||
val client = Client(
|
||||
clientInfo = Implementation(
|
||||
name = "test-client",
|
||||
version = "1.0",
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
// Real time-keeping is needed; otherwise Protocol will always throw TimeoutCancellationException in tests
|
||||
assertFailsWith<IllegalStateException>(
|
||||
message = "Expected client.connect to fail on invalid JSON response",
|
||||
) {
|
||||
withContext(Dispatchers.Default.limitedParallelism(1)) {
|
||||
withTimeout(5.seconds) {
|
||||
client.connect(transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@ 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.Implementation
|
||||
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.shared.McpJson
|
||||
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.McpJson
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import org.junit.jupiter.api.TestInstance
|
|||
internal abstract class AbstractStreamableHttpClientTest {
|
||||
|
||||
// start mokksy on random port
|
||||
protected val mockMcp: MockMcp = MockMcp(verbose = true)
|
||||
protected val mockMcp: OldSchemaMockMcp = OldSchemaMockMcp(verbose = true)
|
||||
|
||||
@AfterEach
|
||||
fun afterEach() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import dev.mokksy.mokksy.BuildingStep
|
||||
import dev.mokksy.mokksy.Mokksy
|
||||
import dev.mokksy.mokksy.StubConfiguration
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
const val MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
||||
|
||||
internal class MockMcp(verbose: Boolean = false) {
|
||||
|
||||
private val mokksy: Mokksy = Mokksy(verbose = verbose)
|
||||
|
||||
fun checkForUnmatchedRequests() {
|
||||
mokksy.checkForUnmatchedRequests()
|
||||
}
|
||||
|
||||
val url = "${mokksy.baseUrl()}/mcp"
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun onInitialize(
|
||||
clientName: String? = null,
|
||||
sessionId: String,
|
||||
protocolVersion: String = "2025-03-26",
|
||||
serverName: String = "Mock MCP Server",
|
||||
serverVersion: String = "1.0.0",
|
||||
capabilities: JsonObject = buildJsonObject {
|
||||
putJsonObject("tools") {
|
||||
put("listChanged", JsonPrimitive(false))
|
||||
}
|
||||
},
|
||||
) {
|
||||
val predicates = if (clientName != null) {
|
||||
arrayOf<(JSONRPCRequest?) -> Boolean>({
|
||||
it?.params?.jsonObject
|
||||
?.get("clientInfo")?.jsonObject
|
||||
?.get("name")?.jsonPrimitive
|
||||
?.contentOrNull == clientName
|
||||
})
|
||||
} else {
|
||||
emptyArray()
|
||||
}
|
||||
|
||||
handleWithResult(
|
||||
jsonRpcMethod = "initialize",
|
||||
sessionId = sessionId,
|
||||
bodyPredicates = predicates,
|
||||
// language=json
|
||||
result = """
|
||||
{
|
||||
"capabilities": $capabilities,
|
||||
"protocolVersion": "$protocolVersion",
|
||||
"serverInfo": {
|
||||
"name": "$serverName",
|
||||
"version": "$serverVersion"
|
||||
},
|
||||
"_meta": {
|
||||
"foo": "bar"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun onJSONRPCRequest(
|
||||
httpMethod: HttpMethod = HttpMethod.Post,
|
||||
jsonRpcMethod: String,
|
||||
expectedSessionId: String? = null,
|
||||
vararg bodyPredicates: (JSONRPCRequest) -> Boolean,
|
||||
): BuildingStep<JSONRPCRequest> = mokksy.method(
|
||||
configuration = StubConfiguration(removeAfterMatch = true),
|
||||
httpMethod = httpMethod,
|
||||
requestType = JSONRPCRequest::class,
|
||||
) {
|
||||
path("/mcp")
|
||||
expectedSessionId?.let {
|
||||
containsHeader(MCP_SESSION_ID_HEADER, it)
|
||||
}
|
||||
bodyMatchesPredicate(
|
||||
description = "JSON-RPC version is '2.0'",
|
||||
predicate =
|
||||
{
|
||||
it!!.jsonrpc == "2.0"
|
||||
},
|
||||
)
|
||||
bodyMatchesPredicate(
|
||||
description = "JSON-RPC Method should be '$jsonRpcMethod'",
|
||||
predicate =
|
||||
{
|
||||
it!!.method == jsonRpcMethod
|
||||
},
|
||||
)
|
||||
bodyPredicates.forEach { predicate ->
|
||||
bodyMatchesPredicate(predicate = { predicate.invoke(it!!) })
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun handleWithResult(
|
||||
httpMethod: HttpMethod = HttpMethod.Post,
|
||||
jsonRpcMethod: String,
|
||||
expectedSessionId: String? = null,
|
||||
sessionId: String,
|
||||
contentType: ContentType = ContentType.Application.Json,
|
||||
statusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
vararg bodyPredicates: (JSONRPCRequest) -> Boolean,
|
||||
result: () -> JsonObject,
|
||||
) {
|
||||
onJSONRPCRequest(
|
||||
httpMethod = httpMethod,
|
||||
jsonRpcMethod = jsonRpcMethod,
|
||||
expectedSessionId = expectedSessionId,
|
||||
bodyPredicates = bodyPredicates,
|
||||
) respondsWith {
|
||||
val requestId = when (request.body.id) {
|
||||
is RequestId.NumberId -> (request.body.id as RequestId.NumberId).value.toString()
|
||||
is RequestId.StringId -> "\"${(request.body.id as RequestId.StringId).value}\""
|
||||
}
|
||||
val resultObject = result.invoke()
|
||||
// language=json
|
||||
body = """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": $requestId,
|
||||
"result": $resultObject
|
||||
}
|
||||
""".trimIndent()
|
||||
this.contentType = contentType
|
||||
headers += MCP_SESSION_ID_HEADER to sessionId
|
||||
httpStatus = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun handleWithResult(
|
||||
httpMethod: HttpMethod = HttpMethod.Post,
|
||||
jsonRpcMethod: String,
|
||||
expectedSessionId: String? = null,
|
||||
sessionId: String,
|
||||
contentType: ContentType = ContentType.Application.Json,
|
||||
statusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
vararg bodyPredicates: (JSONRPCRequest) -> Boolean,
|
||||
result: String,
|
||||
) {
|
||||
handleWithResult(
|
||||
httpMethod = httpMethod,
|
||||
jsonRpcMethod = jsonRpcMethod,
|
||||
expectedSessionId = expectedSessionId,
|
||||
sessionId = sessionId,
|
||||
contentType = contentType,
|
||||
statusCode = statusCode,
|
||||
bodyPredicates = bodyPredicates,
|
||||
result = {
|
||||
Json.parseToJsonElement(result).jsonObject
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun handleJSONRPCRequest(
|
||||
httpMethod: HttpMethod = HttpMethod.Post,
|
||||
jsonRpcMethod: String,
|
||||
expectedSessionId: String? = null,
|
||||
sessionId: String,
|
||||
contentType: ContentType = ContentType.Application.Json,
|
||||
statusCode: HttpStatusCode = HttpStatusCode.OK,
|
||||
vararg bodyPredicates: (JSONRPCRequest?) -> Boolean,
|
||||
bodyBuilder: () -> String = { "" },
|
||||
) {
|
||||
onJSONRPCRequest(
|
||||
httpMethod = httpMethod,
|
||||
jsonRpcMethod = jsonRpcMethod,
|
||||
expectedSessionId = expectedSessionId,
|
||||
bodyPredicates = bodyPredicates,
|
||||
) respondsWith {
|
||||
body = bodyBuilder.invoke()
|
||||
this.contentType = contentType
|
||||
headers += MCP_SESSION_ID_HEADER to sessionId
|
||||
httpStatus = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
fun onSubscribe(httpMethod: HttpMethod = HttpMethod.Post, sessionId: String): BuildingStep<Any> = mokksy.method(
|
||||
httpMethod = httpMethod,
|
||||
name = "MCP GETs",
|
||||
requestType = Any::class,
|
||||
) {
|
||||
path("/mcp")
|
||||
containsHeader(MCP_SESSION_ID_HEADER, sessionId)
|
||||
containsHeader("Accept", "application/json,text/event-stream")
|
||||
containsHeader("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
fun handleSubscribeWithGet(sessionId: String, block: () -> Flow<ServerSentEvent>) {
|
||||
onSubscribe(
|
||||
httpMethod = HttpMethod.Get,
|
||||
sessionId = sessionId,
|
||||
) respondsWithSseStream {
|
||||
headers += MCP_SESSION_ID_HEADER to sessionId
|
||||
this.flow = block.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
fun mockUnsubscribeRequest(sessionId: String) {
|
||||
mokksy.delete(
|
||||
configuration = StubConfiguration(removeAfterMatch = true),
|
||||
requestType = JSONRPCRequest::class,
|
||||
) {
|
||||
path("/mcp")
|
||||
containsHeader(MCP_SESSION_ID_HEADER, sessionId)
|
||||
} respondsWith {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,6 @@ import kotlinx.serialization.json.jsonObject
|
|||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
const val MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
||||
|
||||
/**
|
||||
* High-level helper for simulating an MCP server over Streaming HTTP transport with Server-Sent Events (SSE),
|
||||
* built on top of an HTTP server using the [Mokksy](https://mokksy.dev) library.
|
||||
|
|
@ -30,7 +28,7 @@ const val MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
|||
* @param verbose Whether to print detailed logs. Defaults to `false`.
|
||||
* @author Konstantin Pavlov
|
||||
*/
|
||||
internal class MockMcp(verbose: Boolean = false) {
|
||||
internal class OldSchemaMockMcp(verbose: Boolean = false) {
|
||||
|
||||
private val mokksy: Mokksy = Mokksy(verbose = verbose)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.client
|
||||
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Tool
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@Suppress("LongMethod")
|
||||
internal class StreamableHttpClientTest : AbstractStreamableHttpClientTest() {
|
||||
|
||||
@Test
|
||||
fun `test streamableHttpClient`() = 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) {
|
||||
flow {
|
||||
delay(500.milliseconds)
|
||||
emit(
|
||||
ServerSentEvent(
|
||||
event = "message",
|
||||
id = "1",
|
||||
data = @Suppress("MaxLineLength")
|
||||
//language=json
|
||||
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
|
||||
),
|
||||
)
|
||||
delay(200.milliseconds)
|
||||
emit(
|
||||
ServerSentEvent(
|
||||
data = @Suppress("MaxLineLength")
|
||||
//language=json
|
||||
"""{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"upload-123","progress":50,"total":100}}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: how to get notifications via Client API?
|
||||
|
||||
mockMcp.handleWithResult(
|
||||
jsonRpcMethod = "tools/list",
|
||||
sessionId = sessionId,
|
||||
// language=json
|
||||
result = """
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"title": "Weather Information Provider",
|
||||
"description": "Get current weather information for a location",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "City name or zip code"
|
||||
}
|
||||
},
|
||||
"required": ["location"]
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"description": "Temperature, Celsius"
|
||||
}
|
||||
},
|
||||
"required": ["temperature"]
|
||||
},
|
||||
"_meta": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
connect(client)
|
||||
|
||||
val listToolsResult = client.listTools()
|
||||
|
||||
listToolsResult.tools shouldContain Tool(
|
||||
name = "get_weather",
|
||||
title = "Weather Information Provider",
|
||||
description = "Get current weather information for a location",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("location") {
|
||||
put("type", "string")
|
||||
put("description", "City name or zip code")
|
||||
}
|
||||
},
|
||||
required = listOf("location"),
|
||||
),
|
||||
outputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("temperature") {
|
||||
put("type", "number")
|
||||
put("description", "Temperature, Celsius")
|
||||
}
|
||||
},
|
||||
required = listOf("temperature"),
|
||||
),
|
||||
annotations = null,
|
||||
meta = EmptyJsonObject,
|
||||
)
|
||||
|
||||
mockMcp.mockUnsubscribeRequest(sessionId = sessionId)
|
||||
|
||||
client.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle MethodNotAllowed`() = runBlocking {
|
||||
checkSupportNonStreamingResponse(
|
||||
ContentType.Text.EventStream,
|
||||
HttpStatusCode.MethodNotAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle non-streaming response`() = runBlocking {
|
||||
checkSupportNonStreamingResponse(
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.OK,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun checkSupportNonStreamingResponse(contentType: ContentType, statusCode: HttpStatusCode) {
|
||||
val sessionId = "SID_${Uuid.random().toHexString()}"
|
||||
val clientName = "client-${Uuid.random().toHexString()}"
|
||||
val client = Client(
|
||||
clientInfo = Implementation(name = clientName, version = "1.0.0"),
|
||||
options = ClientOptions(
|
||||
capabilities = ClientCapabilities(),
|
||||
),
|
||||
)
|
||||
|
||||
mockMcp.onInitialize(clientName = clientName, sessionId = sessionId)
|
||||
|
||||
mockMcp.handleJSONRPCRequest(
|
||||
jsonRpcMethod = "notifications/initialized",
|
||||
expectedSessionId = sessionId,
|
||||
sessionId = sessionId,
|
||||
statusCode = HttpStatusCode.Accepted,
|
||||
)
|
||||
|
||||
mockMcp.onSubscribe(
|
||||
httpMethod = HttpMethod.Get,
|
||||
sessionId = sessionId,
|
||||
) respondsWith {
|
||||
headers += MCP_SESSION_ID_HEADER to sessionId
|
||||
body = null
|
||||
httpStatus = statusCode
|
||||
this.contentType = contentType
|
||||
}
|
||||
|
||||
mockMcp.handleWithResult(jsonRpcMethod = "ping", sessionId = sessionId) {
|
||||
buildJsonObject {}
|
||||
}
|
||||
|
||||
mockMcp.mockUnsubscribeRequest(sessionId = sessionId)
|
||||
|
||||
connect(client)
|
||||
|
||||
delay(1.seconds)
|
||||
|
||||
client.ping() // connection is still alive
|
||||
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue