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
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [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
This commit is contained in:
Konstantin Pavlov 2026-02-17 14:21:43 +02:00 • committed by GitHub
commit b2878ed406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 806 additions and 13 deletions

View file

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

View file

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

View file

@ -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<IllegalArgumentException> {
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<IllegalArgumentException> {
buildCreateMessageRequest {
maxTokens = 100
messages {
assistantImage {
mimeType = "image/png"
}
}
}
}
}
@Test
fun `assistantImage should throw if mimeType is missing`() {
shouldThrow<IllegalArgumentException> {
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<IllegalArgumentException> {
buildCreateMessageRequest {
maxTokens = 100
messages {
assistantAudio {
mimeType = "audio/wav"
}
}
}
}
}
@Test
fun `assistantAudio should throw if mimeType is missing`() {
shouldThrow<IllegalArgumentException> {
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()
}
}
}

View file

@ -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<RequestId.StringId>()
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<RequestId.StringId>()
value shouldBe "token-abc"
}
// Int progressToken
val intRequest = buildListToolsRequest {
meta { progressToken(42) }
}
intRequest.params?.meta?.progressToken shouldNotBeNull {
shouldBeInstanceOf<RequestId.NumberId>()
value shouldBe 42L
}
// Long progressToken
val longRequest = buildListToolsRequest {
meta { progressToken(999L) }
}
longRequest.params?.meta?.progressToken shouldNotBeNull {
shouldBeInstanceOf<RequestId.NumberId>()
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<RequestId.StringId>()
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
}
}