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:
Pavel Gorgulov 2025-11-14 10:39:43 +01:00 • committed by GitHub
commit 666cfd6b82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 7286 additions and 55 deletions

View file

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

View file

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

View file

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

View file

@ -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
}
}

View file

@ -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()
}
}
}

View file

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

View file

@ -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() {

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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()
}
}