Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dfd5621
test: require MCP tool calls to close their command context
erichare Oct 13, 2026
04adc36
fix: close the MCP command context when a tool call ends
erichare Oct 13, 2026
777b416
Return readable MCP tool results
erichare Oct 22, 2026
1cafd99
Close MCP contexts after synchronous execution failures
erichare Oct 22, 2026
b94db2b
Merge branch 'fix/mcp-simplicity-update' into fix/mcp-simplicity-cleanup
erichare Oct 22, 2026
3b70fff
Merge branch 'fix/mcp-simplicity-cleanup' into fix/mcp-simplicity-res…
erichare Oct 22, 2026
b5a76f3
Reuse the MCP HTTP result assertions
erichare Oct 22, 2026
5dd1527
Merge branch 'fix/mcp-simplicity-update' into fix/mcp-simplicity-cleanup
erichare Oct 22, 2026
5f1b638
Merge branch 'fix/mcp-simplicity-cleanup' into fix/mcp-simplicity-res…
erichare Oct 22, 2026
9e34a16
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
d095c95
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
7ae20dc
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
72433ab
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
4190d11
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
5f34aae
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
44b013a
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
0255dc8
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
3d0750b
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
1dc19bc
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
fa69dd8
Merge branch 'fix/mcp-update-wire-schema' into fix/mcp-request-contex…
erichare Oct 23, 2026
f8b0f28
Clarify MCP context cleanup and remove a redundant assertion
erichare Oct 23, 2026
c7076f4
Merge branch 'fix/mcp-request-context-cleanup' into fix/mcp-readable-…
erichare Oct 23, 2026
4568b12
Remove an unrelated comment from MCP result coverage
erichare Oct 23, 2026
c4fdb21
Merge remote-tracking branch 'origin/fix/mcp-request-context-cleanup'…
erichare Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -101,29 +104,33 @@ public RestResponse<CommandResult> toRestResponse() {
/**
* Create a {@link ToolResponse} from this {@link CommandResult} for MCP tool responses.
*
* <p>Mapping rules:
*
* <ul>
* <li>{@link #errors()} → {@link ToolResponse#isError()} and error content in {@link
* ToolResponse#structuredContent()}
* <li>{@link #data()} → {@link ToolResponse#structuredContent()} (when no errors)
* <li>{@link #status()} → {@link ToolResponse#_meta()} with key {@code "status"}
* </ul>
* <p>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();

// Map "status" in CommandResult to _meta in ToolResponse
Map<MetaKey, Object> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public Uni<ToolResponse> 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) {
Expand Down Expand Up @@ -211,7 +211,8 @@ public Uni<ToolResponse> 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
Expand All @@ -221,7 +222,7 @@ public Uni<ToolResponse> processCommand(Uni<CommandContext<?>> contextUni, Comma

return contextUni
.flatMap(context -> executeCommand(context, command))
.map(CommandResult::toToolResponse);
.map(result -> result.toToolResponse(objectMapper));
}

private Uni<CommandResult> executeCommand(CommandContext<?> context, Command command) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolResponse> 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));
};
}

Expand All @@ -168,11 +168,11 @@ protected Consumer<ToolResponse> assertStatusOnlyWithJson(Consumer<JsonObject> 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));
};
}

Expand All @@ -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<ToolResponse> assertErrorOnly(Consumer<JsonArray> errorsAssertions) {
return response -> {
assertThat(response.content()).isEmpty();
assertServerInfoOnly(response);
assertTrue(response.isError());
assertThat(response.structuredContent()).isNotNull();
Expand All @@ -203,30 +202,33 @@ protected Consumer<ToolResponse> assertErrorOnly(Consumer<JsonArray> 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<ToolResponse> assertDataOnly(Consumer<JsonObject> 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
Expand All @@ -235,17 +237,26 @@ protected Consumer<ToolResponse> assertDataAndStatus(
Consumer<JsonObject> dataAssertions, Consumer<JsonObject> statusAssertions) {
return response -> {
assertFalse(response.isError());
assertThat(response.content()).isEmpty();
assertNotNull(response._meta());
assertNotNull(response.structuredContent());

var data = (JsonObject) response.structuredContent();
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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ private McpResource mcpResource(
Instance<RequestTenantResolver> tenantResolver,
Instance<EmbeddingCredentialsResolver> embeddingCredentialsResolver) {
return new McpResource(
mock(ObjectMapper.class),
new ObjectMapper(),
commandProcessor,
mock(MeterRegistry.class),
mock(JsonProcessingMetricsReporter.class),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading