feat!: SEP-1577 sampling with tools (#718)

Implements SEP-1577 sampling With tools

closes #409 

## How Has This Been Tested?
unit + integration 

## Breaking Changes
- binary changes
- MediaContent -> SamplingMessageContent
- 

## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## 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
This commit is contained in:
Pavel Gorgulov 2026-04-28 17:51:48 +02:00 • committed by GitHub
commit f434fcaff1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1553 additions and 135 deletions

View file

@ -556,16 +556,31 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
block: suspend (T, RequestHandlerExtra) -> RequestResult?,
) {
assertRequestHandlerCapability(method)
val wrapped = wrapRequestHandler(method, block)
_requestHandlers.update { current ->
current.put(method.value) { jSONRPCRequest, extraHandler ->
val request = jSONRPCRequest.fromJSON()
val response = block(request as T, extraHandler)
val response = wrapped(request as T, extraHandler)
response
}
}
}
/**
* Subclass hook to wrap an incoming-request handler before it is registered.
*
* Called once by [setRequestHandler] during registration. Subclasses may return a
* new function that, when invoked, performs additional checks (capability gates,
* schema validation, etc.) before delegating to [block]. The default implementation
* is the identity.
*/
@Suppress("UNUSED_PARAMETER")
protected open fun <T : Request> wrapRequestHandler(
method: Method,
block: suspend (T, RequestHandlerExtra) -> RequestResult?,
): suspend (T, RequestHandlerExtra) -> RequestResult? = block
/**
* Removes the request handler for the given method.
*/

View file

@ -21,7 +21,7 @@ import kotlinx.serialization.json.buildJsonObject
* val request = buildInitializeRequest {
* protocolVersion = "1.0"
* capabilities {
* sampling(ClientCapabilities.sampling)
* sampling(ClientCapabilities.Sampling())
* roots(listChanged = true)
* experimental {
* put("customFeature", JsonPrimitive(true))
@ -36,46 +36,32 @@ import kotlinx.serialization.json.buildJsonObject
*/
@McpDsl
public class ClientCapabilitiesBuilder @PublishedApi internal constructor() {
private var sampling: JsonObject? = null
private var sampling: ClientCapabilities.Sampling? = null
private var roots: ClientCapabilities.Roots? = null
private var elicitation: JsonObject? = null
private var extensions: Map<String, JsonObject>? = null
private var experimental: JsonObject? = null
/**
* Indicates that the client supports sampling from an LLM.
* Sampling capability configuration. See [ClientCapabilities.Sampling].
*
* Use [ClientCapabilities.sampling] for default empty configuration.
* Pass `ClientCapabilities.Sampling()` to enable base sampling with no sub-capabilities.
* Construct `ClientCapabilities.Sampling(tools = EmptyJsonObject, context = EmptyJsonObject)`
* directly to enable SEP-1577 sub-capabilities.
*
* Example:
* ```kotlin
* capabilities {
* sampling(ClientCapabilities.sampling)
* sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject))
* }
* ```
*
* @param value The sampling capability configuration
*/
public fun sampling(value: JsonObject) {
public fun sampling(value: ClientCapabilities.Sampling) {
this.sampling = value
}
/**
* Indicates that the client supports sampling from an LLM with custom configuration.
*
* Example:
* ```kotlin
* capabilities {
* sampling {
* put("temperature", JsonPrimitive(0.7))
* }
* }
* ```
*
* @param block Lambda for building the sampling configuration
*/
public fun sampling(block: JsonObjectBuilder.() -> Unit): Unit = sampling(buildJsonObject(block))
/**
* Indicates that the client supports listing roots.
*

View file

@ -41,6 +41,7 @@ public data class Implementation(
* supports that capability.
*
* @property sampling Present if the client supports sampling from an LLM.
* Use [ClientCapabilities.Sampling] to configure SEP-1577 sub-capabilities (tools, context).
* @property roots Present if the client supports listing roots.
* @property elicitation Present if the client supports elicitation from the server.
* @property experimental Experimental, non-standard capabilities that the client supports.
@ -51,7 +52,7 @@ public data class Implementation(
*/
@Serializable
public data class ClientCapabilities(
public val sampling: JsonObject? = null,
public val sampling: Sampling? = null,
public val roots: Roots? = null,
public val elicitation: JsonObject? = null,
public val experimental: JsonObject? = null,
@ -59,11 +60,51 @@ public data class ClientCapabilities(
) {
/**
* @property sampling convenience value to enable the sampling capability
* Source-compatibility constructor retaining the pre-SEP-1577 `sampling: JsonObject?`
* shape. Any non-null [sampling] is converted to an empty [Sampling] (sub-capabilities
* cannot be recovered from the old opaque `JsonObject`).
*/
@Deprecated(
"ClientCapabilities.sampling is now typed. Pass a ClientCapabilities.Sampling? " +
"instead of JsonObject?.",
ReplaceWith(
"ClientCapabilities(sampling?.let { ClientCapabilities.Sampling() }, " +
"roots, elicitation, experimental, extensions)",
),
)
public constructor(
sampling: JsonObject?,
roots: Roots? = null,
elicitation: JsonObject? = null,
experimental: JsonObject? = null,
extensions: Map<String, JsonObject>? = null,
) : this(
sampling = sampling?.let { Sampling() },
roots = roots,
elicitation = elicitation,
experimental = experimental,
extensions = extensions,
)
/**
* sub-capabilities for sampling.
*
* @property context Present if the client supports context inclusion via
* [CreateMessageRequestParams.includeContext] with values other than [IncludeContext.None].
* Servers SHOULD avoid non-`none` values when this field is absent.
* @property tools Present if the client supports tool use via
* [CreateMessageRequestParams.tools] / [CreateMessageRequestParams.toolChoice].
* Servers MUST NOT send `tools`/`toolChoice` when this field is absent.
*/
@Serializable
public data class Sampling(public val context: JsonObject? = null, public val tools: JsonObject? = null)
/**
* @property sampling convenience value to enable the base sampling capability with no sub-capabilities
* @property elicitation convenience value to enable the elicitation capability
*/
public companion object {
public val sampling: JsonObject = EmptyJsonObject
public val sampling: Sampling = Sampling()
public val elicitation: JsonObject = EmptyJsonObject
}

View file

@ -29,6 +29,12 @@ public enum class ContentTypes(public val value: String) {
@SerialName("resource")
EMBEDDED_RESOURCE("resource"),
@SerialName("tool_use")
TOOL_USE("tool_use"),
@SerialName("tool_result")
TOOL_RESULT("tool_result"),
}
/**
@ -43,9 +49,27 @@ public sealed interface ContentBlock : WithMeta {
/**
* Content block that carries media data such as text, images, or audio.
*
* Every [MediaContent] is also a valid [SamplingMessageContent]; the sampling content
* hierarchy additionally admits [ToolUseContent] and [ToolResultContent].
*/
@Serializable(with = MediaContentPolymorphicSerializer::class)
public sealed interface MediaContent : ContentBlock
public sealed interface MediaContent :
ContentBlock,
SamplingMessageContent
/**
* Content block that can appear inside a [SamplingMessage] or [CreateMessageResult].
*
* Implemented by [TextContent], [ImageContent], [AudioContent], [ToolUseContent],
* and [ToolResultContent].
*
* @property type discriminator identifying the content block subtype
*/
@Serializable(with = SamplingMessageContentPolymorphicSerializer::class)
public sealed interface SamplingMessageContent : WithMeta {
public val type: ContentTypes
}
/**
* Text provided to or from an LLM.
@ -174,3 +198,50 @@ public data class EmbeddedResource(
@EncodeDefault
public override val type: ContentTypes = ContentTypes.EMBEDDED_RESOURCE
}
/**
* A request from the assistant to invoke a tool during sampling.
*
* @property id Unique identifier for this tool use; matches a subsequent
* [ToolResultContent.toolUseId] that reports the result.
* @property name The tool name (must match a tool declared in the sampling request's tools list).
* @property input The arguments to pass to the tool, conforming to the tool's input schema.
* @property meta property/parameter is reserved by MCP to allow clients and servers
* to attach additional metadata to their interactions.
*/
@Serializable
public data class ToolUseContent(
val id: String,
val name: String,
val input: JsonObject,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : SamplingMessageContent {
@EncodeDefault
public override val type: ContentTypes = ContentTypes.TOOL_USE
}
/**
* The result of a tool call previously requested via [ToolUseContent], supplied back
* to the assistant on the next sampling turn.
*
* @property toolUseId The id of the [ToolUseContent] this result corresponds to.
* @property content The unstructured result, following the same shape as [CallToolResult.content].
* @property structuredContent Optional structured result; if the tool declared an output schema,
* this SHOULD conform to it.
* @property isError Whether the tool call ended in error. Defaults to absent (treated as false).
* @property meta property/parameter is reserved by MCP to allow clients and servers
* to attach additional metadata to their interactions.
*/
@Serializable
public data class ToolResultContent(
val toolUseId: String,
val content: List<ContentBlock> = emptyList(),
val structuredContent: JsonObject? = null,
val isError: Boolean? = null,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : SamplingMessageContent {
@EncodeDefault
public override val type: ContentTypes = ContentTypes.TOOL_RESULT
}

View file

@ -21,7 +21,7 @@ import kotlin.contracts.contract
* val request = buildInitializeRequest {
* protocolVersion = "2024-11-05"
* capabilities {
* sampling(ClientCapabilities.sampling)
* sampling(ClientCapabilities.Sampling())
* roots(listChanged = true)
* }
* info("MyClient", "1.0.0")
@ -33,7 +33,7 @@ import kotlin.contracts.contract
* val request = buildInitializeRequest {
* protocolVersion = "2024-11-05"
* capabilities {
* sampling(ClientCapabilities.sampling)
* sampling(ClientCapabilities.Sampling())
* experimental {
* put("feature", JsonPrimitive(true))
* }
@ -94,7 +94,7 @@ public class InitializeRequestBuilder @PublishedApi internal constructor() : Req
* Example:
* ```kotlin
* capabilities(ClientCapabilities(
* sampling = ClientCapabilities.sampling,
* sampling = ClientCapabilities.Sampling(),
* roots = ClientCapabilities.Roots(listChanged = true)
* ))
* ```
@ -113,7 +113,7 @@ public class InitializeRequestBuilder @PublishedApi internal constructor() : Req
* Example:
* ```kotlin
* capabilities {
* sampling(ClientCapabilities.sampling)
* sampling(ClientCapabilities.Sampling())
* roots(listChanged = true)
* elicitation(ClientCapabilities.elicitation)
* }

View file

@ -112,8 +112,8 @@ public class CreateMessageRequestBuilder @PublishedApi internal constructor() :
* Example:
* ```kotlin
* messages(listOf(
* SamplingMessage(Role.User, TextContent("Hello")),
* SamplingMessage(Role.Assistant, TextContent("Hi!"))
* SamplingMessage(Role.User, listOf(TextContent("Hello"))),
* SamplingMessage(Role.Assistant, listOf(TextContent("Hi!")))
* ))
* ```
*/
@ -246,7 +246,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() {
* ```
*/
public fun user(content: MediaContent) {
messages.add(SamplingMessage(Role.User, content))
messages.add(SamplingMessage(Role.User, listOf(content)))
}
/**
@ -258,7 +258,7 @@ public class SamplingMessageBuilder @PublishedApi internal constructor() {
* ```
*/
public fun assistant(content: MediaContent) {
messages.add(SamplingMessage(Role.Assistant, content))
messages.add(SamplingMessage(Role.Assistant, listOf(content)))
}
@PublishedApi

View file

@ -75,11 +75,33 @@ public data class ModelPreferences(
*
* Used in sampling requests to provide conversation context and history to the LLM.
*
* Under SEP-1577, [content] is a list of [SamplingMessageContent] blocks rather than a
* single block. On the wire, a list of size 1 is serialised as a single object (wire-compatible
* with pre-SEP single-block content); a list of size 2+ is serialised as a JSON array.
*
* @property role The role of the message sender (user, assistant, or system).
* @property content The content of the message. Can be text, image, or audio content.
* @property content The content blocks of the message; MUST contain at least one block.
* @property meta Optional metadata reserved by MCP for clients and servers.
*/
@Serializable
public data class SamplingMessage(val role: Role, val content: MediaContent)
public data class SamplingMessage(
val role: Role,
@Serializable(with = SamplingContentSerializer::class)
val content: List<SamplingMessageContent>,
@SerialName("_meta")
val meta: JsonObject? = null,
) {
/**
* Convenience constructor for a single-block message. Wraps [content] in a
* singleton list so call sites can write `SamplingMessage(Role.User, TextContent("hi"))`
* without the explicit `listOf(...)`.
*/
public constructor(
role: Role,
content: SamplingMessageContent,
meta: JsonObject? = null,
) : this(role, listOf(content), meta)
}
// ============================================================================
// sampling/createMessage
@ -184,6 +206,12 @@ public data class CreateMessageRequest(override val params: CreateMessageRequest
* @property stopSequences Optional list of sequences that will stop generation if encountered.
* @property metadata Optional metadata to pass through to the LLM provider.
* The format of this metadata is provider-specific.
* @property tools Optional list of tools the model may use during generation.
* The client MUST return an error if this field is present but the client did not advertise
* [ClientCapabilities.Sampling.tools].
* @property toolChoice Optional policy controlling how the model uses the provided [tools].
* The client MUST return an error if this field is present but the client did not advertise
* [ClientCapabilities.Sampling.tools].
* @property meta Optional metadata for this request. May include a progressToken for
* out-of-band progress notifications.
*/
@ -197,6 +225,8 @@ public data class CreateMessageRequestParams(
val temperature: Double? = null,
val stopSequences: List<String>? = null,
val metadata: JsonObject? = null,
val tools: List<Tool>? = null,
val toolChoice: ToolChoice? = null,
@SerialName("_meta")
override val meta: RequestMeta? = null,
) : RequestParams
@ -226,22 +256,37 @@ public enum class IncludeContext {
* to inspect the response (human in the loop) and decide whether to allow the server to see it.
*
* @property role The role of the message sender. Typically [Role.Assistant] for LLM-generated responses.
* @property content The generated content. Can be text, image, or audio content.
* @property model The name of the model that generated the message (e.g., "claude-3-opus-20240229",
* "gpt-4-turbo-preview"). This helps the server understand which model was used.
* @property content The generated content blocks; at least one block is required.
* @property model The name of the model that generated the message (e.g., "claude-3-opus-20240229").
* @property stopReason The reason why sampling stopped, if known.
* Common values: "end_turn", "max_tokens", "stop_sequence", "content_filter"
* Common values: [StopReason.EndTurn], [StopReason.StopSequence], [StopReason.MaxTokens],
* [StopReason.ToolUse].
* @property meta Optional metadata for this response.
*/
@Serializable
public data class CreateMessageResult(
val role: Role,
val content: MediaContent,
@Serializable(with = SamplingContentSerializer::class)
val content: List<SamplingMessageContent>,
val model: String,
val stopReason: StopReason? = null,
@SerialName("_meta")
override val meta: JsonObject? = null,
) : ClientResult
) : ClientResult {
/**
* Convenience constructor for a single-block response. Wraps [content] in a
* singleton list so call sites can write
* `CreateMessageResult(Role.Assistant, TextContent("ok"), "model-name")`
* without the explicit `listOf(...)`.
*/
public constructor(
role: Role,
content: SamplingMessageContent,
model: String,
stopReason: StopReason? = null,
meta: JsonObject? = null,
) : this(role, listOf(content), model, stopReason, meta)
}
/**
* The reason why the LLM stopped generating tokens.
@ -255,10 +300,38 @@ public value class StopReason(public val value: String) {
* @property EndTurn generation ended naturally
* @property StopSequence a stop sequence was encountered
* @property MaxTokens the maximum token limit was reached
* @property ToolUse the assistant issued a tool call (see SEP-1577)
*/
public companion object {
public val EndTurn: StopReason = StopReason("endTurn")
public val StopSequence: StopReason = StopReason("stopSequence")
public val MaxTokens: StopReason = StopReason("maxTokens")
public val ToolUse: StopReason = StopReason("toolUse")
}
}
/**
* Controls tool-selection behaviour for [CreateMessageRequest] under SEP-1577.
*
* @property mode Tool-use policy:
* - [Mode.Auto]: the model decides whether to use tools (default when [mode] is absent)
* - [Mode.Required]: the model MUST use at least one tool before completing
* - [Mode.None]: the model MUST NOT use any tool
*/
@Serializable
public data class ToolChoice(public val mode: Mode? = null) {
/**
* Allowed values of [ToolChoice.mode] per SEP-1577.
*/
@Serializable
public enum class Mode {
@SerialName("auto")
Auto,
@SerialName("required")
Required,
@SerialName("none")
None,
}
}

View file

@ -4,13 +4,18 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
@ -121,6 +126,101 @@ internal object MediaContentPolymorphicSerializer :
}
}
/**
* Polymorphic serializer for [SamplingMessageContent] types.
* Determines the concrete type based on the "type" field in JSON.
*/
internal object SamplingMessageContentPolymorphicSerializer :
JsonContentPolymorphicSerializer<SamplingMessageContent>(SamplingMessageContent::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<SamplingMessageContent> =
when (element.getType()) {
ContentTypes.TEXT.value -> TextContent.serializer()
ContentTypes.IMAGE.value -> ImageContent.serializer()
ContentTypes.AUDIO.value -> AudioContent.serializer()
ContentTypes.TOOL_USE.value -> ToolUseContent.serializer()
ContentTypes.TOOL_RESULT.value -> ToolResultContent.serializer()
else -> throw SerializationException(
"Unknown sampling message content type: ${element.getTypeOrNull()}",
)
}
}
/**
* Wire-format serializer for `List<SamplingMessageContent>` honouring SEP-1577's
* single-object-or-array content shape.
*
* **Why this exists.** SEP-1577 widens the `content` field of [SamplingMessage] and
* [CreateMessageResult] from a single content block to either a single block or an
* array of blocks. Pre-SEP peers only understand a single object on the wire; post-SEP
* peers may send arrays when a turn carries multiple blocks (e.g. an assistant reply
* containing `[TextContent, ToolUseContent]` during a tool-loop step).
*
* The Kotlin API uses `List<SamplingMessageContent>` uniformly to avoid branching in
* caller code. This serializer bridges the type difference:
*
* - **Read:** accepts both shapes; a single JSON object becomes `listOf(block)`, an
* array becomes the decoded list.
* - **Write:** size-1 list → single object (wire-compatible with pre-SEP peers);
* size≥2 → array; empty list throws (sampling content has no valid empty meaning
* per spec).
*/
internal object SamplingContentSerializer : KSerializer<List<SamplingMessageContent>> {
private val listSerializer = ListSerializer(SamplingMessageContentPolymorphicSerializer)
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun serialize(encoder: Encoder, value: List<SamplingMessageContent>) {
check(value.isNotEmpty()) { "content must contain at least one block" }
val jsonEncoder = encoder as? JsonEncoder
?: throw SerializationException("SamplingContentSerializer requires a Json encoder")
if (value.size == 1) {
jsonEncoder.encodeJsonElement(
jsonEncoder.json.encodeToJsonElement(
SamplingMessageContentPolymorphicSerializer,
value[0],
),
)
} else {
jsonEncoder.encodeJsonElement(
jsonEncoder.json.encodeToJsonElement(listSerializer, value),
)
}
}
override fun deserialize(decoder: Decoder): List<SamplingMessageContent> {
val jsonDecoder = decoder as? JsonDecoder
?: throw SerializationException("SamplingContentSerializer requires a Json decoder")
return decodeFromElement(jsonDecoder, jsonDecoder.decodeJsonElement())
}
private fun decodeFromElement(jsonDecoder: JsonDecoder, element: JsonElement): List<SamplingMessageContent> =
when (element) {
is JsonArray -> {
if (element.isEmpty()) {
throw SerializationException("content must contain at least one block")
}
jsonDecoder.json.decodeFromJsonElement(listSerializer, element)
}
is JsonObject -> listOf(
jsonDecoder.json.decodeFromJsonElement(
SamplingMessageContentPolymorphicSerializer,
element,
),
)
else -> throw SerializationException(
"content must be a JSON object or array of objects, got $element",
)
}
}
// ============================================================================
// Resource Serializers
// ============================================================================

View file

@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.types
import io.kotest.assertions.json.shouldEqualJson
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
import io.modelcontextprotocol.kotlin.test.utils.verifySerializationRoundTrip
@ -8,6 +9,7 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class CapabilitiesTest {
@ -119,7 +121,7 @@ class CapabilitiesTest {
@Test
fun `should serialize ClientCapabilities with sampling`() {
val capabilities = ClientCapabilities(
sampling = ClientCapabilities.sampling,
sampling = ClientCapabilities.Sampling(),
)
verifySerialization(
capabilities,
@ -266,7 +268,7 @@ class CapabilitiesTest {
"io.modelcontextprotocol/ui" to EmptyJsonObject,
)
val capabilities = ClientCapabilities(
sampling = ClientCapabilities.sampling,
sampling = ClientCapabilities.Sampling(),
roots = ClientCapabilities.Roots(listChanged = true),
elicitation = ClientCapabilities.elicitation,
experimental = experimental,
@ -309,7 +311,7 @@ class CapabilitiesTest {
val capabilities = verifyDeserialization<ClientCapabilities>(McpJson, json)
assertEquals(EmptyJsonObject, capabilities.sampling)
assertEquals(ClientCapabilities.Sampling(), capabilities.sampling)
assertEquals(true, capabilities.roots?.listChanged)
assertEquals(EmptyJsonObject, capabilities.elicitation)
}
@ -330,7 +332,7 @@ class CapabilitiesTest {
@Test
fun `should serialize and deserialize ClientCapabilities round trip`() {
val original = ClientCapabilities(
sampling = ClientCapabilities.sampling,
sampling = ClientCapabilities.Sampling(),
roots = ClientCapabilities.Roots(listChanged = false),
elicitation = ClientCapabilities.elicitation,
extensions = mapOf(
@ -734,10 +736,11 @@ class CapabilitiesTest {
}
""".trimIndent()
val capabilities = verifyDeserialization<ClientCapabilities>(McpJson, json)
// Sampling is now a typed struct; unknown fields are ignored on deserialization
val capabilities = McpJson.decodeFromString<ClientCapabilities>(json)
// Should not fail - additionalProperties are allowed
assertEquals("customValue", capabilities.sampling?.get("customProperty")?.toString()?.trim('"'))
// Should not fail - additionalProperties are allowed (unknown fields are ignored by Sampling)
assertNotNull(capabilities.sampling)
}
@Test
@ -755,4 +758,50 @@ class CapabilitiesTest {
// Should not fail - additionalProperties are allowed
assertEquals("debug", capabilities.logging?.get("level")?.toString()?.trim('"'))
}
// ============================================================================
// ClientCapabilities.Sampling sub-capabilities (SEP-1577)
// ============================================================================
@Test
fun `empty sampling serialises as empty object`() {
val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling())
val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps)
check("\"sampling\":{}" in json) { json }
}
@Test
fun `sampling with tools sub-capability serialises tools`() {
val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling(tools = EmptyJsonObject))
val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps)
check("\"tools\":{}" in json) { json }
}
@Test
fun `sampling with context sub-capability serialises context`() {
val caps = ClientCapabilities(sampling = ClientCapabilities.Sampling(context = EmptyJsonObject))
val json = McpJson.encodeToString(ClientCapabilities.serializer(), caps)
check("\"context\":{}" in json) { json }
}
@Test
fun `sampling with combined tools and context round-trips`() {
val original = ClientCapabilities(
sampling = ClientCapabilities.Sampling(
context = EmptyJsonObject,
tools = EmptyJsonObject,
),
)
val json = McpJson.encodeToString(ClientCapabilities.serializer(), original)
val decoded = McpJson.decodeFromString(ClientCapabilities.serializer(), json)
decoded shouldBe original
decoded.sampling?.tools shouldBe EmptyJsonObject
decoded.sampling?.context shouldBe EmptyJsonObject
}
@Test
fun `absent sampling means unsupported`() {
val caps = ClientCapabilities()
caps.sampling shouldBe null
}
}

View file

@ -1,17 +1,26 @@
package io.modelcontextprotocol.kotlin.sdk.types
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNull
class ContentTest {
private val samplingContentSerializer = ListSerializer(SamplingMessageContent.serializer())
@Test
fun `should serialize TextContent with minimal fields`() {
val content = TextContent(text = "Hello, MCP!")
@ -308,4 +317,133 @@ class ContentTest {
assertIs<ResourceLink>(content[1])
assertIs<EmbeddedResource>(content[2])
}
// ============================================================================
// SamplingMessageContent hierarchy (SEP-1577)
// ============================================================================
@Test
fun `TextContent implements SamplingMessageContent`() {
val c: SamplingMessageContent = TextContent("hi")
c.shouldBeInstanceOf<TextContent>()
}
@Test
fun `ImageContent implements SamplingMessageContent`() {
val c: SamplingMessageContent = ImageContent(data = "AA==", mimeType = "image/png")
c.shouldBeInstanceOf<ImageContent>()
}
@Test
fun `AudioContent implements SamplingMessageContent`() {
val c: SamplingMessageContent = AudioContent(data = "AA==", mimeType = "audio/wav")
c.shouldBeInstanceOf<AudioContent>()
}
// ============================================================================
// ToolUseContent
// ============================================================================
@Test
fun `ToolUseContent round-trips through McpJson`() {
val input = buildJsonObject { put("location", JsonPrimitive("London")) }
val original = ToolUseContent(id = "call_1", name = "get_weather", input = input)
val encoded = McpJson.encodeToString(ToolUseContent.serializer(), original)
val decoded = McpJson.decodeFromString(ToolUseContent.serializer(), encoded)
decoded shouldBe original
}
@Test
fun `ToolUseContent serialises with type discriminator tool_use`() {
val c = ToolUseContent(id = "x", name = "n", input = JsonObject(emptyMap()))
val json = McpJson.encodeToString(ToolUseContent.serializer(), c)
check("\"type\":\"tool_use\"" in json) { "missing discriminator in: $json" }
}
@Test
fun `ToolUseContent preserves meta when present`() {
val meta = buildJsonObject { put("cacheControl", JsonPrimitive("ephemeral")) }
val c = ToolUseContent(id = "x", name = "n", input = JsonObject(emptyMap()), meta = meta)
val json = McpJson.encodeToString(ToolUseContent.serializer(), c)
val decoded = McpJson.decodeFromString(ToolUseContent.serializer(), json)
decoded.meta shouldBe meta
}
// ============================================================================
// ToolResultContent
// ============================================================================
@Test
fun `ToolResultContent round-trips with text content block`() {
val original = ToolResultContent(
toolUseId = "call_1",
content = listOf(TextContent("20°C sunny")),
)
val encoded = McpJson.encodeToString(ToolResultContent.serializer(), original)
val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), encoded)
decoded shouldBe original
}
@Test
fun `ToolResultContent round-trips with mixed content including resource_link`() {
val original = ToolResultContent(
toolUseId = "call_2",
content = listOf(
TextContent("see file"),
ResourceLink(name = "log", uri = "file:///tmp/a.log"),
),
)
val encoded = McpJson.encodeToString(ToolResultContent.serializer(), original)
val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), encoded)
decoded shouldBe original
}
@Test
fun `ToolResultContent serialises with discriminator and preserves structuredContent and isError`() {
val original = ToolResultContent(
toolUseId = "call_3",
content = emptyList(),
structuredContent = buildJsonObject { put("temp", JsonPrimitive(20)) },
isError = true,
)
val json = McpJson.encodeToString(ToolResultContent.serializer(), original)
check("\"type\":\"tool_result\"" in json) { "missing discriminator in: $json" }
val decoded = McpJson.decodeFromString(ToolResultContent.serializer(), json)
decoded shouldBe original
}
// ============================================================================
// SamplingMessageContentPolymorphicSerializer
// ============================================================================
@Test
fun `SamplingMessageContent polymorphic serializer decodes text`() {
val json = """[{"type":"text","text":"hi"}]"""
val list = McpJson.decodeFromString(samplingContentSerializer, json)
list[0].shouldBeInstanceOf<TextContent>().text shouldBe "hi"
}
@Test
fun `SamplingMessageContent polymorphic serializer decodes tool_use`() {
val json = """[{"type":"tool_use","id":"a","name":"n","input":{}}]"""
val list = McpJson.decodeFromString(samplingContentSerializer, json)
val use = list[0].shouldBeInstanceOf<ToolUseContent>()
use.id shouldBe "a"
use.name shouldBe "n"
}
@Test
fun `SamplingMessageContent polymorphic serializer decodes tool_result`() {
val json = """[{"type":"tool_result","toolUseId":"a","content":[]}]"""
val list = McpJson.decodeFromString(samplingContentSerializer, json)
list[0].shouldBeInstanceOf<ToolResultContent>().toolUseId shouldBe "a"
}
@Test
fun `SamplingMessageContent polymorphic serializer throws on unknown discriminator`() {
val json = """[{"type":"bogus","x":1}]"""
assertFailsWith<SerializationException> {
McpJson.decodeFromString(samplingContentSerializer, json)
}
}
}

View file

@ -21,7 +21,7 @@ class InitializeTest {
InitializeRequestParams(
protocolVersion = "2024-11-05",
capabilities = ClientCapabilities(
sampling = ClientCapabilities.sampling,
sampling = ClientCapabilities.Sampling(),
roots = ClientCapabilities.Roots(listChanged = true),
elicitation = ClientCapabilities.elicitation,
experimental = buildJsonObject {

View file

@ -1,7 +1,13 @@
package io.modelcontextprotocol.kotlin.sdk.types
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonPrimitive
@ -14,6 +20,21 @@ import kotlin.test.assertNotNull
class SamplingTest {
@Serializable
private data class Holder(
@Serializable(with = SamplingContentSerializer::class)
val content: List<SamplingMessageContent>,
)
private val dummyTool = Tool(
name = "get_weather",
description = "returns weather",
inputSchema = ToolSchema(
properties = buildJsonObject { },
required = emptyList(),
),
)
@Test
fun `should serialize ModelHint`() {
val hint = ModelHint(name = "claude-3-5-sonnet")
@ -66,7 +87,7 @@ class SamplingTest {
fun `should serialize SamplingMessage`() {
val message = SamplingMessage(
role = Role.User,
content = TextContent(text = "Summarize the latest release."),
content = listOf(TextContent(text = "Summarize the latest release.")),
)
verifySerialization(
@ -93,11 +114,11 @@ class SamplingTest {
messages = listOf(
SamplingMessage(
role = Role.User,
content = TextContent(text = "You are a helpful assistant."),
content = listOf(TextContent(text = "You are a helpful assistant.")),
),
SamplingMessage(
role = Role.User,
content = TextContent(text = "Provide a short summary."),
content = listOf(TextContent(text = "Provide a short summary.")),
),
),
modelPreferences = ModelPreferences(
@ -202,7 +223,7 @@ class SamplingTest {
val message = params.messages.first()
assertEquals(Role.User, message.role)
val content = assertIs<TextContent>(message.content)
val content = assertIs<TextContent>(message.content.single())
assertEquals("Draft a project update.", content.text)
val preferences = assertNotNull(params.modelPreferences)
@ -259,7 +280,7 @@ class SamplingTest {
val result = verifyDeserialization<CreateMessageResult>(McpJson, json)
assertEquals(Role.Assistant, result.role)
val text = assertIs<TextContent>(result.content)
val text = assertIs<TextContent>(result.content.single())
assertEquals("Summary complete.", text.text)
assertEquals("gpt-4o", result.model)
assertEquals(StopReason.StopSequence, result.stopReason)
@ -267,4 +288,211 @@ class SamplingTest {
assertNotNull(meta)
assertEquals(1200.5, meta["latencyMs"]?.jsonPrimitive?.double)
}
// ============================================================================
// SamplingMessage shape (SEP-1577)
// ============================================================================
@Test
fun `SamplingMessage content is a list of SamplingMessageContent`() {
val m = SamplingMessage(
role = Role.User,
content = listOf(TextContent("hi")),
)
m.content.size shouldBe 1
(m.content[0] as TextContent).text shouldBe "hi"
}
@Test
fun `SamplingMessage single-element content serialises as single object`() {
val m = SamplingMessage(role = Role.User, content = listOf(TextContent("hi")))
val json = McpJson.encodeToString(SamplingMessage.serializer(), m)
check("""{"role":"user","content":""" in json) { "expected role/content prefix, got $json" }
check("\"type\":\"text\"" in json && "\"text\":\"hi\"" in json) {
"expected single-object content with text discriminator, got $json"
}
check("\"content\":[" !in json) { "expected single-object wire, but array form found: $json" }
}
@Test
fun `SamplingMessage multi-element content serialises as array`() {
val m = SamplingMessage(
role = Role.Assistant,
content = listOf(
TextContent("Let me use a tool"),
ToolUseContent(id = "c1", name = "get_weather", input = JsonObject(emptyMap())),
),
)
val json = McpJson.encodeToString(SamplingMessage.serializer(), m)
check("\"content\":[" in json) { "expected array wire form, got $json" }
}
@Test
fun `SamplingMessage _meta round-trips`() {
val meta = buildJsonObject { put("k", JsonPrimitive("v")) }
val m = SamplingMessage(role = Role.User, content = listOf(TextContent("hi")), meta = meta)
val json = McpJson.encodeToString(SamplingMessage.serializer(), m)
val decoded = McpJson.decodeFromString(SamplingMessage.serializer(), json)
decoded.meta shouldBe meta
}
// ============================================================================
// CreateMessageRequestParams: tools / toolChoice + StopReason.ToolUse
// ============================================================================
@Test
fun `CreateMessageRequestParams tools and toolChoice default to null`() {
val params = CreateMessageRequestParams(maxTokens = 100, messages = emptyList())
params.tools shouldBe null
params.toolChoice shouldBe null
}
@Test
fun `CreateMessageRequestParams round-trips tools and toolChoice`() {
val original = CreateMessageRequestParams(
maxTokens = 100,
messages = emptyList(),
tools = listOf(dummyTool),
toolChoice = ToolChoice(mode = ToolChoice.Mode.Required),
)
val encoded = McpJson.encodeToString(CreateMessageRequestParams.serializer(), original)
val decoded = McpJson.decodeFromString(CreateMessageRequestParams.serializer(), encoded)
decoded.tools?.single()?.name shouldBe "get_weather"
decoded.toolChoice shouldBe ToolChoice(mode = ToolChoice.Mode.Required)
}
@Test
fun `StopReason ToolUse serialises as toolUse`() {
StopReason.ToolUse.value shouldBe "toolUse"
}
// ============================================================================
// CreateMessageResult shape (SEP-1577)
// ============================================================================
@Test
fun `CreateMessageResult single-block content serialises as single object`() {
val r = CreateMessageResult(
role = Role.Assistant,
content = TextContent("42"),
model = "test-model",
stopReason = StopReason.EndTurn,
)
val json = McpJson.encodeToString(CreateMessageResult.serializer(), r)
check("\"content\":{" in json) { "expected single-object content wire form, got $json" }
check("\"content\":[" !in json) { "expected NOT to use array wire form for size-1, got $json" }
}
@Test
fun `CreateMessageResult multi-block content with ToolUse stopReason round-trips`() {
val r = CreateMessageResult(
role = Role.Assistant,
content = listOf(
TextContent("Let me use a tool"),
ToolUseContent(id = "c1", name = "get_weather", input = JsonObject(emptyMap())),
),
model = "test-model",
stopReason = StopReason.ToolUse,
)
val json = McpJson.encodeToString(CreateMessageResult.serializer(), r)
val decoded = McpJson.decodeFromString(CreateMessageResult.serializer(), json)
decoded shouldBe r
decoded.stopReason shouldBe StopReason.ToolUse
decoded.content.size shouldBe 2
}
@Test
fun `CreateMessageResult pre-SEP single-object wire decodes correctly`() {
val json = """{"role":"assistant","content":{"type":"text","text":"hi"},"model":"m"}"""
val decoded = McpJson.decodeFromString(CreateMessageResult.serializer(), json)
decoded.content.size shouldBe 1
(decoded.content[0] as TextContent).text shouldBe "hi"
}
// ============================================================================
// SamplingContentSerializer (single-or-array wire heuristic)
// ============================================================================
@Test
fun `SamplingContentSerializer decodes single object into list of one`() {
val json = """{"content":{"type":"text","text":"hi"}}"""
val h = McpJson.decodeFromString(Holder.serializer(), json)
h.content.size shouldBe 1
h.content[0].shouldBeInstanceOf<TextContent>().text shouldBe "hi"
}
@Test
fun `SamplingContentSerializer decodes array into list`() {
val json = """{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}"""
val h = McpJson.decodeFromString(Holder.serializer(), json)
h.content.size shouldBe 2
}
@Test
fun `SamplingContentSerializer encodes list of size one as a single object`() {
val h = Holder(content = listOf(TextContent("hi")))
val json = McpJson.encodeToString(Holder.serializer(), h)
json shouldBe """{"content":{"text":"hi","type":"text"}}"""
}
@Test
fun `SamplingContentSerializer encodes list of size two as an array`() {
val h = Holder(content = listOf(TextContent("a"), TextContent("b")))
val json = McpJson.encodeToString(Holder.serializer(), h)
json shouldBe """{"content":[{"text":"a","type":"text"},{"text":"b","type":"text"}]}"""
}
@Test
fun `SamplingContentSerializer encoding an empty list throws`() {
val h = Holder(content = emptyList())
assertFailsWith<IllegalStateException> {
McpJson.encodeToString(Holder.serializer(), h)
}
}
@Test
fun `SamplingContentSerializer decoding an empty array throws`() {
val json = """{"content":[]}"""
assertFailsWith<SerializationException> {
McpJson.decodeFromString(Holder.serializer(), json)
}
}
// ============================================================================
// ToolChoice
// ============================================================================
@Test
fun `ToolChoice round-trips auto mode`() {
val original = ToolChoice(mode = ToolChoice.Mode.Auto)
val json = McpJson.encodeToString(ToolChoice.serializer(), original)
json shouldBe """{"mode":"auto"}"""
McpJson.decodeFromString(ToolChoice.serializer(), json) shouldBe original
}
@Test
fun `ToolChoice round-trips required mode`() {
val original = ToolChoice(mode = ToolChoice.Mode.Required)
McpJson.decodeFromString(
ToolChoice.serializer(),
McpJson.encodeToString(ToolChoice.serializer(), original),
) shouldBe original
}
@Test
fun `ToolChoice round-trips none mode`() {
val original = ToolChoice(mode = ToolChoice.Mode.None)
McpJson.decodeFromString(
ToolChoice.serializer(),
McpJson.encodeToString(ToolChoice.serializer(), original),
) shouldBe original
}
@Test
fun `ToolChoice absent mode serialises as empty object and deserialises to null mode`() {
val original = ToolChoice()
val json = McpJson.encodeToString(ToolChoice.serializer(), original)
json shouldBe """{}"""
McpJson.decodeFromString(ToolChoice.serializer(), json) shouldBe ToolChoice(mode = null)
}
}

View file

@ -4,10 +4,10 @@ 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.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
@ -42,11 +42,7 @@ class CapabilitiesDslTest {
val request = buildInitializeRequest {
protocolVersion = "2024-11-05"
capabilities {
sampling {
put("temperature", 0.7)
put("maxTokens", 1000)
put("topP", 0.95)
}
sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject))
roots(listChanged = true)
elicitation {
put("mode", "interactive")
@ -72,9 +68,7 @@ class CapabilitiesDslTest {
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
tools shouldBe EmptyJsonObject
}
roots shouldNotBeNull {
listChanged shouldBe true
@ -174,8 +168,8 @@ class CapabilitiesDslTest {
val request = buildInitializeRequest {
protocolVersion = "2024-11-05"
capabilities {
sampling { put("temperature", 0.5) }
sampling { put("temperature", 0.9) } // Should overwrite
sampling(ClientCapabilities.Sampling())
sampling(ClientCapabilities.Sampling(tools = EmptyJsonObject)) // Should overwrite
experimental { put("feature", "v1") }
experimental { put("feature", "v2") } // Should overwrite
}
@ -184,7 +178,7 @@ class CapabilitiesDslTest {
request.params.capabilities.shouldNotBeNull {
sampling shouldNotBeNull {
get("temperature")?.jsonPrimitive?.double shouldBe 0.9
tools shouldBe EmptyJsonObject
}
experimental shouldNotBeNull {
get("feature")?.jsonPrimitive?.content shouldBe "v2"

View file

@ -42,7 +42,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as TextContent).shouldNotBeNull {
(request.params.messages[0].content.single() as TextContent).shouldNotBeNull {
text shouldBe "Hello, world!"
annotations.shouldBeNull()
meta.shouldBeNull()
@ -71,7 +71,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as TextContent).shouldNotBeNull {
(request.params.messages[0].content.single() as TextContent).shouldNotBeNull {
text shouldBe "Analyze the quarterly sales report for Q4 2025"
annotations shouldNotBeNull {
audience shouldBe listOf(Role.User, Role.Assistant)
@ -98,7 +98,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا"
(request.params.messages[0].content.single() as TextContent).text shouldBe "Hello 🌍! Ça va? Москва 北京 مرحبا"
}
@Test
@ -112,7 +112,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as TextContent).text shouldBe ""
(request.params.messages[0].content.single() as TextContent).text shouldBe ""
}
@Test
@ -144,7 +144,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as ImageContent).shouldNotBeNull {
(request.params.messages[0].content.single() as ImageContent).shouldNotBeNull {
data shouldBe
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
mimeType shouldBe "image/png"
@ -177,7 +177,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as ImageContent).shouldNotBeNull {
(request.params.messages[0].content.single() as ImageContent).shouldNotBeNull {
mimeType shouldBe "image/jpeg"
annotations shouldNotBeNull {
audience shouldBe listOf(Role.Assistant)
@ -208,7 +208,7 @@ class ContentDslTest {
}
}
}
(request.params.messages[0].content as ImageContent).mimeType shouldBe mime
(request.params.messages[0].content.single() as ImageContent).mimeType shouldBe mime
}
}
@ -256,7 +256,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as AudioContent).shouldNotBeNull {
(request.params.messages[0].content.single() as AudioContent).shouldNotBeNull {
data shouldBe "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA=="
mimeType shouldBe "audio/wav"
annotations.shouldBeNull()
@ -288,7 +288,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as AudioContent).shouldNotBeNull {
(request.params.messages[0].content.single() as AudioContent).shouldNotBeNull {
mimeType shouldBe "audio/mpeg"
annotations shouldNotBeNull {
audience shouldBe listOf(Role.User)
@ -349,7 +349,7 @@ class ContentDslTest {
}
}
}
(requestMin.params.messages[0].content as TextContent).annotations?.priority shouldBe 0.0
(requestMin.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 0.0
// Priority = 1.0 (maximum)
val requestMax = buildCreateMessageRequest {
@ -361,7 +361,7 @@ class ContentDslTest {
}
}
}
(requestMax.params.messages[0].content as TextContent).annotations?.priority shouldBe 1.0
(requestMax.params.messages[0].content.single() as TextContent).annotations?.priority shouldBe 1.0
}
@Test
@ -376,7 +376,7 @@ class ContentDslTest {
}
}
(request.params.messages[0].content as TextContent).annotations shouldNotBeNull {
(request.params.messages[0].content.single() as TextContent).annotations shouldNotBeNull {
priority shouldBe 0.5
audience shouldBe listOf(Role.User)
lastModified.shouldBeNull()

View file

@ -9,7 +9,6 @@ import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlin.test.Test
@ -21,9 +20,7 @@ class InitializeDslTest {
val request = buildInitializeRequest {
protocolVersion = "2024-11-05"
capabilities {
sampling {
put("maxTokens", 100)
}
sampling(ClientCapabilities.Sampling())
roots(listChanged = true)
elicitation {
put("mode", "interactive")
@ -47,7 +44,7 @@ class InitializeDslTest {
request.params.protocolVersion shouldBe "2024-11-05"
request.params.capabilities.shouldNotBeNull {
sampling?.get("maxTokens")?.jsonPrimitive?.int shouldBe 100
sampling shouldNotBeNull { }
roots?.listChanged shouldBe true
elicitation?.get("mode")?.jsonPrimitive?.content shouldBe "interactive"
experimental?.get("custom")?.jsonPrimitive?.content shouldBe "true"
@ -79,8 +76,8 @@ class InitializeDslTest {
}
@Test
fun `capabilities DSL should support direct JsonObject values`() {
val samplingObj = buildJsonObject { put("key", "value") }
fun `capabilities DSL should support direct typed values`() {
val samplingValue = ClientCapabilities.Sampling(tools = EmptyJsonObject)
val elicitationObj = buildJsonObject { put("key", "value") }
val experimentalObj = buildJsonObject { put("key", "value") }
val extensionsMap = mapOf(
@ -90,7 +87,7 @@ class InitializeDslTest {
val request = buildInitializeRequest {
protocolVersion = "1.0"
capabilities {
sampling(samplingObj)
sampling(samplingValue)
elicitation(elicitationObj)
experimental(experimentalObj)
extensions(extensionsMap)
@ -99,7 +96,7 @@ class InitializeDslTest {
}
request.params.capabilities.shouldNotBeNull {
sampling shouldBe samplingObj
sampling shouldBe samplingValue
elicitation shouldBe elicitationObj
experimental shouldBe experimentalObj
extensions shouldBe extensionsMap

View file

@ -71,7 +71,7 @@ class SamplingDslTest {
request.params.stopSequences shouldBe listOf("STOP")
request.params.messages shouldHaveSize 5
(request.params.messages[2].content as TextContent).shouldNotBeNull {
(request.params.messages[2].content.single() as TextContent).shouldNotBeNull {
text shouldBe "Text with annotations"
annotations shouldNotBeNull {
audience shouldBe listOf(Role.User)
@ -81,13 +81,13 @@ class SamplingDslTest {
meta?.get("key")?.jsonPrimitive?.content shouldBe "value"
}
(request.params.messages[3].content as ImageContent).shouldNotBeNull {
(request.params.messages[3].content.single() as ImageContent).shouldNotBeNull {
data shouldBe "base64image"
mimeType shouldBe "image/png"
annotations?.priority shouldBe 0.5
}
(request.params.messages[4].content as AudioContent).shouldNotBeNull {
(request.params.messages[4].content.single() as AudioContent).shouldNotBeNull {
data shouldBe "base64audio"
mimeType shouldBe "audio/wav"
}
@ -102,7 +102,7 @@ class SamplingDslTest {
@Test
fun `buildCreateMessageRequest should support direct assignments`() {
val messages = listOf(SamplingMessage(Role.User, TextContent("Hello")))
val messages = listOf(SamplingMessage(Role.User, listOf(TextContent("Hello"))))
val preferences = ModelPreferences(costPriority = 0.1)
val request = buildCreateMessageRequest {
maxTokens = 100
@ -126,8 +126,8 @@ class SamplingDslTest {
assistant(content)
}
}
request.params.messages[0].content shouldBe content
request.params.messages[1].content shouldBe content
request.params.messages[0].content.single() shouldBe content
request.params.messages[1].content.single() shouldBe content
}
@Test