diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/JsonDefinition.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/JsonDefinition.java
index ee40ee64c8..39608393e1 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/JsonDefinition.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/JsonDefinition.java
@@ -1,5 +1,6 @@
package io.stargate.sgv2.jsonapi.api.model.command;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.annotations.VisibleForTesting;
import java.util.Objects;
@@ -10,13 +11,15 @@
* intermediate form ({@link JsonNode}) from which actual deserialization and validation can be
* deferred until we have context we need
*
+ *
Fields are {@code @JsonIgnore} so OpenAPI and MCP describe only the clause JSON.
+ *
* @see io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition
* @see io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition
* @param Type of the value object to be deserialized from the JSON value
*/
public abstract class JsonDefinition {
/** The wrapped JSON value */
- private final JsonNode json;
+ @JsonIgnore private final JsonNode json;
protected JsonDefinition(JsonNode json) {
this.json = Objects.requireNonNull(json);
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/FilterDefinition.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/FilterDefinition.java
index 549173565d..63f1157721 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/FilterDefinition.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/FilterDefinition.java
@@ -1,6 +1,7 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.filter;
import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition;
@@ -14,18 +15,26 @@
* allows for lazy deserialization into a {@link FilterClause}.
*/
@Schema(
+ name = "FilterDefinition",
type = SchemaType.OBJECT,
- implementation = FilterClause.class,
- examples =
- """
- {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}}
- """)
+ // FilterClause describes parsed state and would incorrectly reserve fields such as empty.
+ implementation = Object.class,
+ nullable = true,
+ description = "A filter to select documents, see examples.",
+ examples = {
+ """
+ {"age": {"$gt": 40}}
+ """,
+ """
+ {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}}
+ """
+ })
public class FilterDefinition extends JsonDefinition {
/**
* Lazily deserialized {@link FilterClause} from the JSON value. We need this due to existing
* reliance on specific stateful instances of {@link FilterClause}.
*/
- private FilterClause filterClause;
+ @JsonIgnore private FilterClause filterClause;
/**
* To deserialize the whole JSON value, need to ensure DELEGATING mode (instead of PROPERTIES).
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/SortDefinition.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/SortDefinition.java
index 7b3ed5b177..88d21536fa 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/SortDefinition.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/filter/SortDefinition.java
@@ -1,6 +1,7 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.filter;
import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import io.stargate.sgv2.jsonapi.api.model.command.CommandContext;
@@ -17,18 +18,29 @@
* for lazy deserialization into a {@link SortClause}.
*/
@Schema(
+ name = "SortDefinition",
type = SchemaType.OBJECT,
- implementation = SortClause.class,
- examples =
- """
- {"user.age" : -1, "user.name" : 1}
- """)
+ implementation = Object.class,
+ nullable = true,
+ description =
+ "A sort to order selected documents. Supports sorting document fields by ascending or descending, or sorting by $vector, $vectorize, or $lexical. See examples.",
+ examples = {
+ """
+ {"user.age" : -1, "user.name" : 1}
+ """,
+ """
+ {"$vector": [0.1, 0.2, 0.3]}
+ """,
+ """
+ {"$vectorize": "search text"}
+ """
+ })
public class SortDefinition extends JsonDefinition {
/**
* Lazily deserialized {@link SortClause} from the JSON value. We need this due to existing
* reliance on specific stateful instances of {@link SortClause}.
*/
- private SortClause sortClause;
+ @JsonIgnore private SortClause sortClause;
/**
* To deserialize the whole JSON value, need to ensure DELEGATING mode (instead of PROPERTIES).
@@ -84,6 +96,7 @@ public boolean hasVsearchClause() {
* Helper method needed for logging purposes, where caller does not have access to the {@link
* CommandContext}.
*/
+ @JsonIgnore
public List getSortExpressionPaths() {
// We will either be wrapping the clause or have a json value:
if (sortClause != null) {
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java
index c0f02d31eb..efc1c8fd25 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java
@@ -1,11 +1,15 @@
package io.stargate.sgv2.jsonapi.api.model.command.clause.sort;
+import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.stargate.sgv2.jsonapi.metrics.CommandFeatures;
import io.stargate.sgv2.jsonapi.util.recordable.Recordable;
import java.util.*;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.media.SchemaProperty;
/**
* @param vectorizeSort If null then the $vectorize field was not present in the sort
@@ -19,18 +23,84 @@
*/
@JsonDeserialize(using = FindAndRerankSortClauseDeserializer.class)
@Schema(
+ name = "FindAndRerankSort",
type = SchemaType.OBJECT,
implementation = Map.class,
- examples =
- """
- {"$sort" : {"$hybrid" : "Same query for vectorize and bm25 sorting"}}}
- {"$sort" : {"$hybrid" : {"$vectorize" : "vectorize sort query" , "$lexical": "lexical sort" }}}
- {"$sort" : {"$hybrid" : {"$vector" : [1,2,3] , "$lexical": "lexical sort" }}}
- """)
+ nullable = true,
+ additionalProperties = Schema.False.class,
+ properties =
+ @SchemaProperty(
+ name = HYBRID_FIELD,
+ anyOf = {String.class, FindAndRerankSort.HybridSortSchema.class}),
+ description =
+ "Use $hybrid with a query string or an object containing $vectorize or $vector, and optionally $lexical."
+ + " $vector and $vectorize cannot be used together.",
+ examples = {
+ """
+ {"$hybrid": "Same query for vectorize and BM25 sorting"}
+ """,
+ """
+ {"$hybrid": {"$vectorize": "vectorize sort query", "$lexical": "lexical sort"}}
+ """,
+ """
+ {"$hybrid": {"$vector": [1, 2, 3], "$lexical": "lexical sort"}}
+ """,
+ """
+ {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}}
+ """
+ })
public record FindAndRerankSort(
- String vectorizeSort, String lexicalSort, float[] vectorSort, CommandFeatures commandFeatures)
+ @JsonIgnore String vectorizeSort,
+ @JsonIgnore String lexicalSort,
+ @JsonIgnore float[] vectorSort,
+ @JsonIgnore CommandFeatures commandFeatures)
implements Recordable {
+ // Schema-only classes. @SchemaProperty can point at a nested schema only through a class, so
+ // these empty classes carry the @Schema for each nested part of the sort. Nothing creates them.
+
+ /** The object form of $hybrid, with $vector or $vectorize and an optional $lexical. */
+ @Schema(
+ name = "HybridSort",
+ type = SchemaType.OBJECT,
+ implementation = Object.class,
+ additionalProperties = Schema.False.class,
+ properties = {
+ @SchemaProperty(
+ name = VECTOR_EMBEDDING_TEXT_FIELD,
+ type = SchemaType.STRING,
+ nullable = true),
+ @SchemaProperty(name = LEXICAL_CONTENT_FIELD, type = SchemaType.STRING, nullable = true),
+ @SchemaProperty(
+ name = VECTOR_EMBEDDING_FIELD,
+ nullable = true,
+ anyOf = {float[].class, BinaryVectorSchema.class})
+ },
+ not = ConflictingVectorSortSchema.class)
+ public static class HybridSortSchema {}
+
+ /** A binary encoded vector such as {"$binary": "P4AAAEAAAABAQAAA"}. */
+ @Schema(
+ name = "BinaryVector",
+ type = SchemaType.OBJECT,
+ implementation = Object.class,
+ nullable = true,
+ additionalProperties = Schema.False.class,
+ requiredProperties = BINARY_VECTOR_TEXT_FIELD,
+ properties = @SchemaProperty(name = BINARY_VECTOR_TEXT_FIELD, type = SchemaType.STRING))
+ public static class BinaryVectorSchema {}
+
+ /**
+ * Matches an object that has both $vector and $vectorize. HybridSort uses it as its {@code not}
+ * schema, so a sort that sets both fails validation.
+ */
+ @Schema(
+ name = "ConflictingVectorSort",
+ type = SchemaType.OBJECT,
+ implementation = Object.class,
+ requiredProperties = {VECTOR_EMBEDDING_FIELD, VECTOR_EMBEDDING_TEXT_FIELD})
+ public static class ConflictingVectorSortSchema {}
+
public static final FindAndRerankSort NO_ARG_SORT =
new FindAndRerankSort(null, null, null, CommandFeatures.EMPTY);
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java
index ae300a8d7d..0cab88a25e 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java
@@ -1,7 +1,13 @@
package io.stargate.sgv2.jsonapi.api.model.command.impl;
import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*;
+import static io.stargate.sgv2.jsonapi.config.constants.OpenApiConstants.Components.SCHEMA_REF_PREFIX;
+import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.AUTHENTICATION;
+import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.MODEL_NAME;
+import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.PARAMETERS;
+import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.PROVIDER;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.core.JsonParser;
@@ -28,10 +34,9 @@
import javax.annotation.Nullable;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
+import org.eclipse.microprofile.openapi.annotations.media.SchemaProperty;
-@Schema(
- description =
- "Finds documents using using vector and lexical sorting, then reranks the results.")
+@Schema(description = "Finds documents using vector and lexical sorting, then reranks the results.")
@JsonTypeName(CommandName.Names.FIND_AND_RERANK)
public record FindAndRerankCommand(
@Valid @JsonProperty("filter") FilterDefinition filterDefinition,
@@ -70,15 +75,7 @@ public record Options(
implementation = Integer.class)
Integer limit,
/** ---- */
- @Schema(
- description =
- "The maximum number of documents to read for the vector and lexical queries that feed into the reranking. May be a number or an object with $vector and $lexical fields. The accepted range is determined by server configuration.",
- examples =
- """
- {"hybridLimits" : 100}
- {"hybridLimits" : {"$vector" : 100, "$lexical" : 10}}
- """)
- HybridLimits hybridLimits,
+ @Schema(ref = HybridLimits.REF) HybridLimits hybridLimits,
/** ---- */
@Schema(
description =
@@ -117,18 +114,67 @@ public record Options(
"Optional override for the reranking service. Completely replaces the"
+ " collection-level reranking configuration when provided. Both 'provider' and"
+ " 'modelName' are required.",
- implementation = CreateCollectionCommand.Options.RerankServiceDesc.class)
+ implementation = RerankOverrideSchema.class,
+ ref = RerankOverrideSchema.REF)
@JsonProperty("rerank")
CreateCollectionCommand.Options.RerankServiceDesc rerankServiceOverride) {}
+ // Matches the override validation in CollectionRerankDef. Update both together.
+ @Schema(
+ name = RerankOverrideSchema.NAME,
+ type = SchemaType.OBJECT,
+ implementation = Object.class,
+ nullable = true,
+ additionalProperties = Schema.False.class,
+ requiredProperties = {PROVIDER, MODEL_NAME},
+ properties = {
+ @SchemaProperty(name = PROVIDER, type = SchemaType.STRING),
+ @SchemaProperty(name = MODEL_NAME, type = SchemaType.STRING),
+ @SchemaProperty(
+ name = AUTHENTICATION,
+ type = SchemaType.OBJECT,
+ nullable = true,
+ additionalProperties = String.class),
+ @SchemaProperty(
+ name = PARAMETERS,
+ type = SchemaType.OBJECT,
+ nullable = true,
+ additionalProperties = Schema.False.class)
+ },
+ examples =
+ """
+ {"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2"}
+ """)
+ public static class RerankOverrideSchema {
+ public static final String NAME = "RerankServiceOverride";
+ public static final String REF = SCHEMA_REF_PREFIX + NAME;
+ }
+
@JsonDeserialize(using = HybridLimitsDeserializer.class)
+ @Schema(
+ name = HybridLimits.NAME,
+ description =
+ "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits.",
+ // Void is the annotation default, but the OpenAPI scanner only sees values set in source.
+ // Setting it stops the scanner adding the record fields as an object type next to anyOf.
+ implementation = Void.class,
+ anyOf = {Integer.class, HybridLimits.ValuesSchema.class},
+ examples = {
+ "50",
+ """
+ {"$vector": 50, "$lexical": 10}
+ """
+ })
public record HybridLimits(
@JsonProperty(DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD) int vectorLimit,
/** ---- */
@JsonProperty(DocumentConstants.Fields.LEXICAL_CONTENT_FIELD) int lexicalLimit,
- CommandFeatures commandFeatures)
+ @JsonIgnore CommandFeatures commandFeatures)
implements Recordable {
+ public static final String NAME = "HybridLimits";
+ public static final String REF = SCHEMA_REF_PREFIX + NAME;
+
public static final HybridLimits DEFAULT =
new HybridLimits(
RerankingConstants.HybridSearchLimits.DEFAULT,
@@ -142,6 +188,19 @@ public DataRecorder recordTo(DataRecorder dataRecorder) {
.append("lexicalLimit", lexicalLimit)
.append("commandFeatures", commandFeatures);
}
+
+ /** Schema for the object form of the limits, the second branch of the anyOf above. */
+ @Schema(
+ name = "HybridLimitValues",
+ type = SchemaType.OBJECT,
+ nullable = true,
+ additionalProperties = Schema.False.class,
+ requiredProperties = {VECTOR_EMBEDDING_FIELD, LEXICAL_CONTENT_FIELD},
+ properties = {
+ @SchemaProperty(name = VECTOR_EMBEDDING_FIELD, type = SchemaType.INTEGER),
+ @SchemaProperty(name = LEXICAL_CONTENT_FIELD, type = SchemaType.INTEGER)
+ })
+ public static class ValuesSchema {}
}
/** Deserializer for the `hybridLimits` option, the limits for the inner reads. */
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java
index 529fc910ec..efb89bf227 100644
--- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java
@@ -148,16 +148,23 @@ public Uni estimatedDocumentCount(
return mcpResource.processCollectionCommand(keyspace, collection, command);
}
- @Tool(
- description =
- "Finds documents using using vector and lexical sorting, then reranks the results.")
+ @Tool(description = "Finds documents using vector and lexical sorting, then reranks the results.")
public Uni findAndRerank(
@ToolArg(description = "Name of the keyspace") String keyspace,
- @ToolArg(description = "Name of the collection/table") String collection,
- @ToolArg(description = "filter", required = false) FilterDefinition filter,
- @ToolArg(description = "projection", required = false) JsonNode projection,
- @ToolArg(description = "sort", required = false) FindAndRerankSort sort,
- @ToolArg(description = "options", required = false) FindAndRerankCommand.Options options) {
+ @ToolArg(description = "Name of the collection") String collection,
+ // filter and sort have no description here because it would replace the one on their
+ // classes, which Swagger also uses.
+ @ToolArg(required = false) FilterDefinition filter,
+ @ToolArg(
+ description =
+ "Fields to include or exclude in the returned documents, for example {\"title\": 1}",
+ required = false)
+ JsonNode projection,
+ @ToolArg(required = false) FindAndRerankSort sort,
+ @ToolArg(
+ description = "Options for result limits, reranking, and what the response includes",
+ required = false)
+ FindAndRerankCommand.Options options) {
var command = new FindAndRerankCommand(filter, projection, sort, options);
return mcpResource.processCollectionCommand(keyspace, collection, command);
diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java
new file mode 100644
index 0000000000..95207f1039
--- /dev/null
+++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java
@@ -0,0 +1,304 @@
+package io.stargate.sgv2.jsonapi.api.v1.mcp;
+
+import static io.stargate.sgv2.jsonapi.config.constants.OpenApiConstants.Components.SCHEMAS_POINTER;
+import static io.stargate.sgv2.jsonapi.config.constants.OpenApiConstants.Components.SCHEMA_REF_PREFIX;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import io.quarkiverse.mcp.server.GlobalInputSchemaGenerator;
+import io.quarkiverse.mcp.server.ToolManager.ToolInfo;
+import io.quarkus.smallrye.openapi.runtime.OpenApiConstants;
+import io.vertx.core.json.JsonObject;
+import jakarta.annotation.Priority;
+import jakarta.decorator.Decorator;
+import jakarta.decorator.Delegate;
+import jakarta.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+/**
+ * Makes each MCP tool input schema standalone by copying in the OpenAPI schemas it points to.
+ *
+ * {@link McpSchemaCustomizer} replaces the schema of each clause argument, such as filter or
+ * sort, with a pointer to the schema Quarkus generates for Swagger, so both APIs share one
+ * definition. An MCP tool's input schema is a standalone JSON Schema document, though, so a pointer
+ * into the OpenAPI document leads nowhere for an MCP client.
+ *
+ *
After the MCP library generates a tool schema, this class copies each OpenAPI schema the tool
+ * needs into the tool schema's {@code $defs}, and rewrites the pointers to match:
+ *
+ *
+ * before: {"properties": {"filter": {"$ref": "#/components/schemas/FilterDefinition"}}}
+ * after: {"properties": {"filter": {"$ref": "#/$defs/_openapi_FilterDefinition"}},
+ * "$defs": {"_openapi_FilterDefinition": (copy of the OpenAPI schema)}}
+ *
+ *
+ * Pointers stay pointers rather than being replaced by the schemas they name. Clause schemas can
+ * contain themselves, so replacing them would never finish, and keywords written next to a pointer
+ * would have to be merged into the copied schema.
+ */
+// @Decorator wraps the MCP library's generator instead of replacing it. CDI enables a decorator for
+// the whole application only when it has @Priority. The value orders decorators of the same bean,
+// and this is the only one.
+@Decorator
+@Priority(1)
+public class McpOpenApiSchemaAdapter implements GlobalInputSchemaGenerator {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final TypeReference