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
4 changed files with 7286 additions and 55 deletions

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