diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandResult.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandResult.java index de4ef0665b..ebc27f04eb 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandResult.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/CommandResult.java @@ -1,7 +1,10 @@ package io.stargate.sgv2.jsonapi.api.model.command; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkiverse.mcp.server.MetaKey; +import io.quarkiverse.mcp.server.TextContent; import io.quarkiverse.mcp.server.ToolResponse; import io.stargate.sgv2.jsonapi.api.model.command.tracing.RequestTracing; import jakarta.ws.rs.core.Response; @@ -101,18 +104,21 @@ public RestResponse toRestResponse() { /** * Create a {@link ToolResponse} from this {@link CommandResult} for MCP tool responses. * - *

Mapping rules: - * - *

+ *

Text content contains the complete Data API response. Structured content and status metadata + * retain their existing shapes for clients that consume them. * + * @param objectMapper the configured Data API response mapper * @return A new {@link ToolResponse} representing this command result. */ - public ToolResponse toToolResponse() { + public ToolResponse toToolResponse(ObjectMapper objectMapper) { + + final String text; + try { + text = objectMapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + // Neither the partial response nor the exception message is safe to return here. + return ToolResponse.error("Unable to serialize the command result."); + } boolean hasErrors = errors != null && !errors.isEmpty(); @@ -120,10 +126,11 @@ public ToolResponse toToolResponse() { Map meta = (status != null && !status.isEmpty()) ? Map.of(MetaKey.of("status"), status) : Map.of(); - // Map "errors" or "data" to structuredContent - // Also, structuredContent is expected to be a Record (a plain JSON object {}) return new ToolResponse( - hasErrors, List.of(), hasErrors ? Map.of("errors", errors) : data, meta); + hasErrors, + List.of(new TextContent(text)), + hasErrors ? Map.of("errors", errors) : data, + meta); } /** diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java index cb66862762..168d979942 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResource.java @@ -165,7 +165,7 @@ public Uni processCollectionCommand( CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) .addThrowable(throwable) .build(); - return Uni.createFrom().item(errorResult.toToolResponse()); + return Uni.createFrom().item(errorResult.toToolResponse(objectMapper)); } else { VectorColumnDefinition vectorColDef = null; if (schemaObject.type() == SchemaObjectType.COLLECTION) { @@ -211,7 +211,8 @@ public Uni processCollectionCommand( } /** - * Process a command and map its result to an MCP tool response. + * Process a command and map its result to an MCP tool response with readable text and structured + * content. * * @param contextUni The command context * @param command The command to execute @@ -221,7 +222,7 @@ public Uni processCommand(Uni> contextUni, Comma return contextUni .flatMap(context -> executeCommand(context, command)) - .map(CommandResult::toToolResponse); + .map(result -> result.toToolResponse(objectMapper)); } private Uni executeCommand(CommandContext context, Command command) { diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java index 03a16ae209..a2f5067eac 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpIntegrationTestBase.java @@ -142,18 +142,18 @@ protected void dropTable(String keyspace, String table) { /** * Assert that the response is a successful status-only (ok:1) response with no error, no - * structured content, and no text content. + * structured content, and a readable Data API response. */ protected Consumer assertStatusOnlyOk() { return response -> { assertFalse(response.isError()); assertNotNull(response._meta()); assertNull(response.structuredContent()); - assertThat(response.content()).isEmpty(); var status = (JsonObject) response._meta().get(MetaKey.of("status")); assertNotNull(status, "Status should not be null"); assertEquals(1, status.getInteger("ok"), "Status should contain ok:1"); + assertThat(readableResult(response)).isEqualTo(new JsonObject().put("status", status)); }; } @@ -168,11 +168,11 @@ protected Consumer assertStatusOnlyWithJson(Consumer s assertFalse(response.isError()); assertNotNull(response._meta()); assertNull(response.structuredContent()); - assertThat(response.content()).isEmpty(); var status = (JsonObject) response._meta().get(MetaKey.of("status")); assertNotNull(status, "Status should not be null"); statusAssertions.accept(status); + assertThat(readableResult(response)).isEqualTo(new JsonObject().put("status", status)); }; } @@ -186,14 +186,13 @@ private static void assertServerInfoOnly(ToolResponse response) { } /** - * Assert that an error response has server metadata and an empty content field. Then apply - * additional assertions to the error JsonArray extracted from structuredContent. + * Assert that an error response has server metadata and readable content. Then apply additional + * assertions to the error JsonArray extracted from structuredContent. * * @param errorsAssertions additional assertions to run on the error JsonArray */ protected Consumer assertErrorOnly(Consumer errorsAssertions) { return response -> { - assertThat(response.content()).isEmpty(); assertServerInfoOnly(response); assertTrue(response.isError()); assertThat(response.structuredContent()).isNotNull(); @@ -203,30 +202,33 @@ protected Consumer assertErrorOnly(Consumer errorsAsser var errorsArray = errors.getJsonArray("errors"); assertThat(errorsArray).isNotEmpty(); errorsAssertions.accept(errorsArray); + assertThat(readableResult(response)).isEqualTo(errors); }; } /** - * Assert that a data response has server metadata, an empty content field, and no error. Then - * apply additional assertions to the data held in structuredContent. + * Assert that a data response has server metadata, readable content, and no error. Then apply + * additional assertions to the data held in structuredContent. * * @param dataAssertions additional assertions to run on the data JsonObject */ protected Consumer assertDataOnly(Consumer dataAssertions) { return response -> { assertFalse(response.isError()); - assertThat(response.content()).isEmpty(); assertServerInfoOnly(response); assertNotNull(response.structuredContent()); var data = (JsonObject) response.structuredContent(); dataAssertions.accept(data); + var readable = readableResult(response); + assertThat(readable.fieldNames()).containsExactly("data"); + dataAssertions.accept(readable.getJsonObject("data")); }; } /** - * Assert data and status response is valid (no content and error), then apply additional - * assertions on the structuredContent holds the data and meta_ contains the status. + * Assert data and status response is valid with readable content and no error, then apply + * additional assertions on the structuredContent holds the data and meta_ contains the status. * * @param dataAssertions additional assertions to run on the data JsonObject * @param statusAssertions additional assertions to run on the status JsonObject @@ -235,7 +237,6 @@ protected Consumer assertDataAndStatus( Consumer dataAssertions, Consumer statusAssertions) { return response -> { assertFalse(response.isError()); - assertThat(response.content()).isEmpty(); assertNotNull(response._meta()); assertNotNull(response.structuredContent()); @@ -243,9 +244,19 @@ protected Consumer assertDataAndStatus( dataAssertions.accept(data); var status = (JsonObject) response._meta().get(MetaKey.of("status")); statusAssertions.accept(status); + var readable = readableResult(response); + assertThat(readable.fieldNames()).containsExactlyInAnyOrder("data", "status"); + // The Data API and MCP encoders format float vectors differently. Check the data behavior. + dataAssertions.accept(readable.getJsonObject("data")); + assertThat(readable.getJsonObject("status")).isEqualTo(status); }; } + private static JsonObject readableResult(ToolResponse response) { + assertThat(response.content()).hasSize(1); + return new JsonObject(response.firstContent().asText().text()); + } + /** * Execute an MCP tool call and assert the response using the shared client. * diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpReadableResultsIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpReadableResultsIntegrationTest.java new file mode 100644 index 0000000000..61434b1e23 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpReadableResultsIntegrationTest.java @@ -0,0 +1,72 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResponseComplete; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.assertResultReadable; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.toolPayload; +import static org.assertj.core.api.Assertions.assertThat; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import io.stargate.sgv2.jsonapi.api.model.command.CommandName; +import io.stargate.sgv2.jsonapi.config.feature.ApiFeature; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import io.vertx.core.json.JsonObject; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Checks readable results from the packaged endpoint without a client translating the response. */ +@QuarkusIntegrationTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class McpReadableResultsIntegrationTest { + private static final String TOOL_NAME = CommandName.Names.FIND_RERANKING_PROVIDERS; + + @Test + void successfulToolTextMatchesTheHttpResponse() { + // Provider discovery uses packaged configuration and does not need a database or provider call. + var result = callTool(true); + assertThat(result.getBoolean("isError")).isFalse(); + + var httpResponse = + given() + .contentType(ContentType.JSON) + .header("Token", "readable-result-test-credential") + .header(ApiFeature.RERANKING.httpHeaderName(), "true") + .body(Map.of(TOOL_NAME, Map.of())) + .post("/v1") + .then() + .statusCode(200) + .extract() + .asString(); + var expected = new JsonObject(httpResponse); + + assertThat(assertResultReadable(result)).isEqualTo(expected); + assertThat(result.getJsonObject("_meta").getJsonObject("status")) + .isEqualTo(expected.getJsonObject("status")); + } + + @Test + void failedToolReturnsReadableErrorsInACompleteResult() { + var result = callTool(false); + assertThat(result.getBoolean("isError")).isTrue(); + var errors = result.getJsonObject("structuredContent").getJsonArray("errors"); + assertThat(errors.getJsonObject(0).getString("errorCode")) + .isEqualTo(SchemaException.Code.MCP_FEATURE_NOT_ENABLED.name()); + assertThat(assertResultReadable(result)).isEqualTo(result.getJsonObject("structuredContent")); + } + + private JsonObject callTool(boolean mcpEnabled) { + var response = + request("tools/call") + .header("Token", "readable-result-test-credential") + .header(ApiFeature.MCP.httpHeaderName(), mcpEnabled) + .header(ApiFeature.RERANKING.httpHeaderName(), "true") + .header("Mcp-Name", TOOL_NAME) + .body(toolPayload(1, TOOL_NAME, Map.of())) + .post("/v1/mcp"); + return assertResponseComplete(response, 1); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java index f4d603f45e..b3ea762e0d 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpResourceTest.java @@ -239,7 +239,7 @@ private McpResource mcpResource( Instance tenantResolver, Instance embeddingCredentialsResolver) { return new McpResource( - mock(ObjectMapper.class), + new ObjectMapper(), commandProcessor, mock(MeterRegistry.class), mock(JsonProcessingMetricsReporter.class), diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/TableCommandToolsMcpIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/TableCommandToolsMcpIntegrationTest.java index 31cac658da..c3c7ccfc48 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/TableCommandToolsMcpIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/TableCommandToolsMcpIntegrationTest.java @@ -273,6 +273,7 @@ void testFindOneToolCall() { JsonArray embedding = doc.getJsonArray("embedding"); assertNotNull(embedding, "embedding should not be null"); assertEquals(5, embedding.size()); + assertEquals(0.1, embedding.getDouble(0), 1e-6); }, status -> { // Table findOne returns projectionSchema describing all returned columns diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/response/CommandResultToToolResponseTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/response/CommandResultToToolResponseTest.java index a6e0d02457..a31aa4ea37 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/response/CommandResultToToolResponseTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/response/CommandResultToToolResponseTest.java @@ -1,105 +1,220 @@ package io.stargate.sgv2.jsonapi.api.v1.response; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; import io.quarkiverse.mcp.server.MetaKey; +import io.quarkiverse.mcp.server.TextContent; import io.quarkiverse.mcp.server.ToolResponse; +import io.stargate.sgv2.jsonapi.api.model.command.CommandErrorFactory; import io.stargate.sgv2.jsonapi.api.model.command.CommandResult; import io.stargate.sgv2.jsonapi.api.model.command.CommandStatus; -import io.stargate.sgv2.jsonapi.api.model.command.ResponseData; import io.stargate.sgv2.jsonapi.api.model.command.tracing.RequestTracing; +import java.io.IOException; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; /** - * Unit tests for {@link CommandResult#toToolResponse()} verifying the CommandResult → ToolResponse - * mapping logic. + * Unit tests for {@link CommandResult#toToolResponse(ObjectMapper)} verifying readable content and + * the existing structured response mapping. */ public class CommandResultToToolResponseTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + @Test - void successWithStatusOnly() { - // Arrange: a DDL-style result with status and no errors or data + void successWithStatusOnly() throws Exception { var commandResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) .addStatus(CommandStatus.OK, 1) .build(); - // Act - ToolResponse response = commandResult.toToolResponse(); + ToolResponse response = commandResult.toToolResponse(objectMapper); - // Assert: no error, no content and structuredContent, status should be mapped into _meta - assertFalse(response.isError()); - assertThat(response.content()).isEmpty(); - assertNull(response.structuredContent()); - assertNotNull(response._meta()); - assertNotNull(response._meta().get(MetaKey.of("status"))); + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isNull(); + assertThat(response._meta()) + .containsExactlyEntriesOf(Map.of(MetaKey.of("status"), commandResult.status())); } @Test - void successWithStatusAndData() { - // Arrange: a result with data and status + void successWithStatusAndData() throws Exception { var commandResult = CommandResult.singleDocumentBuilder(RequestTracing.NO_OP) .addStatus(CommandStatus.DELETED_COUNT, 0) .addDocument(null) .build(); - // Act - ToolResponse response = commandResult.toToolResponse(); + ToolResponse response = commandResult.toToolResponse(objectMapper); - // Assert: no error, no content, data in structuredContent, status should be mapped into _meta - assertFalse(response.isError()); - assertThat(response.content()).isEmpty(); - assertThat(response.structuredContent()).isNotNull(); - assertInstanceOf(ResponseData.class, response.structuredContent()); - assertNotNull(response._meta()); - assertNotNull(response._meta().get(MetaKey.of("status"))); + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(commandResult.data()); + assertThat(response._meta()) + .containsExactlyEntriesOf(Map.of(MetaKey.of("status"), commandResult.status())); } @Test - void failWithErrorOnly() { - // Arrange: a result that contains errors + void failWithErrorOnly() throws Exception { var commandResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) .addThrowable(new RuntimeException("test error")) .build(); - // Act - ToolResponse response = commandResult.toToolResponse(); + ToolResponse response = commandResult.toToolResponse(objectMapper); - // Assert: should be an error in structuredContent, no _meta and content - assertThat(response.content()).isEmpty(); + assertThat(response.isError()).isTrue(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(Map.of("errors", commandResult.errors())); assertThat(response._meta()).isEmpty(); - assertTrue(response.isError()); - assertThat(response.structuredContent()).isNotNull(); - @SuppressWarnings("unchecked") - var errorContent = (Map) response.structuredContent(); - assertThat(errorContent).containsKey("errors"); } @Test - void failWithErrorAndStatus() { - // Arrange: a result that contains errors and status + void failWithErrorAndStatus() throws Exception { var commandResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) .addStatus(CommandStatus.INSERTED_IDS, 1) .addThrowable(new RuntimeException("test error")) .build(); - // Act - ToolResponse response = commandResult.toToolResponse(); - - // Assert: no content, should be an error in structuredContent, and status in _meta - assertThat(response.content()).isEmpty(); - assertTrue(response.isError()); - assertThat(response.structuredContent()).isNotNull(); - @SuppressWarnings("unchecked") - var errorContent = (Map) response.structuredContent(); - assertThat(errorContent).containsKey("errors"); - assertNotNull(response._meta()); - assertNotNull(response._meta().get(MetaKey.of("status"))); + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isTrue(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(Map.of("errors", commandResult.errors())); + assertThat(response._meta()) + .containsExactlyEntriesOf(Map.of(MetaKey.of("status"), commandResult.status())); + } + + @Test + void successWithDocumentData() throws Exception { + var document = + objectMapper.createObjectNode().put("message", "Quotes: \"hello\"\nUnicode: café 🌟"); + document.putArray("values").add(0).add(true).addNull(); + var commandResult = + CommandResult.singleDocumentBuilder(RequestTracing.NO_OP).addDocument(document).build(); + + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(commandResult.data()); + assertThat(response._meta()).isEmpty(); + } + + @Test + void successWithMultipleDocumentsAndPagination() throws Exception { + var commandResult = + CommandResult.multiDocumentBuilder(RequestTracing.NO_OP) + .addDocument(objectMapper.createObjectNode().put("_id", "first")) + .addDocument(objectMapper.createObjectNode().put("_id", "second")) + .nextPageState("next-page") + .build(); + + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(commandResult.data()); + assertThat(response._meta()).isEmpty(); + } + + @Test + void successWithEmptyResult() throws Exception { + var commandResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP).build(); + + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isNull(); + assertThat(response._meta()).isEmpty(); + } + + @Test + void errorsDoNotHidePartialDataFromReadableContent() throws Exception { + var success = + CommandResult.multiDocumentBuilder(RequestTracing.NO_OP) + .addDocument(objectMapper.createObjectNode().put("_id", "successful-document")) + .addStatus(CommandStatus.INSERTED_IDS, List.of("successful-document")) + .build(); + var failure = + CommandResult.statusOnlyBuilder(RequestTracing.NO_OP) + .addThrowable(new RuntimeException("another document failed")) + .build(); + // The builder suppresses data on errors. The response mapper must also handle partial results. + var commandResult = new CommandResult(success.data(), success.status(), failure.errors()); + + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isTrue(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isEqualTo(Map.of("errors", commandResult.errors())); + assertThat(response._meta()) + .containsExactlyEntriesOf(Map.of(MetaKey.of("status"), commandResult.status())); + } + + @Test + void warningsRemainReadableWithoutMarkingResultAsError() throws Exception { + var commandResult = CommandResult.statusOnlyBuilder(RequestTracing.NO_OP).build(); + commandResult.addWarning(CommandErrorFactory.create(new RuntimeException("test warning"))); + + ToolResponse response = commandResult.toToolResponse(objectMapper); + + assertThat(response.isError()).isFalse(); + assertReadableResult(response, commandResult); + assertThat(response.structuredContent()).isNull(); + assertThat(response._meta()) + .containsExactlyEntriesOf(Map.of(MetaKey.of("status"), commandResult.status())); + } + + @Test + void serializationFailureReturnsSafeReadableError() { + String secret = "sensitive-payload-and-exception-details"; + var commandResult = + CommandResult.singleDocumentBuilder(RequestTracing.NO_OP) + .addDocument(objectMapper.createObjectNode().put("secret", secret)) + .addStatus(CommandStatus.INSERTED_IDS, List.of(secret)) + .build(); + var failingMapper = objectMapper.copy(); + failingMapper.registerModule( + new SimpleModule() + .addSerializer( + CommandResult.class, + new JsonSerializer() { + @Override + public void serialize( + CommandResult value, JsonGenerator generator, SerializerProvider provider) + throws IOException { + generator.writeStartObject(); + generator.writeStringField("partial", secret); + throw new IOException(secret); + } + })); + + ToolResponse response = commandResult.toToolResponse(failingMapper); + + assertThat(response.isError()).isTrue(); + assertThat(readableText(response)).isNotBlank().hasSizeLessThan(200).doesNotContain(secret); + assertThat(response.structuredContent()).isNull(); + assertThat(response._meta()).isNullOrEmpty(); + } + + private void assertReadableResult(ToolResponse response, CommandResult commandResult) + throws IOException { + assertThat(objectMapper.readTree(readableText(response))) + .isEqualTo(objectMapper.valueToTree(commandResult)); + } + + private String readableText(ToolResponse response) { + assertThat(response.content()).hasSize(1); + return assertInstanceOf(TextContent.class, response.content().getFirst()).text(); } }