diff --git a/buildSrc/src/main/kotlin/mcp.dokka.gradle.kts b/buildSrc/src/main/kotlin/mcp.dokka.gradle.kts index 7ad493134..3db0a617c 100644 --- a/buildSrc/src/main/kotlin/mcp.dokka.gradle.kts +++ b/buildSrc/src/main/kotlin/mcp.dokka.gradle.kts @@ -31,6 +31,11 @@ dokka { packageListUrl("https://kotlinlang.org/api/kotlinx.coroutines/package-list") } + externalDocumentationLinks.register("kotlinx-schema") { + url("https://kotlin.github.io/kotlinx-schema/") + packageListUrl("https://kotlin.github.io/kotlinx-schema/package-list") + } + externalDocumentationLinks.register("kotlinx-serialization") { url("https://kotlinlang.org/api/kotlinx.serialization/") packageListUrl("https://kotlinlang.org/api/kotlinx.serialization/package-list") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 313c8f6a5..11c672298 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ collections-immutable = "0.4.0" coroutines = "1.10.2" kotest = "6.1.3" kotlinx-io = "0.8.2" +kotlinx-schema = "0.3.1" ktor = "3.2.3" logging = "7.0.14" mockk = "1.14.9" @@ -36,6 +37,8 @@ maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version # Kotlinx libraries kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "logging" } +kotlinx-schema-generator-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-schema-generator-json", version.ref = "kotlinx-schema" } +kotlinx-schema-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-schema-json", version.ref = "kotlinx-schema" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collections-immutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core-wasm = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core-wasm-js", version.ref = "coroutines" } diff --git a/kotlin-sdk-core/Module.md b/kotlin-sdk-core/Module.md index b4ed71b52..cc43f29a1 100644 --- a/kotlin-sdk-core/Module.md +++ b/kotlin-sdk-core/Module.md @@ -42,6 +42,78 @@ val request = CallToolRequest { val message: JSONRPCMessage = McpJson.decodeFromString(jsonString) ``` +## JSON Schema integration + +The module integrates with [kotlinx-schema](https://kotlin.github.io/kotlinx-schema/) to enable type-safe schema +generation from Kotlin data classes. Extension functions convert between kotlinx-schema types and MCP's [ToolSchema], +allowing you to define tool schemas using `@Description` annotations and other schema metadata. + +### Supported conversions + +- [JsonObject.asToolSchema][asToolSchema] — converts a raw JSON Schema object +- [JsonSchema.asToolSchema][asToolSchema] — converts kotlinx-schema's [JsonSchema] +- [FunctionCallingSchema.asToolSchema][asToolSchema] — extracts parameters from [FunctionCallingSchema] + +### Example: generating tool schema from a data class + +```kotlin +import kotlinx.schema.Description +import kotlinx.schema.generator.core.SchemaGeneratorService +import kotlinx.schema.json.JsonSchema + +data class SearchParams( + @property:Description("Search query") + val query: String, + @property:Description("Maximum number of results") + val limit: Int = 10, +) + +// Generate schema from data class +val generator = SchemaGeneratorService.getGenerator(KClass::class, JsonSchema::class) +val schema = generator.generateSchema(SearchParams::class) + +// Convert to MCP ToolSchema +val toolSchema = schema.asToolSchema() + +// Use in Tool definition +val tool = Tool( + name = "web-search", + description = "Search the web", + inputSchema = toolSchema, +) +``` + +### Example: using FunctionCallingSchema + +```kotlin +import kotlinx.schema.json.FunctionCallingSchema +import kotlinx.schema.json.ObjectPropertyDefinition +import kotlinx.schema.json.StringPropertyDefinition + +val functionSchema = FunctionCallingSchema( + name = "calculate", + description = "Perform a calculation", + parameters = ObjectPropertyDefinition( + properties = mapOf( + "expression" to StringPropertyDefinition( + description = "Mathematical expression to evaluate" + ), + ), + required = listOf("expression"), + ), +) + +val toolSchema = functionSchema.asToolSchema() +``` + +### Schema validation + +All conversion functions validate that the schema type is `"object"` (or omit validation if the type field is absent). +Only object schemas are supported for MCP tool input/output schemas. Attempting to convert array, string, or other +non-object schemas will throw [IllegalArgumentException]. + +--- + Use this module when you need the raw building blocks of MCP—types, JSON config, and transport base classes—whether to embed in another runtime, author new transports, or contribute higher-level features in the client/server modules. The APIs are explicit to keep the shared surface stable for downstream users. diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api index af22323e2..da603837b 100644 --- a/kotlin-sdk-core/api/kotlin-sdk-core.api +++ b/kotlin-sdk-core/api/kotlin-sdk-core.api @@ -4613,6 +4613,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/types/Tools_dslKt { public static final fun buildListToolsRequest (Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/types/ListToolsRequest; } +public final class io/modelcontextprotocol/kotlin/sdk/types/Tools_schemaKt { + public static final fun asToolSchema (Lkotlinx/schema/json/FunctionCallingSchema;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public static final fun asToolSchema (Lkotlinx/schema/json/JsonSchema;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; + public static final fun asToolSchema (Lkotlinx/serialization/json/JsonObject;)Lio/modelcontextprotocol/kotlin/sdk/types/ToolSchema; +} + public final class io/modelcontextprotocol/kotlin/sdk/types/UnknownResourceContents : io/modelcontextprotocol/kotlin/sdk/types/ResourceContents { public static final field Companion Lio/modelcontextprotocol/kotlin/sdk/types/UnknownResourceContents$Companion; public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V diff --git a/kotlin-sdk-core/build.gradle.kts b/kotlin-sdk-core/build.gradle.kts index 0e702c476..70f67575d 100644 --- a/kotlin-sdk-core/build.gradle.kts +++ b/kotlin-sdk-core/build.gradle.kts @@ -110,12 +110,13 @@ kotlin { commonMain { kotlin.srcDir(generateLibVersion) dependencies { - api(libs.kotlinx.serialization.json) + api(libs.kotlinx.collections.immutable) api(libs.kotlinx.coroutines.core) api(libs.kotlinx.io.core) - api(libs.kotlinx.collections.immutable) - implementation(libs.ktor.server.websockets) + api(libs.kotlinx.schema.json) + api(libs.kotlinx.serialization.json) implementation(libs.kotlin.logging) + implementation(libs.ktor.server.websockets) } } @@ -128,6 +129,7 @@ kotlin { jvmTest { dependencies { + implementation(libs.kotlinx.schema.generator.json) implementation(libs.junit.jupiter.params) implementation(libs.mockk) runtimeOnly(libs.slf4j.simple) diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.schema.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.schema.kt new file mode 100644 index 000000000..68648b492 --- /dev/null +++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/types/tools.schema.kt @@ -0,0 +1,99 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import kotlinx.schema.json.FunctionCallingSchema +import kotlinx.schema.json.JsonSchema +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Converts a JSON Schema [JsonObject] into a [ToolSchema] representation. + * + * Extracts `properties` and `required` fields from the JSON Schema object. The JSON object + * should conform to JSON Schema Draft 2020-12 specification for object types. + * + * Sample: + * ```kotlin + * val jsonSchema = buildJsonObject { + * put("type", "object") + * putJsonObject("properties") { + * putJsonObject("name") { + * put("type", "string") + * } + * } + * putJsonArray("required") { add("name") } + * } + * val toolSchema = jsonSchema.asToolSchema() + * ``` + * + * @return A [ToolSchema] instance with extracted schema metadata. + * @throws IllegalArgumentException if the schema type is specified but is not "object". Missing type field is accepted. + */ +public fun JsonObject.asToolSchema(): ToolSchema { + // Validate a schema type if present + val schemaType = this["type"]?.jsonPrimitive?.content + require(schemaType == null || schemaType == "object") { + "Only object schemas are supported for ToolSchema conversion, got: $schemaType" + } + + return ToolSchema( + properties = this["properties"]?.jsonObject, + required = this["required"]?.jsonArray?.map { it.jsonPrimitive.content }, + ) +} + +/** + * Converts a [JsonSchema] from kotlinx-schema into a [ToolSchema] representation. + * + * This is useful when generating schemas from Kotlin data classes using kotlinx-schema's + * schema generator. The schema is first serialized to JSON and then converted to [ToolSchema]. + * + * @return A [ToolSchema] instance with the schema's properties and required fields. + * @throws IllegalArgumentException if the schema type is not "object". + * @sample + * ```kotlin + * import kotlinx.schema.generator.core.SchemaGeneratorService + * import kotlinx.schema.Description + * + * @Serializable + * data class SearchParams( + * @property:Description("Search query") + * val query: String, + * val limit: Int = 10 + * ) + * + * val generator = SchemaGeneratorService.getGenerator(KClass::class, JsonSchema::class) + * val schema = generator.generateSchema(SearchParams::class) + * val toolSchema = schema.asToolSchema() + * ``` + */ +public fun JsonSchema.asToolSchema(): ToolSchema = McpJson.encodeToJsonElement(this) + .jsonObject.asToolSchema() + +/** + * Converts a [FunctionCallingSchema]'s parameters into a [ToolSchema] representation. + * + * Extracts and converts the `parameters` field from the function calling schema, + * which is typically used in OpenAI-compatible function calling APIs. + * + * @return A [ToolSchema] object containing the function's parameter schema. + * @throws IllegalArgumentException if the parameters schema type is not "object". + * @sample + * ```kotlin + * val functionSchema = FunctionCallingSchema( + * name = "search", + * description = "Search the web", + * parameters = ObjectPropertyDefinition( + * properties = mapOf( + * "query" to StringPropertyDefinition(description = "Search query") + * ), + * required = listOf("query") + * ) + * ) + * val toolSchema = functionSchema.asToolSchema() + * ``` + */ +public fun FunctionCallingSchema.asToolSchema(): ToolSchema = + McpJson.encodeToJsonElement(this.parameters).jsonObject.asToolSchema() diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/FunctionCallingSchemaAsToolSchemaTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/FunctionCallingSchemaAsToolSchemaTest.kt new file mode 100644 index 000000000..4afddbcb8 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/FunctionCallingSchemaAsToolSchemaTest.kt @@ -0,0 +1,141 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.schema.json.ArrayPropertyDefinition +import kotlinx.schema.json.FunctionCallingSchema +import kotlinx.schema.json.ObjectPropertyDefinition +import kotlinx.schema.json.StringPropertyDefinition +import kotlin.test.Test + +class FunctionCallingSchemaAsToolSchemaTest { + + @Test + fun `should convert function parameters`() { + val functionSchema = FunctionCallingSchema( + name = "calculate", + description = "Perform calculation", + parameters = ObjectPropertyDefinition( + properties = mapOf( + "operation" to StringPropertyDefinition( + description = "Math operation", + ), + "x" to StringPropertyDefinition(), + "y" to StringPropertyDefinition(), + ), + required = listOf("operation", "x", "y"), + ), + ) + + val toolSchema = functionSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf("operation", "x", "y") + } + toolSchema.required shouldContainExactly listOf("operation", "x", "y") + } + + @Test + fun `should handle required and optional parameters`() { + // Test with some required, some optional + val mixedSchema = FunctionCallingSchema( + name = "search", + parameters = ObjectPropertyDefinition( + properties = mapOf( + "query" to StringPropertyDefinition(description = "Search query"), + "limit" to StringPropertyDefinition(description = "Result limit"), + ), + required = listOf("query"), + ), + ) + + val mixedToolSchema = mixedSchema.asToolSchema() + mixedToolSchema.type shouldBe "object" + mixedToolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf("query", "limit") + } + mixedToolSchema.required shouldContainExactly listOf("query") + + // Test with all optional (empty required list) + val allOptionalSchema = FunctionCallingSchema( + name = "list", + parameters = ObjectPropertyDefinition( + properties = mapOf("filter" to StringPropertyDefinition()), + required = emptyList(), + ), + ) + + val allOptionalToolSchema = allOptionalSchema.asToolSchema() + allOptionalToolSchema.required.shouldNotBeNull { + this shouldContainExactly emptyList() + } + } + + @Test + fun `should handle complex nested parameter types`() { + val functionSchema = FunctionCallingSchema( + name = "process", + parameters = ObjectPropertyDefinition( + properties = mapOf( + "items" to ArrayPropertyDefinition( + items = StringPropertyDefinition(), + description = "List of items to process", + ), + "config" to ObjectPropertyDefinition( + properties = mapOf( + "timeout" to StringPropertyDefinition(description = "Timeout in seconds"), + "retries" to StringPropertyDefinition(description = "Number of retries"), + ), + required = listOf("timeout"), + ), + ), + required = listOf("items", "config"), + ), + ) + + val toolSchema = functionSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf("items", "config") + } + toolSchema.required shouldContainExactly listOf("items", "config") + } + + @Test + fun `should handle parameters count variations`() { + // Empty parameters + val emptySchema = FunctionCallingSchema( + name = "ping", + parameters = ObjectPropertyDefinition( + properties = emptyMap(), + required = emptyList(), + ), + ) + + val emptyToolSchema = emptySchema.asToolSchema() + emptyToolSchema.type shouldBe "object" + emptyToolSchema.properties.shouldNotBeNull { + keys shouldContainExactly emptySet() + } + + // Single parameter + val singleSchema = FunctionCallingSchema( + name = "greet", + parameters = ObjectPropertyDefinition( + properties = mapOf( + "name" to StringPropertyDefinition(description = "Name to greet"), + ), + required = listOf("name"), + ), + ) + + val singleToolSchema = singleSchema.asToolSchema() + singleToolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf("name") + } + singleToolSchema.required shouldContainExactly listOf("name") + } +} diff --git a/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonObjectAsToolSchemaTest.kt b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonObjectAsToolSchemaTest.kt new file mode 100644 index 000000000..202fb9e87 --- /dev/null +++ b/kotlin-sdk-core/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonObjectAsToolSchemaTest.kt @@ -0,0 +1,151 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import kotlin.test.Test + +class JsonObjectAsToolSchemaTest { + + @Test + fun `should convert valid object schema`() { + val jsonSchema = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("name") { + put("type", "string") + put("description", "User name") + } + putJsonObject("age") { + put("type", "integer") + } + } + putJsonArray("required") { + add("name") + } + } + + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf("name", "age") + } + toolSchema.required shouldContainExactly listOf("name") + } + + @Test + fun `should handle missing required field`() { + val jsonSchema = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("optional") { + put("type", "string") + } + } + } + + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull() + toolSchema.required.shouldBeNull() + } + + @Test + fun `should handle empty required array`() { + val jsonSchema = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("field1") { put("type", "string") } + } + putJsonArray("required") { } // Empty array - all fields optional + } + + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.required.shouldNotBeNull { + shouldBeEmpty() + } + } + + @Test + fun `should accept schema without type field`() { + val jsonSchema = buildJsonObject { + putJsonObject("properties") { + putJsonObject("name") { put("type", "string") } + } + putJsonArray("required") { add("name") } + } + + val toolSchema = jsonSchema.asToolSchema() + + // ToolSchema always has type="object" by default + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull() + toolSchema.required shouldContainExactly listOf("name") + } + + @Test + fun `should reject non-object schema types`() { + listOf("array", "string", "number", "boolean", "null").forEach { type -> + val invalidSchema = buildJsonObject { put("type", type) } + + shouldThrow { + invalidSchema.asToolSchema() + }.message shouldBe "Only object schemas are supported for ToolSchema conversion, got: $type" + } + } + + @Test + fun `should preserve nested schema structures`() { + val jsonSchema = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("config") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("enabled") { + put("type", "boolean") + } + putJsonObject("timeout") { + put("type", "integer") + } + } + putJsonArray("required") { + add("enabled") + } + } + } + putJsonArray("required") { + add("config") + } + } + + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull() + toolSchema.required shouldContainExactly listOf("config") + + val configProperty = toolSchema.properties["config"]?.jsonObject + configProperty.shouldNotBeNull { + get("type")?.jsonPrimitive?.content shouldBe "object" + get("properties")?.jsonObject.shouldNotBeNull { + keys shouldContainExactly setOf("enabled", "timeout") + } + } + } + +} diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonSchemaAsToolSchemaTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonSchemaAsToolSchemaTest.kt new file mode 100644 index 000000000..376500a62 --- /dev/null +++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/JsonSchemaAsToolSchemaTest.kt @@ -0,0 +1,89 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.schema.Description +import kotlinx.schema.generator.core.SchemaGeneratorService +import kotlinx.schema.json.JsonSchema +import kotlin.reflect.KClass +import kotlin.test.Test + +class JsonSchemaAsToolSchemaTest { + + private val schemaGenerator = requireNotNull( + SchemaGeneratorService.getGenerator(KClass::class, JsonSchema::class), + ) + + @Test + fun `should convert data class with annotations`() { + data class TestParams( + @property:Description("Test parameter") + val value: String, + ) + + val jsonSchema = schemaGenerator.generateSchema(TestParams::class) + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull() + toolSchema.required shouldContainExactly listOf("value") + + val tool = Tool( + name = "test", + inputSchema = toolSchema, + ) + val json = McpJson.encodeToString(tool) + + json shouldEqualJson """ + { + "name": "test", + "inputSchema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Test parameter" + } + }, + "required": ["value"] + } + } + """.trimIndent() + } + + @Test + fun `should handle complex types and optional fields`() { + data class ComplexParams( + val stringField: String, + val intField: Int, + val boolField: Boolean, + val optionalField: String? = null, + val listField: List, + ) + + val jsonSchema = schemaGenerator.generateSchema(ComplexParams::class) + val toolSchema = jsonSchema.asToolSchema() + + toolSchema.type shouldBe "object" + toolSchema.properties.shouldNotBeNull { + keys shouldContainExactly setOf( + "stringField", + "intField", + "boolField", + "optionalField", + "listField", + ) + } + + toolSchema.required.shouldNotBeNull { + this shouldContainExactly listOf( + "stringField", + "intField", + "boolField", + "listField", + ) + } + } +} diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolSchemaTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolSchemaTest.kt new file mode 100644 index 000000000..72bdc9f3b --- /dev/null +++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/types/ToolSchemaTest.kt @@ -0,0 +1,104 @@ +package io.modelcontextprotocol.kotlin.sdk.types + +import io.kotest.assertions.json.shouldEqualJson +import kotlinx.schema.Description +import kotlinx.schema.generator.core.SchemaGeneratorService +import kotlinx.schema.json.JsonSchema +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.reflect.KClass +import kotlin.test.Test + +/** + * Integration test for Tool schema functionality. + * + * Specific tests for individual extension functions are in: + * - [JsonObjectAsToolSchemaTest] + * - [JsonSchemaAsToolSchemaTest] + * - [FunctionCallingSchemaAsToolSchemaTest] + */ +class ToolSchemaTest { + + private val schemaGenerator = requireNotNull( + SchemaGeneratorService.getGenerator(KClass::class, JsonSchema::class), + ) + + data class SearchRequest( + @property:Description("Search query") + val query: String, + ) + + data class SearchResult(val results: List) + + @Test + fun `should serialize Tool with annotations and schemas`() { + val searchRequestSchema = schemaGenerator.generateSchema(SearchRequest::class) + val searchResultSchema = schemaGenerator.generateSchema(SearchResult::class) + + val inputSchema = searchRequestSchema.asToolSchema() + + val outputSchema = searchResultSchema.asToolSchema() + + val tool = Tool( + name = "web-search", + inputSchema = inputSchema, + description = "Search the web for information", + outputSchema = outputSchema, + title = "Web Search", + annotations = ToolAnnotations( + title = "Web Search (Preferred)", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = true, + ), + icons = listOf(Icon(src = "https://example.com/search.png")), + meta = buildJsonObject { put("category", "search") }, + ) + + val json = McpJson.encodeToString(tool) + + json shouldEqualJson """ + { + "name": "web-search", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": ["query"] + }, + "description": "Search the web for information", + "outputSchema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["results"] + }, + "title": "Web Search", + "annotations": { + "title": "Web Search (Preferred)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "icons": [ + {"src": "https://example.com/search.png"} + ], + "_meta": { + "category": "search" + } + } + """.trimIndent() + } +}