From 4d0fe6fcf75c447164f9bd13e6da6c415c611af0 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:04:18 +0100 Subject: [PATCH] fix(mcp): retain structured-only tool error details Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- .../main/kotlin/com/anthropic/mcp/BetaMcp.kt | 29 +++--- .../mcp/BetaMcpStructuredErrorTest.kt | 92 +++++++++++++++++++ 2 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 anthropic-java-mcp/src/test/kotlin/com/anthropic/mcp/BetaMcpStructuredErrorTest.kt diff --git a/anthropic-java-mcp/src/main/kotlin/com/anthropic/mcp/BetaMcp.kt b/anthropic-java-mcp/src/main/kotlin/com/anthropic/mcp/BetaMcp.kt index adaa1220f..40bebb021 100644 --- a/anthropic-java-mcp/src/main/kotlin/com/anthropic/mcp/BetaMcp.kt +++ b/anthropic-java-mcp/src/main/kotlin/com/anthropic/mcp/BetaMcp.kt @@ -268,24 +268,27 @@ object BetaMcp { private fun convertCallToolResult( result: McpSchema.CallToolResult ): BetaToolResultBlockParam.Content { + val structuredText = + if (result.content().isEmpty() && result.structuredContent() != null) { + try { + OBJECT_MAPPER.writeValueAsString(result.structuredContent()) + } catch (e: Exception) { + throw AnthropicInvalidDataException("Failed to serialize structuredContent", e) + } + } else null if (result.isError() == true) { val errorText = - result.content().joinToString("\n") { - when (it) { - is McpSchema.TextContent -> it.text() - else -> it.toString() + structuredText + ?: result.content().joinToString("\n") { + when (it) { + is McpSchema.TextContent -> it.text() + else -> it.toString() + } } - } throw AnthropicException(errorText) } - if (result.content().isEmpty() && result.structuredContent() != null) { - return try { - BetaToolResultBlockParam.Content.ofString( - OBJECT_MAPPER.writeValueAsString(result.structuredContent()) - ) - } catch (e: Exception) { - throw AnthropicInvalidDataException("Failed to serialize structuredContent", e) - } + if (structuredText != null) { + return BetaToolResultBlockParam.Content.ofString(structuredText) } return BetaToolResultBlockParam.Content.ofBlocks( result.content().map { contentParamToBlock(mcpContent(it)) } diff --git a/anthropic-java-mcp/src/test/kotlin/com/anthropic/mcp/BetaMcpStructuredErrorTest.kt b/anthropic-java-mcp/src/test/kotlin/com/anthropic/mcp/BetaMcpStructuredErrorTest.kt new file mode 100644 index 000000000..f7de1bf8e --- /dev/null +++ b/anthropic-java-mcp/src/test/kotlin/com/anthropic/mcp/BetaMcpStructuredErrorTest.kt @@ -0,0 +1,92 @@ +package com.anthropic.mcp + +import com.anthropic.errors.AnthropicException +import com.fasterxml.jackson.databind.ObjectMapper +import io.modelcontextprotocol.client.McpSyncClient +import io.modelcontextprotocol.spec.McpSchema +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class BetaMcpStructuredErrorTest { + private val client: McpSyncClient = mock() + private val mapper = ObjectMapper() + + private fun execute(result: McpSchema.CallToolResult): String { + whenever(client.callTool(any())).thenReturn(result) + val tool = + McpSchema.Tool( + "check", + null, + null, + McpSchema.JsonSchema("object", null, null, null, null, null), + null, + null, + null, + ) + return BetaMcp.mcpTool(tool, client).runner.apply("{}").string().orElseThrow() + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun structuredOnlyResultPreservesPayload(isError: Boolean) { + val payloads = + listOf( + emptyMap(), + mapOf( + "message" to "try again", + "retry" to false, + "attempt" to 0, + "nested" to mapOf("values" to listOf("café", null, 3)), + ), + ) + for (payload in payloads) { + val result = McpSchema.CallToolResult(emptyList(), isError, payload, null) + val text = + if (isError) { + val error = + org.junit.jupiter.api.assertThrows { execute(result) } + requireNotNull(error.message) + } else execute(result) + assertThat(mapper.readTree(text)).isEqualTo(mapper.valueToTree(payload)) + } + } + + @Test + fun existingTextErrorTakesPrecedenceOverStructuredContent() { + val result = + McpSchema.CallToolResult( + listOf(McpSchema.TextContent("first"), McpSchema.TextContent("second")), + true, + mapOf("ignored" to "detail"), + null, + ) + val error = org.junit.jupiter.api.assertThrows { execute(result) } + assertThat(error.message).isEqualTo("first\nsecond") + } + + @Test + fun errorWithoutEitherContentKeepsExistingEmptyMessage() { + val result = McpSchema.CallToolResult(emptyList(), true, null, null) + val error = org.junit.jupiter.api.assertThrows { execute(result) } + assertThat(error.message).isEmpty() + } + + @Test + fun structuredErrorSerializationFailureRetainsItsCause() { + val recursive = mutableMapOf() + recursive["self"] = recursive + val result = McpSchema.CallToolResult(emptyList(), true, recursive, null) + val error = + org.junit.jupiter.api.assertThrows { + execute(result) + } + assertThat(error) + .hasMessage("Failed to serialize structuredContent") + .hasCauseInstanceOf(com.fasterxml.jackson.core.JsonProcessingException::class.java) + } +}