From b2878ed406afaa4b6355ee587fb0b69f1c968ba9 Mon Sep 17 00:00:00 2001 From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:21:43 +0200 Subject: [PATCH] test: add DSL builder tests for Request, Capabilities, and Content types (#529) # Add DSL builder tests for Request, Capabilities, and Content types - Introduced unit tests for `RequestDslTest`, `CapabilitiesDslTest`, and `ContentDslTest` to validate DSL builder functionality. - Covered edge cases, nested properties, custom fields, and overwrite behavior for requests, capabilities, and content creation. - Update AGENTS.md ## Motivation and Context for confidence ## Breaking Changes No ## Types of changes - [x] Tests ## 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 - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed --- AGENTS.md | 43 +- .../sdk/types/dsl/CapabilitiesDslTest.kt | 135 ++++++ .../kotlin/sdk/types/dsl/ContentDslTest.kt | 385 ++++++++++++++++++ .../kotlin/sdk/types/dsl/RequestDslTest.kt | 256 ++++++++++++ 4 files changed, 806 insertions(+), 13 deletions(-) create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt create mode 100644 kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt diff --git a/AGENTS.md b/AGENTS.md index bf2b318..7b723e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ This document is for autonomous agents and AI copilots contributing code to this Follow these rules to keep changes safe, comprehensible, and easy to maintain. ## Repository Layout + - `kotlin-sdk-core`: Core protocol types, transport abstractions, serialization utilities - `kotlin-sdk-client`: Client transports (STDIO, SSE, StreamableHttp, WebSocket) - `kotlin-sdk-server`: Server transports + Ktor integration (STDIO, SSE, WebSocket) @@ -30,19 +31,21 @@ Follow these rules to keep changes safe, comprehensible, and easy to maintain. - Interface Segregation: keep abstractions small and focused. - Dependency Inversion: depend on abstractions, not concretions. 4. **Make the minimal change** that satisfies the tests and the issue. - Stay focused on a single concern and avoid "drive-by" changes. + Stay focused on a single concern and avoid "drive-by" changes. 5. **Never commit changes!** It's human who should verify and commit to git. 6. **Keep the build green.** Do not merge changes that break existing tests. 7. **Prefer clarity** over micro-optimizations and cleverness. 8. **Ask when uncertain.** If requirements are ambiguous, request clarification with a concise question. 9. **Write code with the quality of a Kotlin Champion.** -10. **Prefer using MCP servers** like `jetbrains` and `intellij-index` for code navigation, analysis, and refactoring. +10. **Use MCP servers** like `jetbrains` and `intellij-index` to explore the codebase, for code navigation, analysis, + refactoring, and running tests. 11. **Use the Bash tool for terminal commands** instead of MCP terminal execution features. 12. **Suggest updates** to AGENTS.md/CLAUDE.md with best practices and guidelines. ## Code Style ### Kotlin + - 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. @@ -51,7 +54,8 @@ Follow these rules to keep changes safe, comprehensible, and easy to maintain. - Use the provided `.editorconfig` for consistent formatting - Use Kotlin typesafe DSL builders where possible; prioritize fluent builder style over standard builder methods - Prefer DSL builder style (method with lambda blocks) over constructors, if possible -- Use `val` for immutable properties and `var` for mutable; consider `lateinit var` instead of nullable types when appropriate +- Use `val` for immutable properties and `var` for mutable; consider `lateinit var` instead of nullable types when + appropriate - Use multi-dollar interpolation prefix for strings where applicable - Use fully qualified imports instead of star imports - Ensure backward compatibility when making changes @@ -59,6 +63,7 @@ Follow these rules to keep changes safe, comprehensible, and easy to maintain. ## Testing Guidance ### Test Organization + - **Location**: `src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/` - **Platform-specific**: `src/jvmTest/`, `src/jsTest/`, etc. - **Naming**: Use function `Names with backticks`, e.g., `fun \`should return 200 OK\`()` @@ -66,21 +71,24 @@ Follow these rules to keep changes safe, comprehensible, and easy to maintain. - **Documentation**: Keep tests self-documenting; don't add KDocs to tests. ### Test Principles -- Write comprehensive tests for new features. Always add tests for new features or bug fixes, even if not explicitly requested. + +- Write comprehensive tests for new features. Always add tests for new features or bug fixes, even if not explicitly + requested. - **Prioritize test readability** - Avoid creating too many test methods; use parametrized tests when testing multiple similar scenarios - When running tests on Kotlin Multiplatform projects, run JVM tests only unless asked for other platforms ### Test Framework Stack + - **kotlin-test**: Core test framework - **Kotest assertions**: Use infix style (`shouldBe`, `shouldContain`) instead of `assertEquals`. - - Use `shouldMatchJson` from kotest-assertions-json for JSON validation. - - For nullable objects with nested properties, prefer `shouldNotBeNull { ... }` blocks: - ```kotlin - content.annotations shouldNotBeNull { - priority shouldBe 1.0 - } - ``` + - Use `shouldMatchJson` from kotest-assertions-json for JSON validation. + - For nullable objects with nested properties, prefer `shouldNotBeNull { ... }` blocks: + ```kotlin + content.annotations shouldNotBeNull { + priority shouldBe 1.0 + } + ``` - **mockk**: Mocking library - **Ktor MockEngine**: For HTTP client mocking (`io.ktor:ktor-client-mock`) - **Java tests**: Use JUnit5, Mockito, AssertJ core @@ -129,6 +137,7 @@ Follow these rules to keep changes safe, comprehensible, and easy to maintain. ``` #### Avoiding `this.` in Lambda Blocks + ```kotlin // Avoid explicit `this.` unless necessary for disambiguation prop.shouldNotBeNull { @@ -138,6 +147,7 @@ prop.shouldNotBeNull { ``` ### 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 (11+), JS/Wasm, iOS, watchOS, tvOS. @@ -145,22 +155,26 @@ prop.shouldNotBeNull { ## Coding Guidance ### Module Boundaries + - Respect separation of concerns between modules - Keep abstractions framework-agnostic - Avoid leaking implementation details across module boundaries ### Serialization + - Use Kotlinx Serialization with explicit `@Serializable` annotations - JSON config is defined in `jsonUtils.kt` as `McpJson` — use it consistently - Register custom serializers in companion objects ### Error Handling + - Use sealed classes for result states - Map errors to JSON-RPC error codes in protocol handlers - Log errors using `io.github.oshai.kotlinlogging.KotlinLogging` - Never log sensitive data (credentials, tokens) ### Code Quality + - Handle nullability and references as established patterns - Keep changes small and reversible; prefer adding functions over editing many call sites - Add focused comments **only** where intent is non-obvious @@ -228,7 +242,7 @@ prop.shouldNotBeNull { ## Definition of done -- Tests for affected functionality exist +- Tests for affected functionality exist - All tests pass across all targets - Changes are focused on a single concern. - Code respects module boundaries @@ -238,6 +252,7 @@ prop.shouldNotBeNull { ## Documentation ### General Guidelines + - **Never make up documentation/facts** - Update README files when adding new features - Document API changes in the appropriate module @@ -246,6 +261,7 @@ prop.shouldNotBeNull { - Write tutorials in Markdown format in README.md ### KDoc Guidelines + - Properly document interfaces and abstract classes in production code - Avoid KDocs on override functions to reduce verbosity - Update KDocs when API changes @@ -258,11 +274,13 @@ prop.shouldNotBeNull { Each module must have a `Module.md` file at the module root for Dokka-generated API documentation. **Format Requirements** (per [Dokka specification](https://kotlinlang.org/docs/dokka-module-and-package-docs.html)): + - Must start with `# Module module-name` (level 1 header) - Package documentation uses `# Package package.name` (level 1 header) - All other content uses level 2+ headers (`##`, `###`, etc.) **Content Guidelines**: + - **Purpose**: Provide module-level overview in generated API docs - **Detail level**: API documentation style (concise, focused on classes/interfaces) - **Module section**: @@ -305,7 +323,6 @@ Each module must have a `Module.md` file at the module root for Dokka-generated Another package description. - ## When to Ask for Help - Requirements conflict with existing tests or documentation diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt new file mode 100644 index 0000000..e4a949f --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/CapabilitiesDslTest.kt @@ -0,0 +1,135 @@ +package io.modelcontextprotocol.kotlin.sdk.types.dsl + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test + +/** + * Tests for ClientCapabilities DSL builder. + * + * Verifies that capabilities can be constructed via DSL within InitializeRequest, + * covering minimal (empty), full (all fields), and variant patterns. + */ +@OptIn(ExperimentalMcpApi::class) +class CapabilitiesDslTest { + @Test + fun `capabilities should build minimal empty capabilities`() { + val request = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { } + info("Test", "1.0") + } + + request.params.capabilities.shouldNotBeNull { + sampling.shouldBeNull() + roots.shouldBeNull() + elicitation.shouldBeNull() + experimental.shouldBeNull() + } + } + + @Test + fun `capabilities should build full with all fields and nested properties`() { + val request = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { + sampling { + put("temperature", 0.7) + put("maxTokens", 1000) + put("topP", 0.95) + } + roots(listChanged = true) + elicitation { + put("mode", "interactive") + put("timeout", 30) + put("retries", 3) + } + experimental { + put("customFeature", true) + put("version", "1.0") + put("beta", false) + put("maxConcurrency", 10) + } + } + info("Test", "1.0") + } + + request.params.capabilities.shouldNotBeNull { + sampling shouldNotBeNull { + get("temperature")?.jsonPrimitive?.double shouldBe 0.7 + get("maxTokens")?.jsonPrimitive?.content shouldBe "1000" + get("topP")?.jsonPrimitive?.double shouldBe 0.95 + } + roots shouldNotBeNull { + listChanged shouldBe true + } + elicitation shouldNotBeNull { + get("mode")?.jsonPrimitive?.content shouldBe "interactive" + get("timeout")?.jsonPrimitive?.content shouldBe "30" + get("retries")?.jsonPrimitive?.content shouldBe "3" + } + experimental shouldNotBeNull { + get("customFeature")?.jsonPrimitive?.content shouldBe "true" + get("version")?.jsonPrimitive?.content shouldBe "1.0" + get("beta")?.jsonPrimitive?.content shouldBe "false" + get("maxConcurrency")?.jsonPrimitive?.content shouldBe "10" + } + } + } + + @Test + fun `capabilities should support roots variants`() { + // Test listChanged = true + val requestTrue = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { roots(listChanged = true) } + info("Test", "1.0") + } + requestTrue.params.capabilities.roots?.listChanged shouldBe true + + // Test listChanged = false + val requestFalse = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { roots(listChanged = false) } + info("Test", "1.0") + } + requestFalse.params.capabilities.roots?.listChanged shouldBe false + + // Test listChanged = null (not provided) + val requestNull = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { roots() } + info("Test", "1.0") + } + requestNull.params.capabilities.roots?.listChanged.shouldBeNull() + } + + @Test + fun `capabilities should overwrite when same field set multiple times`() { + val request = buildInitializeRequest { + protocolVersion = "2024-11-05" + capabilities { + sampling { put("temperature", 0.5) } + sampling { put("temperature", 0.9) } // Should overwrite + experimental { put("feature", "v1") } + experimental { put("feature", "v2") } // Should overwrite + } + info("Test", "1.0") + } + + request.params.capabilities.shouldNotBeNull { + sampling shouldNotBeNull { + get("temperature")?.jsonPrimitive?.double shouldBe 0.9 + } + experimental shouldNotBeNull { + get("feature")?.jsonPrimitive?.content shouldBe "v2" + } + } + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt new file mode 100644 index 0000000..180a308 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/ContentDslTest.kt @@ -0,0 +1,385 @@ +package io.modelcontextprotocol.kotlin.sdk.types.dsl + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.types.Annotations +import io.modelcontextprotocol.kotlin.sdk.types.AudioContent +import io.modelcontextprotocol.kotlin.sdk.types.ImageContent +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.assistantAudio +import io.modelcontextprotocol.kotlin.sdk.types.assistantImage +import io.modelcontextprotocol.kotlin.sdk.types.buildCreateMessageRequest +import io.modelcontextprotocol.kotlin.sdk.types.userText +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test + +/** + * Tests for MediaContent DSL builders (TextContent, ImageContent, AudioContent). + * + * Verifies content can be constructed via DSL within sampling messages, + * covering minimal (required fields), full (all fields), validation, and edge cases. + */ +@OptIn(ExperimentalMcpApi::class) +class ContentDslTest { + + // ======================================================================== + // TextContent Tests + // ======================================================================== + + @Test + fun `userText should build minimal with text only`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "Hello, world!" + } + } + } + + (request.params.messages[0].content as TextContent).shouldNotBeNull { + text shouldBe "Hello, world!" + annotations.shouldBeNull() + meta.shouldBeNull() + } + } + + @Test + fun `userText should build full with all fields and nested meta`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "Analyze the quarterly sales report for Q4 2025" + annotations( + audience = listOf(Role.User, Role.Assistant), + priority = 0.85, + lastModified = "2025-02-17T10:30:00Z", + ) + meta { + put("source", "dashboard") + put("userId", "user-123") + put("sessionId", 42) + put("analyzed", true) + } + } + } + } + + (request.params.messages[0].content as TextContent).shouldNotBeNull { + text shouldBe "Analyze the quarterly sales report for Q4 2025" + annotations shouldNotBeNull { + audience shouldBe listOf(Role.User, Role.Assistant) + priority shouldBe 0.85 + lastModified shouldBe "2025-02-17T10:30:00Z" + } + meta shouldNotBeNull { + get("source")?.jsonPrimitive?.content shouldBe "dashboard" + get("userId")?.jsonPrimitive?.content shouldBe "user-123" + get("sessionId")?.jsonPrimitive?.content shouldBe "42" + get("analyzed")?.jsonPrimitive?.content shouldBe "true" + } + } + } + + @Test + fun `userText should handle unicode and special characters`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "Hello 🌍! Ça va? Москва 北京 مرحبا" + } + } + } + + (request.params.messages[0].content as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا" + } + + @Test + fun `userText should handle empty string`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "" + } + } + } + + (request.params.messages[0].content as TextContent).text shouldBe "" + } + + @Test + fun `userText should throw if text is missing`() { + shouldThrow { + buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { } + } + } + } + } + + // ======================================================================== + // ImageContent Tests + // ======================================================================== + + @Test + fun `assistantImage should build minimal with data and mimeType`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantImage { + data = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + mimeType = "image/png" + } + } + } + + (request.params.messages[0].content as ImageContent).shouldNotBeNull { + data shouldBe + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + mimeType shouldBe "image/png" + annotations.shouldBeNull() + meta.shouldBeNull() + } + } + + @Test + fun `assistantImage should build full with all fields and image metadata`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantImage { + data = "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFklEQVR42mNk+M9QzzCCgAEYjRZnRwEA" + mimeType = "image/jpeg" + annotations( + audience = listOf(Role.Assistant), + priority = 0.92, + lastModified = "2025-02-17T11:45:30Z", + ) + meta { + put("width", 1920) + put("height", 1080) + put("format", "jpeg") + put("compression", "lossy") + put("quality", 85) + } + } + } + } + + (request.params.messages[0].content as ImageContent).shouldNotBeNull { + mimeType shouldBe "image/jpeg" + annotations shouldNotBeNull { + audience shouldBe listOf(Role.Assistant) + priority shouldBe 0.92 + lastModified shouldBe "2025-02-17T11:45:30Z" + } + meta shouldNotBeNull { + get("width")?.jsonPrimitive?.content shouldBe "1920" + get("height")?.jsonPrimitive?.content shouldBe "1080" + get("format")?.jsonPrimitive?.content shouldBe "jpeg" + get("compression")?.jsonPrimitive?.content shouldBe "lossy" + get("quality")?.jsonPrimitive?.content shouldBe "85" + } + } + } + + @Test + fun `assistantImage should accept various MIME types`() { + val mimeTypes = listOf("image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml") + + mimeTypes.forEach { mime -> + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantImage { + data = "base64data" + mimeType = mime + } + } + } + (request.params.messages[0].content as ImageContent).mimeType shouldBe mime + } + } + + @Test + fun `assistantImage should throw if data is missing`() { + shouldThrow { + buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantImage { + mimeType = "image/png" + } + } + } + } + } + + @Test + fun `assistantImage should throw if mimeType is missing`() { + shouldThrow { + buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantImage { + data = "base64data" + } + } + } + } + } + + // ======================================================================== + // AudioContent Tests + // ======================================================================== + + @Test + fun `assistantAudio should build minimal with data and mimeType`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantAudio { + data = "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" + mimeType = "audio/wav" + } + } + } + + (request.params.messages[0].content as AudioContent).shouldNotBeNull { + data shouldBe "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==" + mimeType shouldBe "audio/wav" + annotations.shouldBeNull() + meta.shouldBeNull() + } + } + + @Test + fun `assistantAudio should build full with all fields and audio metadata`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantAudio { + data = "//uQxAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAADIADMzMzMzMzMzMzMzMzMzMzMzMzMzMzM" + mimeType = "audio/mpeg" + annotations( + audience = listOf(Role.User), + priority = 0.75, + lastModified = "2025-02-17T12:00:00Z", + ) + meta { + put("duration", 180) + put("bitrate", 128) + put("codec", "mp3") + put("sampleRate", 44100) + put("channels", 2) + } + } + } + } + + (request.params.messages[0].content as AudioContent).shouldNotBeNull { + mimeType shouldBe "audio/mpeg" + annotations shouldNotBeNull { + audience shouldBe listOf(Role.User) + priority shouldBe 0.75 + lastModified shouldBe "2025-02-17T12:00:00Z" + } + meta shouldNotBeNull { + get("duration")?.jsonPrimitive?.content shouldBe "180" + get("bitrate")?.jsonPrimitive?.content shouldBe "128" + get("codec")?.jsonPrimitive?.content shouldBe "mp3" + get("sampleRate")?.jsonPrimitive?.content shouldBe "44100" + get("channels")?.jsonPrimitive?.content shouldBe "2" + } + } + } + + @Test + fun `assistantAudio should throw if data is missing`() { + shouldThrow { + buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantAudio { + mimeType = "audio/wav" + } + } + } + } + } + + @Test + fun `assistantAudio should throw if mimeType is missing`() { + shouldThrow { + buildCreateMessageRequest { + maxTokens = 100 + messages { + assistantAudio { + data = "base64audio" + } + } + } + } + } + + // ======================================================================== + // Annotations Edge Cases + // ======================================================================== + + @Test + fun `annotations should support boundary priority values`() { + // Priority = 0.0 (minimum) + val requestMin = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "Min priority" + annotations(priority = 0.0) + } + } + } + (requestMin.params.messages[0].content as TextContent).annotations?.priority shouldBe 0.0 + + // Priority = 1.0 (maximum) + val requestMax = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "Max priority" + annotations(priority = 1.0) + } + } + } + (requestMax.params.messages[0].content as TextContent).annotations?.priority shouldBe 1.0 + } + + @Test + fun `annotations should support direct object construction`() { + val request = buildCreateMessageRequest { + maxTokens = 100 + messages { + userText { + text = "With annotations object" + annotations(Annotations(priority = 0.5, audience = listOf(Role.User))) + } + } + } + + (request.params.messages[0].content as TextContent).annotations shouldNotBeNull { + priority shouldBe 0.5 + audience shouldBe listOf(Role.User) + lastModified.shouldBeNull() + } + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt new file mode 100644 index 0000000..ad9c719 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/dsl/RequestDslTest.kt @@ -0,0 +1,256 @@ +package io.modelcontextprotocol.kotlin.sdk.types.dsl + +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi +import io.modelcontextprotocol.kotlin.sdk.types.Method +import io.modelcontextprotocol.kotlin.sdk.types.RequestId +import io.modelcontextprotocol.kotlin.sdk.types.buildListToolsRequest +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.add +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlin.test.Test + +/** + * Tests for RequestMeta and PaginatedRequest DSL builders. + * + * Verifies meta and cursor can be constructed via DSL within paginated requests, + * covering minimal (no params), full (all field types), variants, and edge cases. + */ +@OptIn(ExperimentalMcpApi::class) +class RequestDslTest { + @Test + fun `request should build minimal without any params`() { + val request = buildListToolsRequest { } + + request.params.shouldBeNull() + request.method shouldBe Method.Defined.ToolsList + } + + @Test + fun `request should build full with cursor and meta containing all field types`() { + val request = buildListToolsRequest { + cursor = "next-page-eyJvZmZzZXQiOjEwMH0" + meta { + // ProgressToken + progressToken("progress-abc123") + + // String fields + put("requestId", "req-456") + put("source", "api-gateway") + + // Number fields + put("priority", 5) + put("retryCount", 3L) + put("timeout", 30000) + + // Boolean fields + put("urgent", true) + put("cached", false) + + // Null field + put("optional", null) + + // Nested JsonObject + putJsonObject("context") { + put("userId", "user-789") + put("sessionId", "sess-abc") + put("authenticated", true) + put("permissions", 7) + } + + // JsonArray + putJsonArray("tags") { + add("production") + add("critical") + add("monitored") + } + } + } + + request.params shouldNotBeNull { + cursor shouldBe "next-page-eyJvZmZzZXQiOjEwMH0" + meta shouldNotBeNull { + // ProgressToken + progressToken shouldNotBeNull { + shouldBeInstanceOf() + value shouldBe "progress-abc123" + } + + // String fields + get("requestId")?.jsonPrimitive?.content shouldBe "req-456" + get("source")?.jsonPrimitive?.content shouldBe "api-gateway" + + // Number fields + get("priority")?.jsonPrimitive?.int shouldBe 5 + get("retryCount")?.jsonPrimitive?.long shouldBe 3L + get("timeout")?.jsonPrimitive?.int shouldBe 30000 + + // Boolean fields + get("urgent")?.jsonPrimitive?.boolean shouldBe true + get("cached")?.jsonPrimitive?.boolean shouldBe false + + // Null field + get("optional") shouldBe JsonNull + + // Nested JsonObject + get("context") shouldNotBeNull { + jsonObject["userId"]?.jsonPrimitive?.content shouldBe "user-789" + jsonObject["sessionId"]?.jsonPrimitive?.content shouldBe "sess-abc" + jsonObject["authenticated"]?.jsonPrimitive?.boolean shouldBe true + jsonObject["permissions"]?.jsonPrimitive?.int shouldBe 7 + } + + // JsonArray + get("tags") shouldNotBeNull { + jsonArray.map { it.jsonPrimitive.content } shouldContainAll listOf( + "production", + "critical", + "monitored", + ) + } + } + } + } + + @Test + fun `meta progressToken should support String Int and Long types`() { + // String progressToken + val stringRequest = buildListToolsRequest { + meta { progressToken("token-abc") } + } + stringRequest.params?.meta?.progressToken shouldNotBeNull { + shouldBeInstanceOf() + value shouldBe "token-abc" + } + + // Int progressToken + val intRequest = buildListToolsRequest { + meta { progressToken(42) } + } + intRequest.params?.meta?.progressToken shouldNotBeNull { + shouldBeInstanceOf() + value shouldBe 42L + } + + // Long progressToken + val longRequest = buildListToolsRequest { + meta { progressToken(999L) } + } + longRequest.params?.meta?.progressToken shouldNotBeNull { + shouldBeInstanceOf() + value shouldBe 999L + } + } + + @Test + fun `meta should support custom fields without progressToken`() { + val request = buildListToolsRequest { + meta { + put("requestId", "req-123") + put("source", "cli") + // No progressToken + } + } + + request.params shouldNotBeNull { + meta shouldNotBeNull { + progressToken.shouldBeNull() + get("requestId")?.jsonPrimitive?.content shouldBe "req-123" + get("source")?.jsonPrimitive?.content shouldBe "cli" + } + } + } + + @Test + fun `cursor should work without meta`() { + val request = buildListToolsRequest { + cursor = "page-2-cursor" + } + + request.params shouldNotBeNull { + cursor shouldBe "page-2-cursor" + meta.shouldBeNull() + } + } + + @Test + fun `cursor should handle empty string`() { + val request = buildListToolsRequest { + cursor = "" + } + + request.params shouldNotBeNull { + cursor shouldBe "" + } + } + + @Test + fun `meta should handle special characters in keys`() { + val request = buildListToolsRequest { + meta { + put("key-with-dashes", "value1") + put("key.with.dots", "value2") + put("key_with_underscores", "value3") + put("keyWithCamelCase", "value4") + } + } + + request.params?.meta shouldNotBeNull { + get("key-with-dashes")?.jsonPrimitive?.content shouldBe "value1" + get("key.with.dots")?.jsonPrimitive?.content shouldBe "value2" + get("key_with_underscores")?.jsonPrimitive?.content shouldBe "value3" + get("keyWithCamelCase")?.jsonPrimitive?.content shouldBe "value4" + } + } + + @Test + fun `meta should overwrite when progressToken set multiple times`() { + val request = buildListToolsRequest { + meta { + progressToken("first") + progressToken(123) // Different type + progressToken("second") // Back to string, should be final + } + } + + request.params?.meta?.progressToken shouldNotBeNull { + shouldBeInstanceOf() + value shouldBe "second" + } + } + + @Test + fun `meta should overwrite when custom field set multiple times`() { + val request = buildListToolsRequest { + meta { + put("key", "first") + put("key", 123) // Different type + put("key", "third") // Back to string, should be final + } + } + + request.params?.meta shouldNotBeNull { + get("key")?.jsonPrimitive?.content shouldBe "third" + } + } + + @Test + fun `meta should handle very long cursor strings`() { + val longCursor = "x".repeat(1000) + val request = buildListToolsRequest { + cursor = longCursor + } + + request.params?.cursor shouldBe longCursor + } +}