IJPL-230494 [MCP Server] Fix output schema generation for optional properties

(cherry picked from commit 65a37e82ef552a95515dbeedececbeb0e2696d38)

IJ-MR-188464

Space-RevId: 78cc3d1451493ac2d4bd905157d255b5e356ac48

GitOrigin-RevId: 28fa1bf89d4ac06fffe9a0cf99470b56792af3aa
This commit is contained in:
Artem.Bukhonov
2026-01-20 15:20:21 +00:00
committed by intellij-monorepo-bot
parent f3ad3193e0
commit fa75590de3
2 changed files with 241 additions and 4 deletions
@@ -19,6 +19,7 @@ import io.github.smiley4.schemakenerator.jsonschema.jsonDsl.JsonArray
import io.github.smiley4.schemakenerator.jsonschema.jsonDsl.JsonObject
import io.github.smiley4.schemakenerator.serialization.SerializationSteps.analyzeTypeUsingKotlinxSerialization
import io.github.smiley4.schemakenerator.serialization.analyzer.AnnotationAnalyzer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.*
import kotlinx.serialization.serializerOrNull
import kotlin.reflect.KAnnotatedElement
@@ -86,7 +87,7 @@ fun KCallable<*>.returnTypeSchema(): McpToolSchema? {
if (type.isSubtypeOf(typeOf<Enum<*>>())) return null
if (type.isSubtypeOf(typeOf<McpToolCallResult>())) return null
if (type.isSubtypeOf(typeOf<McpToolCallResultContent>())) return null
if (serializerOrNull(type) == null) return null
val serializer = serializerOrNull(type) ?: return null
val intermediateJsonSchemaData = initial(type)
.analyzeTypeUsingKotlinxSerialization()
@@ -99,7 +100,9 @@ fun KCallable<*>.returnTypeSchema(): McpToolSchema? {
val jsonSchema = schema.json.toKt() as? kotlinx.serialization.json.JsonObject ?: error("Non-primitive type is expected in return type: ${type.classifier} in $this")
val properties = jsonSchema["properties"] as? kotlinx.serialization.json.JsonObject ?: error("Properties are expected in return type: ${type.classifier} in $this")
val required = jsonSchema["required"] as? kotlinx.serialization.json.JsonArray ?: error("Required is expected in return type: ${type.classifier} in $this")
return McpToolSchema.ofPropertiesSchema(properties = properties, requiredProperties = required.map { it.jsonPrimitive.content }.toSet(), definitions = emptyMap(), definitionsPath = McpToolSchema.DEFAULT_DEFINITIONS_PATH)
val requiredProperties = required.map { it.jsonPrimitive.content }.toSet()
val adjustedRequired = removeRequiredForDefaultValues(requiredProperties, serializer)
return McpToolSchema.ofPropertiesSchema(properties = properties, requiredProperties = adjustedRequired, definitions = emptyMap(), definitionsPath = McpToolSchema.DEFAULT_DEFINITIONS_PATH)
}
private fun JsonNode.toKt(): JsonElement {
@@ -144,6 +147,24 @@ private fun IntermediateJsonSchemaData.removeNumericBounds(): IntermediateJsonSc
return this
}
// to mark properties as optional when they have default values
// fixes problem with EncodeDefault.Never case
// see https://youtrack.jetbrains.com/issue/IJPL-230494
private fun removeRequiredForDefaultValues(requiredProperties: Set<String>, serializer: KSerializer<*>): Set<String> {
val result = mutableSetOf(*requiredProperties.toTypedArray())
val descriptor = serializer.descriptor
if (descriptor.elementsCount == 0) return result
for (i in 0 until descriptor.elementsCount) {
if (descriptor.isElementOptional(i)) {
result.remove(descriptor.getElementName(i))
}
}
return result
}
private const val descriptionPropertyNameInschema = "description"
private class JsonSchemaCoreAnnotationMcpDescriptionStep() {
@@ -1,12 +1,45 @@
package com.intellij.mcpserver
import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.impl.util.asTool
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.Serializable
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import kotlin.reflect.KFunction
@Serializable
class Object2(
@property:McpDescription("Int field") val intField: Int,
@property:McpDescription("Int nullable") val intNullable: Int? = null,
@property:McpDescription("String field") val stringField: String,
@property:McpDescription("String nullable") val stringNullable: String? = null,
@property:McpDescription("Boolean field") val booleanField: Boolean,
@property:McpDescription("Boolean nullable") val booleanNullable: Boolean? = null,
@property:McpDescription("Double field") val doubleField: Double,
@property:McpDescription("Double nullable") val doubleNullable: Double? = null,
@property:McpDescription("List field") val listField: List<String>,
@property:McpDescription("List nullable") val listNullable: List<String>? = null,
@property:McpDescription("Nested field") val nestedField: Inner,
@property:McpDescription("Nested nullable") val nestedNullable: Inner? = null,
@property:McpDescription("Enum field") val enumField: Kind,
@property:McpDescription("Enum nullable") val enumNullable: Kind? = null,
@property:McpDescription("EncodeDefault ALWAYS") @EncodeDefault(EncodeDefault.Mode.ALWAYS) val encodeDefaultAlways: String = "default",
@property:McpDescription("EncodeDefault NEVER") @EncodeDefault(EncodeDefault.Mode.NEVER) val encodeDefaultNever: String = "default",
@property:McpDescription("EncodeDefault ALWAYS nullable") @EncodeDefault(EncodeDefault.Mode.ALWAYS) val encodeDefaultAlwaysNullable: String? = null,
@property:McpDescription("EncodeDefault NEVER nullable") @EncodeDefault(EncodeDefault.Mode.NEVER) val encodeDefaultNeverNullable: String? = null,
@property:McpDescription("Default value") val defaultValue: Int = 42,
@property:McpDescription("Bool with default false") val boolDefaultFalse: Boolean = false,
@property:McpDescription("Bool with default true") val boolDefaultTrue: Boolean = true,
@property:McpDescription("Bool EncodeDefault NEVER with default false") @EncodeDefault(EncodeDefault.Mode.NEVER) val boolEncodeNeverDefaultFalse: Boolean = false,
@property:McpDescription("Bool EncodeDefault NEVER with default true") @EncodeDefault(EncodeDefault.Mode.NEVER) val boolEncodeNeverDefaultTrue: Boolean = true,
@property:McpDescription("Bool EncodeDefault ALWAYS with default false") @EncodeDefault(EncodeDefault.Mode.ALWAYS) val boolEncodeAlwaysDefaultFalse: Boolean = false,
@property:McpDescription("Bool EncodeDefault ALWAYS with default true") @EncodeDefault(EncodeDefault.Mode.ALWAYS) val boolEncodeAlwaysDefaultTrue: Boolean = true,
@property:McpDescription("Bool nullable EncodeDefault NEVER") @EncodeDefault(EncodeDefault.Mode.NEVER) val boolNullableEncodeNever: Boolean? = false,
@property:McpDescription("Bool nullable EncodeDefault ALWAYS") @EncodeDefault(EncodeDefault.Mode.ALWAYS) val boolNullableEncodeAlways: Boolean? = null,
)
fun output_fun1(): Object { TODO() }
fun output_fun2(): String { TODO() }
@@ -19,6 +52,7 @@ fun output_fun8(): Char { TODO() }
fun output_fun9(): McpToolCallResult { TODO() }
fun output_fun10(): McpToolCallResultContent { TODO() }
fun output_fun11(): Kind { TODO() }
fun output_fun12(): Object2 { TODO() }
class OutputSchemaTest {
companion object {
@@ -94,8 +128,7 @@ class OutputSchemaTest {
"b",
"b1",
"c",
"d",
"eEncodeDefault"
"d"
],
"additionalProperties": false
}"""),
@@ -109,6 +142,189 @@ class OutputSchemaTest {
Arguments.of(::output_fun9, null),
Arguments.of(::output_fun10, null),
Arguments.of(::output_fun11, null),
Arguments.of(::output_fun12, /**language=JSON*/ """{
"type": "object",
"properties": {
"intField": {
"type": "integer",
"description": "Int field"
},
"intNullable": {
"type": [
"integer",
"null"
],
"description": "Int nullable"
},
"stringField": {
"type": "string",
"description": "String field"
},
"stringNullable": {
"type": [
"string",
"null"
],
"description": "String nullable"
},
"booleanField": {
"type": "boolean",
"description": "Boolean field"
},
"booleanNullable": {
"type": [
"boolean",
"null"
],
"description": "Boolean nullable"
},
"doubleField": {
"type": "number",
"description": "Double field"
},
"doubleNullable": {
"type": [
"number",
"null"
],
"description": "Double nullable"
},
"listField": {
"type": "array",
"items": {
"type": "string"
},
"description": "List field"
},
"listNullable": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "List nullable"
},
"nestedField": {
"type": "object",
"required": [
"e"
],
"properties": {
"e": {
"type": "number"
}
},
"description": "Nested field"
},
"nestedNullable": {
"type": [
"object",
"null"
],
"required": [
"e"
],
"properties": {
"e": {
"type": "number"
}
},
"description": "Nested nullable"
},
"enumField": {
"enum": [
"K1",
"K2",
"K3"
],
"description": "Enum field"
},
"enumNullable": {
"enum": [
"K1",
"K2",
"K3"
],
"description": "Enum nullable"
},
"encodeDefaultAlways": {
"type": "string",
"description": "EncodeDefault ALWAYS"
},
"encodeDefaultNever": {
"type": "string",
"description": "EncodeDefault NEVER"
},
"encodeDefaultAlwaysNullable": {
"type": [
"string",
"null"
],
"description": "EncodeDefault ALWAYS nullable"
},
"encodeDefaultNeverNullable": {
"type": [
"string",
"null"
],
"description": "EncodeDefault NEVER nullable"
},
"defaultValue": {
"type": "integer",
"description": "Default value"
},
"boolDefaultFalse": {
"type": "boolean",
"description": "Bool with default false"
},
"boolDefaultTrue": {
"type": "boolean",
"description": "Bool with default true"
},
"boolEncodeNeverDefaultFalse": {
"type": "boolean",
"description": "Bool EncodeDefault NEVER with default false"
},
"boolEncodeNeverDefaultTrue": {
"type": "boolean",
"description": "Bool EncodeDefault NEVER with default true"
},
"boolEncodeAlwaysDefaultFalse": {
"type": "boolean",
"description": "Bool EncodeDefault ALWAYS with default false"
},
"boolEncodeAlwaysDefaultTrue": {
"type": "boolean",
"description": "Bool EncodeDefault ALWAYS with default true"
},
"boolNullableEncodeNever": {
"type": [
"boolean",
"null"
],
"description": "Bool nullable EncodeDefault NEVER"
},
"boolNullableEncodeAlways": {
"type": [
"boolean",
"null"
],
"description": "Bool nullable EncodeDefault ALWAYS"
}
},
"required": [
"intField",
"stringField",
"booleanField",
"doubleField",
"listField",
"nestedField",
"enumField"
],
"additionalProperties": false
}"""),
)
}
}