Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
MCP Kotlin SDK — Kotlin Multiplatform implementation of the Model Context Protocol.

## Repository Layout

- `kotlin-sdk-core`: Core protocol types, transport abstractions, WebSocket implementation
- `kotlin-sdk-client`: Client transports (STDIO, SSE, StreamableHttp, WebSocket)
- `kotlin-sdk-server`: Server transports + Ktor integration (STDIO, SSE, WebSocket)
Expand All @@ -11,13 +12,15 @@ MCP Kotlin SDK — Kotlin Multiplatform implementation of the Model Context Prot
- `samples/`: Three sample projects (composite builds)

## General Guidance

- Avoid changing public API signatures. Run `./gradlew apiCheck` before every commit.
- **Explicit API mode** is strict: all public APIs must have explicit visibility modifiers and return types.
- Anything under an `internal` directory is not part of the public API and may change freely.
- Package structure follows: `io.modelcontextprotocol.kotlin.sdk.*`
- The SDK targets Kotlin 2.2+ and JVM 1.8+ as minimum.

## Building and Testing

1. Build the project:
```bash
./gradlew build
Expand All @@ -43,7 +46,17 @@ MCP Kotlin SDK — Kotlin Multiplatform implementation of the Model Context Prot
```

## Tests
- All tests for each module are located in `src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/`

- Write comprehensive tests for new features
- **Prioritize test readability**
- Avoid creating too many test methods. If multiple parameters can be tested in one scenario, go for it.
- In case of similar scenarios/use-cases, consider using parametrized tests.
But never make a parametrized test for only one use-case
- Use function `Names with backticks` for test methods in Kotlin, e.g. "fun `should return 200 OK`()"
- Avoid writing KDocs for tests, keep code self-documenting
- When running tests on a Kotlin Multiplatform project, run only JVM tests,
if not asked to run tests on another platform either.
- Common tests for each module are located in `src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/`
- Platform-specific tests go in `src/jvmTest/`, `src/jsTest/`, etc.
- Use Kotest assertions (`shouldBe`, `shouldContain`, etc.) for readable test failures.
- Use `shouldMatchJson` from Kotest for JSON validation.
Expand All @@ -53,41 +66,48 @@ MCP Kotlin SDK — Kotlin Multiplatform implementation of the Model Context Prot
## Code Conventions

### Multiplatform Patterns

- Use `expect`/`actual` pattern for platform-specific implementations in `utils.*` files.
- Test changes on JVM first, then verify platform-specific behavior if needed.
- Supported targets: JVM (1.8+), JS/Wasm, iOS, watchOS, tvOS.

### Serialization

- Use Kotlinx Serialization with explicit `@Serializable` annotations.
- Custom serializers should be registered in the companion object.
- JSON config is defined in `McpJson.kt` — use it consistently.

### Concurrency and State

- Use `kotlinx.atomicfu` for thread-safe operations across platforms.
- Prefer coroutines over callbacks where possible.
- Transport implementations extend `AbstractTransport` for consistent callback management.

### Error Handling

- Use sealed classes for representing different result states.
- Map errors to JSON-RPC error codes in protocol handlers.
- Log errors using `io.github.oshai.kotlinlogging.KotlinLogging`.

### Logging

- Use `KotlinLogging.logger {}` for structured logging.
- Never log sensitive data (credentials, tokens).

## Pull Requests

- Base all PRs on the `main` branch.
- PR title format: Brief description of the change
- Include in PR description:
- **What changed?**
- **Why? Motivation and context**
- **Breaking changes?** (if any)
- **What changed?**
- **Why? Motivation and context**
- **Breaking changes?** (if any)
- Run `./gradlew apiCheck` before submitting.
- Link PR to related issue (except for minor docs/typo fixes).
- Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/reference/coding-conventions.html).

## Review Checklist

- `./gradlew apiCheck` must pass.
- `./gradlew test` must succeed for affected modules.
- New tests added for any new feature or bug fix.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
Expand Down Expand Up @@ -40,6 +41,15 @@ private fun JsonElement.getTypeOrNull(): String? = jsonObject["type"]?.jsonPrimi
*/
private fun JsonElement.getType(): String = requireNotNull(getTypeOrNull()) { "Missing required 'type' field" }

@Throws(SerializationException::class)
private fun JsonElement.asJsonObject(): JsonObject {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make more sense to put this in the json utils file and make the function as internal

if (this !is JsonObject) {
throw SerializationException("Invalid response. JsonObject expected, got: ${this::class.simpleName}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What’s the benefit of this function for us?
If we call a jsonObject, we’ll get an error as well, but it will be an IllegalArgumentException

https://pl.kotl.in/hHslkLCW8

Copy link
Contributor Author

@kpavlov kpavlov Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a better error message. JsonElement.jsonObject's message is cryptic. I was thinking about exposing the raw response payload here, but it's not secure enough.

}
val jsonObject = this.jsonObject
return jsonObject
}

// ============================================================================
// Method Serializer
// ============================================================================
Expand Down Expand Up @@ -131,7 +141,7 @@ internal object MediaContentPolymorphicSerializer :
internal object ResourceContentsPolymorphicSerializer :
JsonContentPolymorphicSerializer<ResourceContents>(ResourceContents::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ResourceContents> {
val jsonObject = element.jsonObject
val jsonObject = element.asJsonObject()
return when {
"text" in jsonObject -> TextResourceContents.serializer()
"blob" in jsonObject -> BlobResourceContents.serializer()
Expand Down Expand Up @@ -284,7 +294,7 @@ internal object ServerNotificationPolymorphicSerializer :
* Returns EmptyResult serializer if the JSON object is empty or contains only metadata.
*/
private fun selectEmptyResult(element: JsonElement): DeserializationStrategy<EmptyResult>? {
val jsonObject = element.jsonObject
val jsonObject = element.asJsonObject()
return when {
jsonObject.isEmpty() || (jsonObject.size == 1 && "_meta" in jsonObject) -> EmptyResult.serializer()
else -> null
Expand All @@ -296,7 +306,7 @@ private fun selectEmptyResult(element: JsonElement): DeserializationStrategy<Emp
* Returns null if the structure doesn't match any known client result type.
*/
private fun selectClientResultDeserializer(element: JsonElement): DeserializationStrategy<ClientResult>? {
val jsonObject = element.jsonObject
val jsonObject = element.asJsonObject()
return when {
"model" in jsonObject && "role" in jsonObject -> CreateMessageResult.serializer()
"roots" in jsonObject -> ListRootsResult.serializer()
Expand All @@ -310,7 +320,7 @@ private fun selectClientResultDeserializer(element: JsonElement): Deserializatio
* Returns null if the structure doesn't match any known server result type.
*/
private fun selectServerResultDeserializer(element: JsonElement): DeserializationStrategy<ServerResult>? {
val jsonObject = element.jsonObject
val jsonObject = element.asJsonObject()
return when {
"protocolVersion" in jsonObject && "capabilities" in jsonObject -> InitializeResult.serializer()
"completion" in jsonObject -> CompleteResult.serializer()
Expand Down Expand Up @@ -378,7 +388,7 @@ internal object ServerResultPolymorphicSerializer :
internal object JSONRPCMessagePolymorphicSerializer :
JsonContentPolymorphicSerializer<JSONRPCMessage>(JSONRPCMessage::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<JSONRPCMessage> {
val jsonObject = element.jsonObject
val jsonObject = element.asJsonObject()
return when {
"error" in jsonObject -> JSONRPCError.serializer()
"result" in jsonObject -> JSONRPCResponse.serializer()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package io.modelcontextprotocol.kotlin.sdk.types

import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeSameInstanceAs
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
Expand Down Expand Up @@ -43,6 +45,7 @@ class JsonRpcTest {

@Test
fun `should convert JSONRPCRequest to Request`() {
// language=json
val jsonRpc = McpJson.decodeFromString<JSONRPCRequest>(
"""
{
Expand Down Expand Up @@ -101,6 +104,7 @@ class JsonRpcTest {

@Test
fun `should convert JSONRPCNotification to Notification`() {
// language=json
val jsonRpc = McpJson.decodeFromString<JSONRPCNotification>(
"""
{
Expand Down Expand Up @@ -164,6 +168,7 @@ class JsonRpcTest {

@Test
fun `should deserialize JSONRPCRequest with numeric id`() {
// language=json
val json = """
{
"id": 42,
Expand Down Expand Up @@ -212,6 +217,7 @@ class JsonRpcTest {

@Test
fun `should deserialize JSONRPCNotification`() {
// language=json
val json = """
{
"method": "notifications/progress",
Expand Down Expand Up @@ -257,6 +263,7 @@ class JsonRpcTest {

@Test
fun `should deserialize JSONRPCResponse with EmptyResult`() {
// language=json
val json = """
{
"id": 7,
Expand Down Expand Up @@ -308,6 +315,7 @@ class JsonRpcTest {

@Test
fun `should deserialize JSONRPCError`() {
// language=json
val json = """
{
"id": "req-404",
Expand Down Expand Up @@ -336,6 +344,7 @@ class JsonRpcTest {

@Test
fun `should decode JSONRPCMessage as request`() {
// language=json
val json = """
{
"id": "msg-1",
Expand All @@ -359,6 +368,7 @@ class JsonRpcTest {

@Test
fun `should decode JSONRPCMessage as error response`() {
// language=json
val json = """
{
"id": 123,
Expand Down Expand Up @@ -401,6 +411,15 @@ class JsonRpcTest {
""".trimIndent()
}

@Test
fun `JSONRPCMessage should throw on non-object JSON`() {
val exception = shouldThrow<SerializationException> {
McpJson.decodeFromString<JSONRPCMessage>("[\"just a string\"]")
}

exception.message shouldBe "Invalid response. JsonObject expected, got: JsonArray"
}

@Test
fun `should create JSONRPCRequest with string ID`() {
val params = buildJsonObject {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.modelcontextprotocol.kotlin.sdk.types

import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
Expand Down Expand Up @@ -250,6 +253,15 @@ class ResourcesTest {
""".trimIndent()
}

@Test
fun `ResourceContents should throw on non-object JSON`() {
val exception = shouldThrow<SerializationException> {
McpJson.decodeFromString<ResourceContents>("\"just a string\"")
}

exception.message shouldBe "Invalid response. JsonObject expected, got: JsonLiteral"
}

@Test
fun `should serialize ListResourcesRequest with cursor`() {
val request = ListResourcesRequest(
Expand Down Expand Up @@ -316,6 +328,7 @@ class ResourcesTest {

@Test
fun `should deserialize ListResourcesResult`() {
// language=json
val json = """
{
"resources": [
Expand Down Expand Up @@ -370,6 +383,7 @@ class ResourcesTest {

@Test
fun `should deserialize ReadResourceResult with mixed contents`() {
// language=json
val json = """
{
"contents": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.timeout.default=2m
8 changes: 8 additions & 0 deletions kotlin-sdk-core/src/jvmTest/resources/simplelogger.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Level of logging for the ROOT logger: ERROR, WARN, INFO, DEBUG, TRACE (default is INFO)
org.slf4j.simpleLogger.defaultLogLevel=INFO
org.slf4j.simpleLogger.showThreadName=true
org.slf4j.simpleLogger.showDateTime=false

# Log level for specific packages or classes
org.slf4j.simpleLogger.log.io.ktor.server=INFO
org.slf4j.simpleLogger.log.io.modelcontextprotocol=DEBUG
Loading