diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/impl/util/schema.util.kt b/plugins/mcp-server/src/com/intellij/mcpserver/impl/util/schema.util.kt index a345b37919c9..4d1f1be85344 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/impl/util/schema.util.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/impl/util/schema.util.kt @@ -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>())) return null if (type.isSubtypeOf(typeOf())) return null if (type.isSubtypeOf(typeOf())) 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, serializer: KSerializer<*>): Set { + 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() { diff --git a/plugins/mcp-server/test/com/intellij/mcpserver/OutputSchemaTest.kt b/plugins/mcp-server/test/com/intellij/mcpserver/OutputSchemaTest.kt index fb1a26deb12f..5a08713ec34a 100644 --- a/plugins/mcp-server/test/com/intellij/mcpserver/OutputSchemaTest.kt +++ b/plugins/mcp-server/test/com/intellij/mcpserver/OutputSchemaTest.kt @@ -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, + @property:McpDescription("List nullable") val listNullable: List? = 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 +}"""), ) } }