From 54129c1f90c12a99b7d46a01a282ad05e719ce1b Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 5 Feb 2026 14:14:06 +0000 Subject: [PATCH] Fix JSON Schema type arrays for nullable fields Claude API rejects JSON Schema with type arrays like ["integer", "null"] for optional fields. Changed markOptionsAsNullable to false so optional fields use simple types with optionality conveyed through the required array instead. Fixes #92 Co-Authored-By: Claude Opus 4.5 --- core/src/main/scala/chimp/McpHandler.scala | 2 +- .../src/test/scala/chimp/McpHandlerSpec.scala | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/core/src/main/scala/chimp/McpHandler.scala b/core/src/main/scala/chimp/McpHandler.scala index 3882d61..1406ebb 100644 --- a/core/src/main/scala/chimp/McpHandler.scala +++ b/core/src/main/scala/chimp/McpHandler.scala @@ -55,7 +55,7 @@ class McpHandler[F[_]]( /** Converts a ServerTool to its protocol definition. */ private def toolToDefinition(tool: ServerTool[?, F]): ToolDefinition = val jsonSchema = - val base = TapirSchemaToJsonSchema(tool.inputSchema, markOptionsAsNullable = true) + val base = TapirSchemaToJsonSchema(tool.inputSchema, markOptionsAsNullable = false) if showJsonSchemaMetadata then base else base.copy($schema = None) diff --git a/core/src/test/scala/chimp/McpHandlerSpec.scala b/core/src/test/scala/chimp/McpHandlerSpec.scala index 37bc7d6..2e9fa98 100644 --- a/core/src/test/scala/chimp/McpHandlerSpec.scala +++ b/core/src/test/scala/chimp/McpHandlerSpec.scala @@ -416,6 +416,51 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: resultObj.content.head shouldBe ToolContent.Text("text", "no header") case _ => fail("Expected Response") + it should "not use type arrays for optional fields in JSON schema" in: + // Given - a tool with optional fields + case class OptionalFieldInput(requiredField: String, optionalField: Option[Long]) derives Schema, Codec + val optionalTool = tool("optionalTest") + .description("Test tool with optional fields.") + .input[OptionalFieldInput] + .handle(_ => Right("ok")) + + val handlerWithOptional = McpHandler(List(optionalTool), "Test", "1.0.0", true) + + val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("opt1")) + val json = req.asJson + // When + val response = handlerWithOptional.handleJsonRpc(json, Seq.empty) + val respJson = extractJsonFromResponse(response) + val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) + // Then + resp match + case Response(_, _, result) => + val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) + val toolDef = resultObj.tools.find(_.name == "optionalTest").get + val inputSchema = toolDef.inputSchema + + // Check that optionalField does NOT use ["integer", "null"] type array + // Claude API rejects this format - it should just be "integer" with the field not in required + val optionalFieldType = inputSchema.hcursor + .downField("properties") + .downField("optionalField") + .downField("type") + .focus + + optionalFieldType match + case Some(typeValue) => + // Should be a simple string "integer", not an array ["integer", "null"] + typeValue.isString shouldBe true + typeValue.asString.get shouldBe "integer" + case None => + fail("optionalField type not found in schema") + + // Verify requiredField is in required array but optionalField is not + val requiredFields = inputSchema.hcursor.downField("required").as[List[String]].getOrElse(Nil) + requiredFields should contain("requiredField") + requiredFields should not contain "optionalField" + case _ => fail("Expected Response") + it should "handle batch requests with mixed headers" in: // Given val req1 = Request(