Skip to content

Commit fdd40b3

Browse files
committed
Ensure proper exception handling and improve JSON deserialization stability.
- Add custom `asJsonObject` method for stricter `JsonObject` validation. - Introduce tests to validate `SerializationException` behavior on invalid JSON inputs.
1 parent e5abc3f commit fdd40b3

File tree

3 files changed

+48
-5
lines changed

3 files changed

+48
-5
lines changed

kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/serializers.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import kotlinx.serialization.encoding.Decoder
1111
import kotlinx.serialization.encoding.Encoder
1212
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
1313
import kotlinx.serialization.json.JsonElement
14+
import kotlinx.serialization.json.JsonObject
1415
import kotlinx.serialization.json.JsonPrimitive
1516
import kotlinx.serialization.json.jsonObject
1617
import kotlinx.serialization.json.jsonPrimitive
@@ -40,6 +41,15 @@ private fun JsonElement.getTypeOrNull(): String? = jsonObject["type"]?.jsonPrimi
4041
*/
4142
private fun JsonElement.getType(): String = requireNotNull(getTypeOrNull()) { "Missing required 'type' field" }
4243

44+
@Throws(SerializationException::class)
45+
private fun JsonElement.asJsonObject(): JsonObject {
46+
if (this !is JsonObject) {
47+
throw SerializationException("Invalid response. JsonObject expected, got: $this")
48+
}
49+
val jsonObject = this.jsonObject
50+
return jsonObject
51+
}
52+
4353
// ============================================================================
4454
// Method Serializer
4555
// ============================================================================
@@ -131,7 +141,7 @@ internal object MediaContentPolymorphicSerializer :
131141
internal object ResourceContentsPolymorphicSerializer :
132142
JsonContentPolymorphicSerializer<ResourceContents>(ResourceContents::class) {
133143
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ResourceContents> {
134-
val jsonObject = element.jsonObject
144+
val jsonObject = element.asJsonObject()
135145
return when {
136146
"text" in jsonObject -> TextResourceContents.serializer()
137147
"blob" in jsonObject -> BlobResourceContents.serializer()
@@ -284,7 +294,7 @@ internal object ServerNotificationPolymorphicSerializer :
284294
* Returns EmptyResult serializer if the JSON object is empty or contains only metadata.
285295
*/
286296
private fun selectEmptyResult(element: JsonElement): DeserializationStrategy<EmptyResult>? {
287-
val jsonObject = element.jsonObject
297+
val jsonObject = element.asJsonObject()
288298
return when {
289299
jsonObject.isEmpty() || (jsonObject.size == 1 && "_meta" in jsonObject) -> EmptyResult.serializer()
290300
else -> null
@@ -296,7 +306,7 @@ private fun selectEmptyResult(element: JsonElement): DeserializationStrategy<Emp
296306
* Returns null if the structure doesn't match any known client result type.
297307
*/
298308
private fun selectClientResultDeserializer(element: JsonElement): DeserializationStrategy<ClientResult>? {
299-
val jsonObject = element.jsonObject
309+
val jsonObject = element.asJsonObject()
300310
return when {
301311
"model" in jsonObject && "role" in jsonObject -> CreateMessageResult.serializer()
302312
"roots" in jsonObject -> ListRootsResult.serializer()
@@ -310,7 +320,7 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio
310320
* Returns null if the structure doesn't match any known server result type.
311321
*/
312322
private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy<ServerResult>? {
313-
val jsonObject = element.jsonObject
323+
val jsonObject = element.asJsonObject()
314324
return when {
315325
"protocolVersion" in jsonObject && "capabilities" in jsonObject -> InitializeResult.serializer()
316326
"completion" in jsonObject -> CompleteResult.serializer()
@@ -378,7 +388,7 @@ internal object ServerResultPolymorphicSerializer :
378388
internal object JSONRPCMessagePolymorphicSerializer :
379389
JsonContentPolymorphicSerializer<JSONRPCMessage>(JSONRPCMessage::class) {
380390
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<JSONRPCMessage> {
381-
val jsonObject = element.jsonObject
391+
val jsonObject = element.asJsonObject()
382392
return when {
383393
"error" in jsonObject -> JSONRPCError.serializer()
384394
"result" in jsonObject -> JSONRPCResponse.serializer()

kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonRpcTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package io.modelcontextprotocol.kotlin.sdk.types
22

33
import io.kotest.assertions.json.shouldEqualJson
4+
import io.kotest.assertions.throwables.shouldThrow
45
import io.kotest.matchers.shouldBe
56
import io.kotest.matchers.types.shouldBeSameInstanceAs
7+
import kotlinx.serialization.SerializationException
68
import kotlinx.serialization.json.boolean
79
import kotlinx.serialization.json.buildJsonObject
810
import kotlinx.serialization.json.int
@@ -43,6 +45,7 @@ class JsonRpcTest {
4345

4446
@Test
4547
fun `should convert JSONRPCRequest to Request`() {
48+
// language=json
4649
val jsonRpc = McpJson.decodeFromString<JSONRPCRequest>(
4750
"""
4851
{
@@ -101,6 +104,7 @@ class JsonRpcTest {
101104

102105
@Test
103106
fun `should convert JSONRPCNotification to Notification`() {
107+
// language=json
104108
val jsonRpc = McpJson.decodeFromString<JSONRPCNotification>(
105109
"""
106110
{
@@ -164,6 +168,7 @@ class JsonRpcTest {
164168

165169
@Test
166170
fun `should deserialize JSONRPCRequest with numeric id`() {
171+
// language=json
167172
val json = """
168173
{
169174
"id": 42,
@@ -212,6 +217,7 @@ class JsonRpcTest {
212217

213218
@Test
214219
fun `should deserialize JSONRPCNotification`() {
220+
// language=json
215221
val json = """
216222
{
217223
"method": "notifications/progress",
@@ -257,6 +263,7 @@ class JsonRpcTest {
257263

258264
@Test
259265
fun `should deserialize JSONRPCResponse with EmptyResult`() {
266+
// language=json
260267
val json = """
261268
{
262269
"id": 7,
@@ -308,6 +315,7 @@ class JsonRpcTest {
308315

309316
@Test
310317
fun `should deserialize JSONRPCError`() {
318+
// language=json
311319
val json = """
312320
{
313321
"id": "req-404",
@@ -336,6 +344,7 @@ class JsonRpcTest {
336344

337345
@Test
338346
fun `should decode JSONRPCMessage as request`() {
347+
// language=json
339348
val json = """
340349
{
341350
"id": "msg-1",
@@ -359,6 +368,7 @@ class JsonRpcTest {
359368

360369
@Test
361370
fun `should decode JSONRPCMessage as error response`() {
371+
// language=json
362372
val json = """
363373
{
364374
"id": 123,
@@ -401,6 +411,15 @@ class JsonRpcTest {
401411
""".trimIndent()
402412
}
403413

414+
@Test
415+
fun `JSONRPCMessage should throw on non-object JSON`() {
416+
val exception = shouldThrow<SerializationException> {
417+
McpJson.decodeFromString<JSONRPCMessage>("[\"just a string\"]")
418+
}
419+
420+
exception.message shouldBe "Invalid response. JsonObject expected, got: [\"just a string\"]"
421+
}
422+
404423
@Test
405424
fun `should create JSONRPCRequest with string ID`() {
406425
val params = buildJsonObject {

kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ResourcesTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package io.modelcontextprotocol.kotlin.sdk.types
22

33
import io.kotest.assertions.json.shouldEqualJson
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.matchers.shouldBe
6+
import kotlinx.serialization.SerializationException
47
import kotlinx.serialization.json.buildJsonObject
58
import kotlinx.serialization.json.int
69
import kotlinx.serialization.json.jsonPrimitive
@@ -250,6 +253,15 @@ class ResourcesTest {
250253
""".trimIndent()
251254
}
252255

256+
@Test
257+
fun `ResourceContents should throw on non-object JSON`() {
258+
val exception = shouldThrow<SerializationException> {
259+
McpJson.decodeFromString<ResourceContents>("\"just a string\"")
260+
}
261+
262+
exception.message shouldBe "Invalid response. JsonObject expected, got: \"just a string\""
263+
}
264+
253265
@Test
254266
fun `should serialize ListResourcesRequest with cursor`() {
255267
val request = ListResourcesRequest(
@@ -316,6 +328,7 @@ class ResourcesTest {
316328

317329
@Test
318330
fun `should deserialize ListResourcesResult`() {
331+
// language=json
319332
val json = """
320333
{
321334
"resources": [
@@ -370,6 +383,7 @@ class ResourcesTest {
370383

371384
@Test
372385
fun `should deserialize ReadResourceResult with mixed contents`() {
386+
// language=json
373387
val json = """
374388
{
375389
"contents": [

0 commit comments

Comments
 (0)