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:
parent
7474a33c56
commit
f434fcaff1
16 changed files with 1553 additions and 135 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
* }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue