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:
Pavel Gorgulov 2026-04-01 12:20:12 +02:00 • committed by GitHub
commit fdecf6bdf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1838 additions and 384 deletions

View file

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

View file

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

View file

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

View file

@ -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()
}
}

View file

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

View file

@ -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(),
)
}
}

View file

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