feat: add URL mode elicitation and typed schema definitions per MCP (#660)
Add URL mode elicitation support and typed `PrimitiveSchemaDefinition` hierarchy to align with MCP specification 2025-11-25 closes #540 ## How Has This Been Tested? unit/integration/conformance ## Breaking Changes - `ElicitRequestParams` changed from `data class` to `sealed interface`. A deprecated factory function preserves source compatibility for `ElicitRequestParams(message, requestedSchema)`. - `RequestedSchema.properties` changed from `JsonObject` to `Map<String, PrimitiveSchemaDefinition>`. - `ElicitRequest.requestedSchema` is deprecated, use `(params as ElicitRequestFormParams).requestedSchema`. ## 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
ad072325c9
commit
fdecf6bdf5
7 changed files with 1838 additions and 384 deletions
|
|
@ -4,6 +4,7 @@ import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
|
|||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
|
@ -223,6 +224,9 @@ public class ElicitRequestedSchemaBuilder @PublishedApi internal constructor() {
|
|||
val properties = requireNotNull(properties) {
|
||||
"Missing required field 'properties'. Use properties { put(\"fieldName\", schema) }"
|
||||
}
|
||||
return ElicitRequestParams.RequestedSchema(properties, required)
|
||||
val typedProperties: Map<String, PrimitiveSchemaDefinition> = properties.mapValues { (_, value) ->
|
||||
McpJson.decodeFromJsonElement<PrimitiveSchemaDefinition>(value)
|
||||
}
|
||||
return ElicitRequestParams.RequestedSchema(properties = typedProperties, required = required)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.types
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* A request from the server to elicit additional information from the user via the client.
|
||||
* Represents an `elicitation/create` request from the server to the client.
|
||||
*
|
||||
* This request type allows servers to prompt users for structured input through forms
|
||||
* or dialogs presented by the client. The server defines a schema for the requested data,
|
||||
* and the client presents an appropriate UI to collect this information.
|
||||
* Supports two modes: form mode ([ElicitRequestFormParams]) for collecting structured data
|
||||
* in-band, and URL mode ([ElicitRequestURLParams]) for directing the user to an external URL.
|
||||
*
|
||||
* @property params The elicitation parameters including the message and requested schema.
|
||||
* @property params The elicitation parameters — either form or URL mode.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ElicitRequest(override val params: ElicitRequestParams) : ServerRequest {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@EncodeDefault
|
||||
public override val method: Method = Method.Defined.ElicitationCreate
|
||||
|
||||
|
|
@ -30,8 +27,13 @@ public data class ElicitRequest(override val params: ElicitRequestParams) : Serv
|
|||
/**
|
||||
* A restricted subset of JSON Schema defining the structure of the requested data.
|
||||
*/
|
||||
public val requestedSchema: ElicitRequestParams.RequestedSchema
|
||||
get() = params.requestedSchema
|
||||
@Deprecated(
|
||||
"Use (params as ElicitRequestFormParams).requestedSchema",
|
||||
ReplaceWith("(params as ElicitRequestFormParams).requestedSchema"),
|
||||
DeprecationLevel.WARNING,
|
||||
)
|
||||
public val requestedSchema: ElicitRequestParams.RequestedSchema?
|
||||
get() = (params as? ElicitRequestFormParams)?.requestedSchema
|
||||
|
||||
/**
|
||||
* Metadata for this request. May include a progressToken for out-of-band progress notifications.
|
||||
|
|
@ -41,23 +43,13 @@ public data class ElicitRequest(override val params: ElicitRequestParams) : Serv
|
|||
}
|
||||
|
||||
/**
|
||||
* Parameters for an elicitation/create request.
|
||||
* Represents the parameters for an `elicitation/create` request.
|
||||
*
|
||||
* @property message The message to present to the user. This should clearly explain
|
||||
* what information is being requested and why.
|
||||
* @property requestedSchema A restricted subset of JSON Schema defining the structure
|
||||
* of the requested data. Only top-level properties are allowed,
|
||||
* without nesting.
|
||||
* @property meta Optional metadata for this request. May include a progressToken for
|
||||
* out-of-band progress notifications.
|
||||
* Implementations: [ElicitRequestFormParams], [ElicitRequestURLParams].
|
||||
*/
|
||||
@Serializable
|
||||
public data class ElicitRequestParams(
|
||||
val message: String,
|
||||
val requestedSchema: RequestedSchema,
|
||||
@SerialName("_meta")
|
||||
override val meta: RequestMeta? = null,
|
||||
) : RequestParams {
|
||||
@Serializable(with = ElicitRequestParamsSerializer::class)
|
||||
public sealed interface ElicitRequestParams : RequestParams {
|
||||
public val message: String
|
||||
|
||||
/**
|
||||
* A restricted JSON Schema for elicitation requests.
|
||||
|
|
@ -65,6 +57,7 @@ public data class ElicitRequestParams(
|
|||
* Only supports top-level primitive properties without nesting. Each property
|
||||
* represents a field in the form or dialog presented to the user.
|
||||
*
|
||||
* @property schema Optional URI to a JSON Schema definition.
|
||||
* @property properties A map of property names to their schema definitions.
|
||||
* Each property must be a primitive type (string, number, boolean).
|
||||
* @property required Optional list of property names that must be provided by the user.
|
||||
|
|
@ -72,24 +65,102 @@ public data class ElicitRequestParams(
|
|||
* @property type Always "object" for elicitation schemas.
|
||||
*/
|
||||
@Serializable
|
||||
public data class RequestedSchema(val properties: JsonObject, val required: List<String>? = null) {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
public data class RequestedSchema(
|
||||
@SerialName($$"$schema")
|
||||
val schema: String? = null,
|
||||
val properties: Map<String, PrimitiveSchemaDefinition>,
|
||||
val required: List<String>? = null,
|
||||
) {
|
||||
@EncodeDefault
|
||||
val type: String = "object"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's response to an [ElicitRequest].
|
||||
* Creates an [ElicitRequestFormParams] for backwards compatibility.
|
||||
*
|
||||
* Represents the user's action and, if accepted, the submitted form data.
|
||||
* @param message The message to present to the user.
|
||||
* @param requestedSchema The JSON Schema for the requested data.
|
||||
* @param meta Optional request metadata.
|
||||
* @return A configured [ElicitRequestFormParams] instance.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use ElicitRequestFormParams instead",
|
||||
ReplaceWith("ElicitRequestFormParams(message, requestedSchema = requestedSchema, meta = meta)"),
|
||||
DeprecationLevel.WARNING,
|
||||
)
|
||||
@Suppress("FunctionNaming", "FunctionName")
|
||||
public fun ElicitRequestParams(
|
||||
message: String,
|
||||
requestedSchema: ElicitRequestParams.RequestedSchema,
|
||||
meta: RequestMeta? = null,
|
||||
): ElicitRequestFormParams = ElicitRequestFormParams(
|
||||
message = message,
|
||||
requestedSchema = requestedSchema,
|
||||
meta = meta,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents form mode parameters for an `elicitation/create` request.
|
||||
*
|
||||
* @property action The user action in response to the elicitation prompt.
|
||||
* @property content The submitted form data, only present when [action] is [Action.Accept].
|
||||
* Contains values matching the requested schema, where keys correspond
|
||||
* to property names and values are primitives (string, number, or boolean).
|
||||
* Collects non-sensitive structured data from the user via a form presented by the client.
|
||||
*
|
||||
* @property message The message to present to the user describing what information is being requested.
|
||||
* @property task If specified, the caller is requesting task-augmented execution. The request
|
||||
* will return a [CreateTaskResult] immediately, and the actual result can be retrieved
|
||||
* later via `tasks/result`.
|
||||
* @property requestedSchema A restricted subset of JSON Schema. Only top-level properties
|
||||
* are allowed, without nesting.
|
||||
* @property meta Optional metadata. May include a progressToken for out-of-band progress notifications.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ElicitRequestFormParams(
|
||||
override val message: String,
|
||||
val task: TaskMetadata? = null,
|
||||
val requestedSchema: ElicitRequestParams.RequestedSchema,
|
||||
@SerialName("_meta")
|
||||
override val meta: RequestMeta? = null,
|
||||
) : ElicitRequestParams {
|
||||
@EncodeDefault
|
||||
public val mode: String = "form"
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents URL mode parameters for an `elicitation/create` request.
|
||||
*
|
||||
* Directs the user to an external URL for out-of-band interactions (e.g., OAuth flows,
|
||||
* payment processing, or entering sensitive credentials) that must not pass through the MCP client.
|
||||
*
|
||||
* @property message The message explaining why the interaction is needed.
|
||||
* @property elicitationId A unique identifier for this elicitation. The client MUST treat
|
||||
* this ID as an opaque value.
|
||||
* @property url The URL that the user should navigate to.
|
||||
* @property task If specified, the caller is requesting task-augmented execution. The request
|
||||
* will return a [CreateTaskResult] immediately, and the actual result can be retrieved
|
||||
* later via `tasks/result`.
|
||||
* @property meta Optional metadata. May include a progressToken for out-of-band progress notifications.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ElicitRequestURLParams(
|
||||
override val message: String,
|
||||
val elicitationId: String,
|
||||
val url: String,
|
||||
val task: TaskMetadata? = null,
|
||||
@SerialName("_meta")
|
||||
override val meta: RequestMeta? = null,
|
||||
) : ElicitRequestParams {
|
||||
@EncodeDefault
|
||||
public val mode: String = "url"
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the client's response to an [ElicitRequest].
|
||||
*
|
||||
* @property action The user action in response to the elicitation.
|
||||
* @property content The submitted form data, only present when [action] is [Action.Accept]
|
||||
* and mode was form. Contains values matching the requested schema. Omitted for
|
||||
* URL mode responses.
|
||||
* @property meta Optional metadata for this response.
|
||||
* @throws IllegalArgumentException if content is provided with a non-accept action.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ElicitResult(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.types
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Restricted schema definition for elicitation request properties.
|
||||
*
|
||||
* Supports primitive types (string, number, integer, boolean) and enum schemas
|
||||
* (single-select and multi-select). Only flat, top-level properties are allowed —
|
||||
* arbitrary nested objects or arrays are not supported.
|
||||
*
|
||||
* Implementations: [StringSchema], [NumberSchemaDefinition], [BooleanSchema], [EnumSchemaDefinition].
|
||||
*/
|
||||
@Serializable(with = PrimitiveSchemaDefinitionSerializer::class)
|
||||
public sealed interface PrimitiveSchemaDefinition
|
||||
|
||||
/**
|
||||
* Defines a string-typed property in an elicitation schema.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property minLength Minimum string length.
|
||||
* @property maxLength Maximum string length.
|
||||
* @property format Optional format constraint (e.g., email, URI, date).
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class StringSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val minLength: Int? = null,
|
||||
val maxLength: Int? = null,
|
||||
val format: StringSchemaFormat? = null,
|
||||
val default: String? = null,
|
||||
) : PrimitiveSchemaDefinition {
|
||||
@EncodeDefault
|
||||
public val type: String = "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported format constraints for [StringSchema].
|
||||
*/
|
||||
@Serializable
|
||||
public enum class StringSchemaFormat {
|
||||
@SerialName("email")
|
||||
Email,
|
||||
|
||||
@SerialName("uri")
|
||||
Uri,
|
||||
|
||||
@SerialName("date")
|
||||
Date,
|
||||
|
||||
@SerialName("date-time")
|
||||
DateTime,
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a numeric property in an elicitation schema.
|
||||
*
|
||||
* Implementations: [IntegerSchema] (`"integer"`), [DoubleSchema] (`"number"`).
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface NumberSchemaDefinition : PrimitiveSchemaDefinition
|
||||
|
||||
/**
|
||||
* Defines an integer-typed property in an elicitation schema.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property minimum Minimum allowed value.
|
||||
* @property maximum Maximum allowed value.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class IntegerSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val minimum: Int? = null,
|
||||
val maximum: Int? = null,
|
||||
val default: Int? = null,
|
||||
) : NumberSchemaDefinition {
|
||||
@EncodeDefault
|
||||
val type: String = "integer"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a floating-point number property in an elicitation schema.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property minimum Minimum allowed value.
|
||||
* @property maximum Maximum allowed value.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class DoubleSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val minimum: Double? = null,
|
||||
val maximum: Double? = null,
|
||||
val default: Double? = null,
|
||||
) : NumberSchemaDefinition {
|
||||
@EncodeDefault
|
||||
val type: String = "number"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a boolean-typed property in an elicitation schema.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class BooleanSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val default: Boolean? = null,
|
||||
) : PrimitiveSchemaDefinition {
|
||||
@EncodeDefault
|
||||
val type: String = "boolean"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines an enumeration property in an elicitation schema.
|
||||
*
|
||||
* Implementations: [SingleSelectEnumSchema], [MultiSelectEnumSchema], [LegacyTitledEnumSchema].
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface EnumSchemaDefinition : PrimitiveSchemaDefinition
|
||||
|
||||
/**
|
||||
* Represents an enum option with a value and a display label.
|
||||
*
|
||||
* Used in [TitledSingleSelectEnumSchema] and [TitledMultiSelectEnumSchema].
|
||||
*
|
||||
* @property const The enum value.
|
||||
* @property title Display label for this option.
|
||||
*/
|
||||
@Serializable
|
||||
public data class EnumOption(val const: String, val title: String)
|
||||
|
||||
/**
|
||||
* Defines a single-selection enumeration property.
|
||||
*
|
||||
* Implementations: [UntitledSingleSelectEnumSchema], [TitledSingleSelectEnumSchema].
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface SingleSelectEnumSchema : EnumSchemaDefinition
|
||||
|
||||
/**
|
||||
* Defines a single-selection enumeration without display titles for options.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property enumValues Array of enum values to choose from.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class UntitledSingleSelectEnumSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("enum")
|
||||
val enumValues: List<String>,
|
||||
val default: String? = null,
|
||||
) : SingleSelectEnumSchema {
|
||||
@EncodeDefault
|
||||
val type: String = "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a single-selection enumeration with display titles for each option.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property oneOf Array of enum options with values and display labels.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class TitledSingleSelectEnumSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val oneOf: List<EnumOption>,
|
||||
val default: String? = null,
|
||||
) : SingleSelectEnumSchema {
|
||||
@EncodeDefault
|
||||
val type: String = "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a single-selection enumeration with display names via the deprecated `enumNames` array.
|
||||
*
|
||||
* Use [TitledSingleSelectEnumSchema] instead. This class will be removed in a future version.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property enumValues Array of enum values to choose from.
|
||||
* @property enumNames Display names for enum values. Non-standard according to JSON Schema 2020-12.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Deprecated("Use TitledSingleSelectEnumSchema instead")
|
||||
@Serializable
|
||||
public data class LegacyTitledEnumSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("enum")
|
||||
val enumValues: List<String>,
|
||||
val enumNames: List<String>? = null,
|
||||
val default: String? = null,
|
||||
) : EnumSchemaDefinition {
|
||||
@EncodeDefault
|
||||
val type: String = "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a multiple-selection enumeration property.
|
||||
*
|
||||
* Implementations: [UntitledMultiSelectEnumSchema], [TitledMultiSelectEnumSchema].
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface MultiSelectEnumSchema : EnumSchemaDefinition
|
||||
|
||||
/**
|
||||
* Defines a multiple-selection enumeration without display titles for options.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property minItems Minimum number of items to select.
|
||||
* @property maxItems Maximum number of items to select.
|
||||
* @property items Schema for the array items.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class UntitledMultiSelectEnumSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val minItems: Int? = null,
|
||||
val maxItems: Int? = null,
|
||||
val items: Items,
|
||||
val default: List<String>? = null,
|
||||
) : MultiSelectEnumSchema {
|
||||
@EncodeDefault
|
||||
val type: String = "array"
|
||||
|
||||
/**
|
||||
* Schema for the array items with plain enum values.
|
||||
*
|
||||
* @property enumValues Array of enum values to choose from.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Items(
|
||||
@SerialName("enum")
|
||||
val enumValues: List<String>,
|
||||
) {
|
||||
@EncodeDefault
|
||||
val type: String = "string"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a multiple-selection enumeration with display titles for each option.
|
||||
*
|
||||
* @property title Optional display title for the field.
|
||||
* @property description Optional description for the field.
|
||||
* @property minItems Minimum number of items to select.
|
||||
* @property maxItems Maximum number of items to select.
|
||||
* @property items Schema for array items with enum options and display labels.
|
||||
* @property default Optional default value.
|
||||
*/
|
||||
@Serializable
|
||||
public data class TitledMultiSelectEnumSchema(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val minItems: Int? = null,
|
||||
val maxItems: Int? = null,
|
||||
val items: Items,
|
||||
val default: List<String>? = null,
|
||||
) : MultiSelectEnumSchema {
|
||||
@EncodeDefault
|
||||
val type: String = "array"
|
||||
|
||||
/**
|
||||
* Schema for array items with enum options and display labels.
|
||||
*
|
||||
* @property anyOf Array of enum options with values and display labels.
|
||||
*/
|
||||
@Serializable
|
||||
public data class Items(val anyOf: List<EnumOption>)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import kotlinx.serialization.encoding.Encoder
|
|||
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
|
@ -420,3 +421,80 @@ internal object RequestIdPolymorphicSerializer : JsonContentPolymorphicSerialize
|
|||
else -> throw SerializationException("Invalid RequestId type: $element")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ElicitationParams Serializers
|
||||
// ============================================================================
|
||||
|
||||
internal object ElicitRequestParamsSerializer : JsonContentPolymorphicSerializer<ElicitRequestParams>(
|
||||
ElicitRequestParams::class,
|
||||
) {
|
||||
@Suppress("ThrowsCount")
|
||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<ElicitRequestParams> {
|
||||
val mode = when (val modeElement = element.jsonObject["mode"]) {
|
||||
null -> null
|
||||
|
||||
is JsonPrimitive -> {
|
||||
if (!modeElement.isString) {
|
||||
throw SerializationException("Invalid 'mode' type: expected string but was: $modeElement")
|
||||
}
|
||||
modeElement.contentOrNull
|
||||
}
|
||||
|
||||
else -> throw SerializationException("Invalid 'mode' type: expected string but was: $modeElement")
|
||||
}
|
||||
return when (mode) {
|
||||
"url" -> ElicitRequestURLParams.serializer()
|
||||
"form", null -> ElicitRequestFormParams.serializer()
|
||||
else -> throw SerializationException("Unsupported elicitation mode: '$mode'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PrimitiveSchemaDefinition Serializers
|
||||
// ============================================================================
|
||||
|
||||
internal object PrimitiveSchemaDefinitionSerializer : JsonContentPolymorphicSerializer<PrimitiveSchemaDefinition>(
|
||||
PrimitiveSchemaDefinition::class,
|
||||
) {
|
||||
@Suppress("ThrowsCount")
|
||||
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<PrimitiveSchemaDefinition> {
|
||||
val obj = element.jsonObject
|
||||
val typeElement = obj["type"]
|
||||
?: throw SerializationException("Missing 'type' in PrimitiveSchemaDefinition: $element")
|
||||
val type = if (typeElement is JsonPrimitive && typeElement.isString) {
|
||||
typeElement.content
|
||||
} else {
|
||||
throw SerializationException("Expected 'type' to be a string but was: $typeElement")
|
||||
}
|
||||
return when (type) {
|
||||
"boolean" -> BooleanSchema.serializer()
|
||||
"integer" -> IntegerSchema.serializer()
|
||||
"number" -> DoubleSchema.serializer()
|
||||
"string" -> selectStringTypeDeserializer(obj)
|
||||
"array" -> selectArrayTypeDeserializer(obj)
|
||||
else -> throw SerializationException("Unknown PrimitiveSchemaDefinition type: '$type'")
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectArrayTypeDeserializer(
|
||||
obj: Map<String, JsonElement>,
|
||||
): DeserializationStrategy<PrimitiveSchemaDefinition> {
|
||||
val items = obj["items"]?.jsonObject
|
||||
return when {
|
||||
items != null && "anyOf" in items -> TitledMultiSelectEnumSchema.serializer()
|
||||
else -> UntitledMultiSelectEnumSchema.serializer()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun selectStringTypeDeserializer(
|
||||
obj: Map<String, JsonElement>,
|
||||
): DeserializationStrategy<PrimitiveSchemaDefinition> = when {
|
||||
"enumNames" in obj -> LegacyTitledEnumSchema.serializer()
|
||||
"enum" in obj -> UntitledSingleSelectEnumSchema.serializer()
|
||||
"oneOf" in obj -> TitledSingleSelectEnumSchema.serializer()
|
||||
else -> StringSchema.serializer()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +1,39 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.types
|
||||
|
||||
import io.kotest.assertions.json.shouldEqualJson
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.modelcontextprotocol.kotlin.test.utils.verifyDeserialization
|
||||
import io.modelcontextprotocol.kotlin.test.utils.verifySerialization
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
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.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ElicitationTest {
|
||||
|
||||
// ── Form mode ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@Suppress("LongMethod")
|
||||
fun `should serialize ElicitRequest with requested schema`() {
|
||||
fun `should serialize form mode ElicitRequest`() {
|
||||
val request = ElicitRequest(
|
||||
ElicitRequestParams(
|
||||
ElicitRequestFormParams(
|
||||
message = "Provide repository details",
|
||||
requestedSchema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "GitHub organization")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"repository",
|
||||
buildJsonObject {
|
||||
put("type", "string")
|
||||
put("title", "Repository name")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"private",
|
||||
buildJsonObject {
|
||||
put("type", "boolean")
|
||||
put("description", "Is the repository private?")
|
||||
},
|
||||
)
|
||||
},
|
||||
properties = mapOf(
|
||||
"owner" to StringSchema(description = "GitHub organization"),
|
||||
"repository" to StringSchema(title = "Repository name"),
|
||||
"private" to BooleanSchema(description = "Is the repository private?"),
|
||||
),
|
||||
required = listOf("owner", "repository"),
|
||||
),
|
||||
meta = RequestMeta(
|
||||
buildJsonObject {
|
||||
put("progressToken", "token-42")
|
||||
},
|
||||
buildJsonObject { put("progressToken", "token-42") },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -68,16 +49,16 @@ class ElicitationTest {
|
|||
"requestedSchema": {
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "GitHub organization"
|
||||
"description": "GitHub organization",
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"title": "Repository name"
|
||||
"title": "Repository name",
|
||||
"type": "string"
|
||||
},
|
||||
"private": {
|
||||
"type": "boolean",
|
||||
"description": "Is the repository private?"
|
||||
"description": "Is the repository private?",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["owner", "repository"],
|
||||
|
|
@ -85,7 +66,8 @@ class ElicitationTest {
|
|||
},
|
||||
"_meta": {
|
||||
"progressToken": "token-42"
|
||||
}
|
||||
},
|
||||
"mode": "form"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
|
|
@ -93,22 +75,16 @@ class ElicitationTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize ElicitRequest from JSON`() {
|
||||
fun `should deserialize form mode ElicitRequest with explicit mode`() {
|
||||
val json = """
|
||||
{
|
||||
"method": "elicitation/create",
|
||||
"params": {
|
||||
"message": "Tell us about the repository",
|
||||
"mode": "form",
|
||||
"message": "Enter your name",
|
||||
"requestedSchema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Repository name"
|
||||
},
|
||||
"stars": {
|
||||
"type": "number",
|
||||
"description": "GitHub stars"
|
||||
}
|
||||
"name": {"type": "string", "title": "Full name"}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
|
|
@ -117,27 +93,121 @@ class ElicitationTest {
|
|||
""".trimIndent()
|
||||
|
||||
val request = verifyDeserialization<ElicitRequest>(McpJson, json)
|
||||
assertEquals(Method.Defined.ElicitationCreate, request.method)
|
||||
request.method shouldBe Method.Defined.ElicitationCreate
|
||||
request.message shouldBe "Enter your name"
|
||||
|
||||
val params = request.params
|
||||
assertEquals("Tell us about the repository", params.message)
|
||||
assertNull(params.meta)
|
||||
|
||||
val schema = params.requestedSchema
|
||||
assertEquals("object", schema.type)
|
||||
assertNull(schema.required)
|
||||
|
||||
val nameDefinition = schema.properties["name"]?.jsonObject
|
||||
assertNotNull(nameDefinition)
|
||||
assertEquals("string", nameDefinition["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("Repository name", nameDefinition["title"]?.jsonPrimitive?.content)
|
||||
|
||||
val starsDefinition = schema.properties["stars"]?.jsonObject
|
||||
assertNotNull(starsDefinition)
|
||||
assertEquals("number", starsDefinition["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("GitHub stars", starsDefinition["description"]?.jsonPrimitive?.content)
|
||||
val params = request.params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
val nameSchema = params.requestedSchema.properties["name"].shouldBeInstanceOf<StringSchema>()
|
||||
nameSchema.title shouldBe "Full name"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize form mode ElicitRequest without mode field`() {
|
||||
val json = """
|
||||
{
|
||||
"method": "elicitation/create",
|
||||
"params": {
|
||||
"message": "Enter your name",
|
||||
"requestedSchema": {
|
||||
"properties": {
|
||||
"name": {"type": "string"}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = McpJson.decodeFromString<ElicitRequest>(json)
|
||||
request.params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
request.params.message shouldBe "Enter your name"
|
||||
}
|
||||
|
||||
// ── URL mode ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize URL mode ElicitRequest`() {
|
||||
val request = ElicitRequest(
|
||||
ElicitRequestURLParams(
|
||||
message = "Please provide your API key",
|
||||
elicitationId = "550e8400-e29b-41d4-a716-446655440000",
|
||||
url = "https://mcp.example.com/ui/set_api_key",
|
||||
),
|
||||
)
|
||||
|
||||
verifySerialization(
|
||||
request,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"method": "elicitation/create",
|
||||
"params": {
|
||||
"message": "Please provide your API key",
|
||||
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://mcp.example.com/ui/set_api_key",
|
||||
"mode": "url"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize URL mode ElicitRequest`() {
|
||||
val json = """
|
||||
{
|
||||
"method": "elicitation/create",
|
||||
"params": {
|
||||
"mode": "url",
|
||||
"message": "Authorize access",
|
||||
"elicitationId": "id-123",
|
||||
"url": "https://example.com/auth"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = verifyDeserialization<ElicitRequest>(McpJson, json)
|
||||
val params = request.params.shouldBeInstanceOf<ElicitRequestURLParams>()
|
||||
params.message shouldBe "Authorize access"
|
||||
params.elicitationId shouldBe "id-123"
|
||||
params.url shouldBe "https://example.com/auth"
|
||||
}
|
||||
|
||||
// ── Deprecated compat ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@Suppress("DEPRECATION")
|
||||
fun `deprecated factory function should create ElicitRequestFormParams`() {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = mapOf("name" to StringSchema()),
|
||||
)
|
||||
val params = ElicitRequestParams(message = "Test", requestedSchema = schema)
|
||||
params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
params.message shouldBe "Test"
|
||||
params.requestedSchema shouldBe schema
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("DEPRECATION")
|
||||
fun `deprecated requestedSchema should return schema for form mode`() {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = mapOf("x" to BooleanSchema()),
|
||||
)
|
||||
val request = ElicitRequest(ElicitRequestFormParams(message = "m", requestedSchema = schema))
|
||||
request.requestedSchema shouldBe schema
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("DEPRECATION")
|
||||
fun `deprecated requestedSchema should return null for URL mode`() {
|
||||
val request = ElicitRequest(
|
||||
ElicitRequestURLParams(message = "m", elicitationId = "id", url = "https://example.com"),
|
||||
)
|
||||
request.requestedSchema.shouldBeNull()
|
||||
}
|
||||
|
||||
// ── ElicitResult ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize and deserialize accept result with content`() {
|
||||
val result = ElicitResult(
|
||||
|
|
@ -169,17 +239,15 @@ class ElicitationTest {
|
|||
""".trimIndent()
|
||||
|
||||
val decoded = verifyDeserialization<ElicitResult>(McpJson, json)
|
||||
assertEquals(ElicitResult.Action.Accept, decoded.action)
|
||||
decoded.action shouldBe ElicitResult.Action.Accept
|
||||
|
||||
val content = decoded.content
|
||||
assertNotNull(content)
|
||||
assertEquals("kotlin-sdk", content["repository"]?.jsonPrimitive?.content)
|
||||
assertEquals(128, content["stars"]?.jsonPrimitive?.int)
|
||||
assertEquals(false, content["private"]?.jsonPrimitive?.boolean)
|
||||
val content = decoded.content.shouldNotBeNull()
|
||||
content["repository"]?.jsonPrimitive?.content shouldBe "kotlin-sdk"
|
||||
content["stars"]?.jsonPrimitive?.int shouldBe 128
|
||||
content["private"]?.jsonPrimitive?.boolean shouldBe false
|
||||
|
||||
val meta = decoded.meta
|
||||
assertNotNull(meta)
|
||||
assertEquals("2025-01-12T15:00:58Z", meta["submittedAt"]?.jsonPrimitive?.content)
|
||||
val meta = decoded.meta.shouldNotBeNull()
|
||||
meta["submittedAt"]?.jsonPrimitive?.content shouldBe "2025-01-12T15:00:58Z"
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -194,14 +262,14 @@ class ElicitationTest {
|
|||
""".trimIndent()
|
||||
|
||||
val result = verifyDeserialization<ElicitResult>(McpJson, json)
|
||||
assertEquals(ElicitResult.Action.Decline, result.action)
|
||||
assertNull(result.content)
|
||||
assertEquals("User skipped", result.meta?.get("reason")?.jsonPrimitive?.content)
|
||||
result.action shouldBe ElicitResult.Action.Decline
|
||||
result.content.shouldBeNull()
|
||||
result.meta?.get("reason")?.jsonPrimitive?.content shouldBe "User skipped"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should require content only for accept action`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
ElicitResult(
|
||||
action = ElicitResult.Action.Cancel,
|
||||
content = buildJsonObject { put("value", "ignored") },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.types
|
||||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
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 kotlin.test.Test
|
||||
|
||||
class PrimitiveSchemaTest {
|
||||
|
||||
// ── StringSchema ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize StringSchema with minimal fields`() {
|
||||
verifySerialization(
|
||||
StringSchema() as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""{"type": "string"}""",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize StringSchema with all fields`() {
|
||||
val schema = StringSchema(
|
||||
title = "Email address",
|
||||
description = "User email",
|
||||
minLength = 5,
|
||||
maxLength = 100,
|
||||
format = StringSchemaFormat.Email,
|
||||
default = "user@example.com",
|
||||
)
|
||||
|
||||
verifySerialization(
|
||||
schema as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Email address",
|
||||
"description": "User email",
|
||||
"minLength": 5,
|
||||
"maxLength": 100,
|
||||
"format": "email",
|
||||
"default": "user@example.com",
|
||||
"type": "string"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize StringSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "string",
|
||||
"title": "Name",
|
||||
"format": "uri"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<StringSchema>()
|
||||
schema.title shouldBe "Name"
|
||||
schema.format shouldBe StringSchemaFormat.Uri
|
||||
}
|
||||
|
||||
// ── StringSchemaFormat ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize all StringSchemaFormat values`() {
|
||||
val cases = mapOf(
|
||||
StringSchemaFormat.Email to "email",
|
||||
StringSchemaFormat.Uri to "uri",
|
||||
StringSchemaFormat.Date to "date",
|
||||
StringSchemaFormat.DateTime to "date-time",
|
||||
)
|
||||
for ((format, expectedValue) in cases) {
|
||||
val schema = StringSchema(format = format)
|
||||
val json = McpJson.encodeToString(schema)
|
||||
json shouldContain "\"format\":\"$expectedValue\""
|
||||
}
|
||||
}
|
||||
|
||||
// ── IntegerSchema ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize IntegerSchema with minimal fields`() {
|
||||
verifySerialization(
|
||||
IntegerSchema() as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""{"type": "integer"}""",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize IntegerSchema with all fields`() {
|
||||
verifySerialization(
|
||||
IntegerSchema(
|
||||
title = "Age",
|
||||
description = "User age",
|
||||
minimum = 0,
|
||||
maximum = 150,
|
||||
default = 25,
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Age",
|
||||
"description": "User age",
|
||||
"minimum": 0,
|
||||
"maximum": 150,
|
||||
"default": 25,
|
||||
"type": "integer"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize IntegerSchema from JSON`() {
|
||||
val json = """{"type": "integer", "minimum": 1, "maximum": 10}"""
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<IntegerSchema>()
|
||||
schema.minimum shouldBe 1
|
||||
schema.maximum shouldBe 10
|
||||
}
|
||||
|
||||
// ── DoubleSchema ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize DoubleSchema with minimal fields`() {
|
||||
verifySerialization(
|
||||
DoubleSchema() as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""{"type": "number"}""",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize DoubleSchema with all fields`() {
|
||||
verifySerialization(
|
||||
DoubleSchema(
|
||||
title = "Score",
|
||||
description = "Test score",
|
||||
minimum = 0.0,
|
||||
maximum = 100.0,
|
||||
default = 50.0,
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Score",
|
||||
"description": "Test score",
|
||||
"minimum": 0.0,
|
||||
"maximum": 100.0,
|
||||
"default": 50.0,
|
||||
"type": "number"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize DoubleSchema from JSON`() {
|
||||
val json = """{"type": "number", "minimum": 0.5}"""
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<DoubleSchema>()
|
||||
schema.minimum shouldBe 0.5
|
||||
}
|
||||
|
||||
// ── BooleanSchema ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize BooleanSchema with minimal fields`() {
|
||||
verifySerialization(
|
||||
BooleanSchema() as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""{"type": "boolean"}""",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize BooleanSchema with all fields`() {
|
||||
verifySerialization(
|
||||
BooleanSchema(
|
||||
title = "Agree",
|
||||
description = "Terms acceptance",
|
||||
default = false,
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Agree",
|
||||
"description": "Terms acceptance",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize BooleanSchema from JSON`() {
|
||||
val json = """{"type": "boolean", "default": true}"""
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<BooleanSchema>()
|
||||
schema.default shouldBe true
|
||||
}
|
||||
|
||||
// ── UntitledSingleSelectEnumSchema ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize UntitledSingleSelectEnumSchema`() {
|
||||
verifySerialization(
|
||||
UntitledSingleSelectEnumSchema(
|
||||
title = "Color",
|
||||
enumValues = listOf("Red", "Green", "Blue"),
|
||||
default = "Red",
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Color",
|
||||
"enum": ["Red", "Green", "Blue"],
|
||||
"default": "Red",
|
||||
"type": "string"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize UntitledSingleSelectEnumSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["A", "B", "C"]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<UntitledSingleSelectEnumSchema>()
|
||||
schema.enumValues shouldBe listOf("A", "B", "C")
|
||||
}
|
||||
|
||||
// ── TitledSingleSelectEnumSchema ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize TitledSingleSelectEnumSchema`() {
|
||||
verifySerialization(
|
||||
TitledSingleSelectEnumSchema(
|
||||
title = "Color",
|
||||
oneOf = listOf(
|
||||
EnumOption(const = "#FF0000", title = "Red"),
|
||||
EnumOption(const = "#00FF00", title = "Green"),
|
||||
),
|
||||
default = "#FF0000",
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Color",
|
||||
"oneOf": [
|
||||
{"const": "#FF0000", "title": "Red"},
|
||||
{"const": "#00FF00", "title": "Green"}
|
||||
],
|
||||
"default": "#FF0000",
|
||||
"type": "string"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize TitledSingleSelectEnumSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{"const": "a", "title": "Alpha"},
|
||||
{"const": "b", "title": "Beta"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<TitledSingleSelectEnumSchema>()
|
||||
schema.oneOf shouldHaveSize 2
|
||||
schema.oneOf[0].const shouldBe "a"
|
||||
schema.oneOf[0].title shouldBe "Alpha"
|
||||
}
|
||||
|
||||
// ── UntitledMultiSelectEnumSchema ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize UntitledMultiSelectEnumSchema`() {
|
||||
verifySerialization(
|
||||
UntitledMultiSelectEnumSchema(
|
||||
title = "Colors",
|
||||
minItems = 1,
|
||||
maxItems = 3,
|
||||
items = UntitledMultiSelectEnumSchema.Items(enumValues = listOf("Red", "Green", "Blue")),
|
||||
default = listOf("Red"),
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Colors",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"enum": ["Red", "Green", "Blue"],
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["Red"],
|
||||
"type": "array"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize UntitledMultiSelectEnumSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["X", "Y"]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<UntitledMultiSelectEnumSchema>()
|
||||
schema.items.enumValues shouldBe listOf("X", "Y")
|
||||
}
|
||||
|
||||
// ── TitledMultiSelectEnumSchema ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should serialize TitledMultiSelectEnumSchema`() {
|
||||
verifySerialization(
|
||||
TitledMultiSelectEnumSchema(
|
||||
title = "Colors",
|
||||
minItems = 1,
|
||||
maxItems = 2,
|
||||
items = TitledMultiSelectEnumSchema.Items(
|
||||
anyOf = listOf(
|
||||
EnumOption(const = "#FF0000", title = "Red"),
|
||||
EnumOption(const = "#00FF00", title = "Green"),
|
||||
),
|
||||
),
|
||||
default = listOf("#FF0000"),
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Colors",
|
||||
"minItems": 1,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"const": "#FF0000", "title": "Red"},
|
||||
{"const": "#00FF00", "title": "Green"}
|
||||
]
|
||||
},
|
||||
"default": ["#FF0000"],
|
||||
"type": "array"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should deserialize TitledMultiSelectEnumSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"const": "x", "title": "X"},
|
||||
{"const": "y", "title": "Y"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<TitledMultiSelectEnumSchema>()
|
||||
schema.items.anyOf shouldHaveSize 2
|
||||
schema.items.anyOf[0].const shouldBe "x"
|
||||
}
|
||||
|
||||
// ── LegacyTitledEnumSchema ────────────────────────────────────────────
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `should serialize LegacyTitledEnumSchema`() {
|
||||
verifySerialization(
|
||||
LegacyTitledEnumSchema(
|
||||
title = "Status",
|
||||
enumValues = listOf("opt1", "opt2", "opt3"),
|
||||
enumNames = listOf("Option One", "Option Two", "Option Three"),
|
||||
default = "opt1",
|
||||
) as PrimitiveSchemaDefinition,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"title": "Status",
|
||||
"enum": ["opt1", "opt2", "opt3"],
|
||||
"enumNames": ["Option One", "Option Two", "Option Three"],
|
||||
"default": "opt1",
|
||||
"type": "string"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Test
|
||||
fun `should deserialize LegacyTitledEnumSchema from JSON`() {
|
||||
val json = """
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["a", "b"],
|
||||
"enumNames": ["Alpha", "Beta"]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val schema = verifyDeserialization<PrimitiveSchemaDefinition>(McpJson, json)
|
||||
schema.shouldBeInstanceOf<LegacyTitledEnumSchema>()
|
||||
schema.enumValues shouldBe listOf("a", "b")
|
||||
schema.enumNames shouldBe listOf("Alpha", "Beta")
|
||||
}
|
||||
|
||||
// ── Error cases ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should throw on unknown type`() {
|
||||
val json = """{"type": "unknown"}"""
|
||||
shouldThrow<SerializationException> {
|
||||
McpJson.decodeFromString<PrimitiveSchemaDefinition>(json)
|
||||
}
|
||||
}
|
||||
|
||||
// ── RequestedSchema with typed properties ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `should round-trip RequestedSchema with mixed property types`() {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = mapOf(
|
||||
"name" to StringSchema(title = "Name"),
|
||||
"age" to IntegerSchema(minimum = 0),
|
||||
"confirmed" to BooleanSchema(default = false),
|
||||
"color" to UntitledSingleSelectEnumSchema(enumValues = listOf("Red", "Blue")),
|
||||
),
|
||||
required = listOf("name"),
|
||||
)
|
||||
|
||||
verifySerialization(
|
||||
schema,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"properties": {
|
||||
"name": {"title": "Name", "type": "string"},
|
||||
"age": {"minimum": 0, "type": "integer"},
|
||||
"confirmed": {"default": false, "type": "boolean"},
|
||||
"color": {"enum": ["Red", "Blue"], "type": "string"}
|
||||
},
|
||||
"required": ["name"],
|
||||
"type": "object"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,11 @@ package io.modelcontextprotocol.kotlin.sdk.types.dsl
|
|||
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.StringSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.buildElicitRequest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
|
@ -24,14 +27,15 @@ class ElicitationDslTest {
|
|||
}
|
||||
|
||||
request.params.message shouldBe "Provide info"
|
||||
request.params.requestedSchema.properties["email"] shouldBe buildJsonObject { put("type", "string") }
|
||||
request.params.requestedSchema.required shouldBe listOf("email")
|
||||
val formParams = request.params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
formParams.requestedSchema.properties["email"].shouldBeInstanceOf<StringSchema>()
|
||||
formParams.requestedSchema.required shouldBe listOf("email")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildElicitRequest should support direct requestedSchema`() {
|
||||
val schema = ElicitRequestParams.RequestedSchema(
|
||||
properties = buildJsonObject { put("name", buildJsonObject { put("type", "string") }) },
|
||||
properties = mapOf("name" to StringSchema()),
|
||||
required = listOf("name"),
|
||||
)
|
||||
val request = buildElicitRequest {
|
||||
|
|
@ -39,19 +43,20 @@ class ElicitationDslTest {
|
|||
requestedSchema(schema)
|
||||
}
|
||||
|
||||
request.params.requestedSchema shouldBe schema
|
||||
val formParams = request.params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
formParams.requestedSchema shouldBe schema
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ElicitRequestedSchemaBuilder should support direct properties assignment`() {
|
||||
val props = buildJsonObject { put("key", "value") }
|
||||
val request = buildElicitRequest {
|
||||
message = "Test"
|
||||
requestedSchema {
|
||||
properties(props)
|
||||
properties(buildJsonObject { put("key", buildJsonObject { put("type", "string") }) })
|
||||
}
|
||||
}
|
||||
request.params.requestedSchema.properties shouldBe props
|
||||
val formParams = request.params.shouldBeInstanceOf<ElicitRequestFormParams>()
|
||||
formParams.requestedSchema.properties["key"].shouldBeInstanceOf<StringSchema>()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Reference in a new issue